@netrojs/fnetro 0.2.20 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.ts CHANGED
@@ -1,22 +1,21 @@
1
1
  // ─────────────────────────────────────────────────────────────────────────────
2
2
  // FNetro · server.ts
3
- // Hono app factory · SolidJS SSR · SEO head · asset manifest · Vite plugin
3
+ // Hono app factory · Vue 3 streaming SSR · SEO head · asset manifest
4
+ // Vite plugin (dual-bundle: server SSR + client SPA)
4
5
  // ─────────────────────────────────────────────────────────────────────────────
5
6
 
6
7
  import { Hono } from 'hono'
7
- import { createComponent } from 'solid-js'
8
- import { renderToStringAsync, generateHydrationScript } from 'solid-js/web'
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'
9
11
  import {
10
- resolveRoutes, compilePath, matchPath,
11
- SPA_HEADER, STATE_KEY, PARAMS_KEY, SEO_KEY,
12
- type AppConfig, type ResolvedRoute, type LayoutDef,
13
- type SEOMeta, type HonoMiddleware,
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,
14
15
  } from './core'
15
- import type { Plugin, UserConfig, ConfigEnv, InlineConfig } from 'vite'
16
+ import { build, type Plugin, type InlineConfig, type UserConfig } from 'vite'
16
17
 
17
- // ══════════════════════════════════════════════════════════════════════════════
18
- // § 1 HTML helpers
19
- // ══════════════════════════════════════════════════════════════════════════════
18
+ // ── HTML helpers ──────────────────────────────────────────────────────────────
20
19
 
21
20
  function esc(s: string): string {
22
21
  return s
@@ -26,18 +25,14 @@ function esc(s: string): string {
26
25
  .replace(/"/g, '"')
27
26
  }
28
27
 
29
- // ══════════════════════════════════════════════════════════════════════════════
30
- // § 2 SEO → <head> HTML
31
- // ══════════════════════════════════════════════════════════════════════════════
28
+ // ── SEO → <head> HTML ─────────────────────────────────────────────────────────
32
29
 
33
30
  function buildHeadMeta(seo: SEOMeta, extraHead = ''): string {
34
31
  const m = (n: string, v?: string) => v ? `<meta name="${n}" content="${esc(v)}">` : ''
35
32
  const p = (pr: string, v?: string) => v ? `<meta property="${pr}" content="${esc(v)}">` : ''
36
33
  const lk = (rel: string, href: string) => `<link rel="${rel}" href="${esc(href)}">`
37
-
38
34
  const parts: string[] = []
39
35
 
40
- // Basic
41
36
  if (seo.description) parts.push(m('description', seo.description))
42
37
  if (seo.keywords) parts.push(m('keywords', seo.keywords))
43
38
  if (seo.author) parts.push(m('author', seo.author))
@@ -45,44 +40,25 @@ function buildHeadMeta(seo: SEOMeta, extraHead = ''): string {
45
40
  if (seo.themeColor) parts.push(m('theme-color', seo.themeColor))
46
41
  if (seo.canonical) parts.push(lk('canonical', seo.canonical))
47
42
 
48
- // Open Graph
49
- if (seo.ogTitle) parts.push(p('og:title', seo.ogTitle))
50
- if (seo.ogDescription) parts.push(p('og:description', seo.ogDescription))
51
- if (seo.ogImage) parts.push(p('og:image', seo.ogImage))
52
- if (seo.ogImageAlt) parts.push(p('og:image:alt', seo.ogImageAlt))
53
- if (seo.ogImageWidth) parts.push(p('og:image:width', seo.ogImageWidth))
54
- if (seo.ogImageHeight) parts.push(p('og:image:height', seo.ogImageHeight))
55
- if (seo.ogUrl) parts.push(p('og:url', seo.ogUrl))
56
- if (seo.ogType) parts.push(p('og:type', seo.ogType))
57
- if (seo.ogSiteName) parts.push(p('og:site_name', seo.ogSiteName))
58
- if (seo.ogLocale) parts.push(p('og:locale', seo.ogLocale))
59
-
60
- // Twitter / X
61
- if (seo.twitterCard) parts.push(m('twitter:card', seo.twitterCard))
62
- if (seo.twitterSite) parts.push(m('twitter:site', seo.twitterSite))
63
- if (seo.twitterCreator) parts.push(m('twitter:creator', seo.twitterCreator))
64
- if (seo.twitterTitle) parts.push(m('twitter:title', seo.twitterTitle))
65
- if (seo.twitterDescription) parts.push(m('twitter:description', seo.twitterDescription))
66
- if (seo.twitterImage) parts.push(m('twitter:image', seo.twitterImage))
67
- if (seo.twitterImageAlt) parts.push(m('twitter:image:alt', seo.twitterImageAlt))
68
-
69
- // Arbitrary extra <meta> tags
70
- for (const tag of seo.extra ?? []) {
71
- const attrs = [
72
- tag.name ? `name="${esc(tag.name)}"` : '',
73
- tag.property ? `property="${esc(tag.property)}"` : '',
74
- tag.httpEquiv ? `http-equiv="${esc(tag.httpEquiv)}"` : '',
75
- `content="${esc(tag.content)}"`,
76
- ].filter(Boolean).join(' ')
77
- parts.push(`<meta ${attrs}>`)
78
- }
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))
79
56
 
80
- // JSON-LD structured data
81
57
  const ld = seo.jsonLd
82
58
  if (ld) {
83
59
  const schemas = Array.isArray(ld) ? ld : [ld]
84
- for (const schema of schemas) {
85
- parts.push(`<script type="application/ld+json">${JSON.stringify(schema)}</script>`)
60
+ for (const s of schemas) {
61
+ parts.push(`<script type="application/ld+json">${JSON.stringify(s)}</script>`)
86
62
  }
87
63
  }
88
64
 
@@ -90,223 +66,225 @@ function buildHeadMeta(seo: SEOMeta, extraHead = ''): string {
90
66
  return parts.join('\n')
91
67
  }
92
68
 
93
- function mergeSEO(base: SEOMeta | undefined, override: SEOMeta | undefined): SEOMeta {
69
+ function mergeSEO(base?: SEOMeta, override?: SEOMeta): SEOMeta {
94
70
  return { ...(base ?? {}), ...(override ?? {}) }
95
71
  }
96
72
 
97
- // ══════════════════════════════════════════════════════════════════════════════
98
- // § 3 Asset resolution — dev vs production
99
- // ══════════════════════════════════════════════════════════════════════════════
73
+ // ── Asset resolution ──────────────────────────────────────────────────────────
100
74
 
101
75
  export interface AssetConfig {
102
- /** Explicit script URLs injected into every HTML page. */
103
76
  scripts?: string[]
104
- /** Explicit stylesheet URLs injected into every HTML page. */
105
77
  styles?: string[]
106
- /**
107
- * Directory that contains the Vite-generated `manifest.json`.
108
- * When provided, asset URLs are resolved from the manifest so hashed
109
- * filenames work correctly. Typically equals `clientOutDir`.
110
- */
78
+ /** Directory containing the Vite-built assets and .vite/manifest.json. */
111
79
  manifestDir?: string
112
- /**
113
- * Key in the manifest corresponding to the client entry file.
114
- * @default `'client.ts'`
115
- */
116
80
  manifestEntry?: string
117
81
  }
118
82
 
119
83
  interface ResolvedAssets { scripts: string[]; styles: string[] }
120
84
 
121
- // Process-lifetime cache — resolved once on first request.
122
- let _assets: ResolvedAssets | null = null
85
+ // Process-level cache — resolved once on first production request.
86
+ let _assetsCache: ResolvedAssets | null = null
123
87
 
124
- /**
125
- * Read the Vite manifest to resolve hashed asset filenames.
126
- * Uses dynamic `import()` so this never runs at module-load time and
127
- * never adds a hard dependency on `node:fs` for edge runtimes.
128
- * Falls back to explicit `cfg.scripts` / `cfg.styles` on any error.
129
- */
130
- async function resolveAssets(
131
- cfg: AssetConfig,
132
- defaultEntry: string,
133
- ): Promise<ResolvedAssets> {
134
- if (_assets) return _assets
88
+ async function resolveAssets(cfg: AssetConfig, defaultEntry: string): Promise<ResolvedAssets> {
89
+ if (_assetsCache) return _assetsCache
135
90
 
136
91
  if (cfg.manifestDir) {
137
92
  try {
138
- // Dynamic imports — safe to use in any ESM environment.
139
- // node:fs and node:path are marked external by tsup and never bundled.
140
93
  const [{ readFileSync }, { join }] = await Promise.all([
141
94
  import('node:fs'),
142
95
  import('node:path'),
143
96
  ])
144
- const raw = readFileSync(join(cfg.manifestDir, 'manifest.json'), 'utf-8')
97
+ // Vite 5+ writes manifest to <outDir>/.vite/manifest.json
98
+ const raw = readFileSync(join(cfg.manifestDir, '.vite', 'manifest.json'), 'utf-8')
145
99
  const manifest = JSON.parse(raw) as Record<string, { file: string; css?: string[] }>
146
- const entryKey =
147
- cfg.manifestEntry ??
148
- Object.keys(manifest).find(k => k.endsWith(defaultEntry)) ??
149
- defaultEntry
150
- const entry = manifest[entryKey]
100
+ const key = cfg.manifestEntry
101
+ ?? Object.keys(manifest).find(k => k.endsWith(defaultEntry))
102
+ ?? defaultEntry
103
+ const entry = manifest[key]
151
104
  if (entry) {
152
- _assets = {
105
+ _assetsCache = {
153
106
  scripts: [`/assets/${entry.file}`],
154
107
  styles: (entry.css ?? []).map((f: string) => `/assets/${f}`),
155
108
  }
156
- return _assets
109
+ return _assetsCache
157
110
  }
158
- } catch { /* edge runtime or manifest not found — fall through */ }
111
+ } catch { /* manifest missing or malformed — fall through */ }
159
112
  }
160
113
 
161
- _assets = {
114
+ _assetsCache = {
162
115
  scripts: cfg.scripts ?? ['/assets/client.js'],
163
116
  styles: cfg.styles ?? [],
164
117
  }
165
- return _assets
118
+ return _assetsCache
166
119
  }
167
120
 
168
- // ══════════════════════════════════════════════════════════════════════════════
169
- // § 4 HTML shell
170
- // ══════════════════════════════════════════════════════════════════════════════
171
-
172
- interface ShellOpts {
173
- title: string
174
- metaHtml: string
175
- bodyHtml: string
176
- stateJson: string
177
- paramsJson: string
178
- seoJson: string
179
- scripts: string[]
180
- styles: string[]
181
- htmlAttrs?: Record<string, string>
121
+ // ── HTML shell parts ──────────────────────────────────────────────────────────
122
+
123
+ interface ShellParts {
124
+ head: string // everything up to and including the opening <div id="fnetro-app">
125
+ tail: string // everything after the closing </div>
182
126
  }
183
127
 
184
- function buildShell(o: ShellOpts): string {
185
- const htmlAttrStr = Object.entries(o.htmlAttrs ?? { lang: 'en' })
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' })
186
139
  .map(([k, v]) => `${k}="${esc(v)}"`)
187
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')
188
143
 
189
- const styleLinks = o.styles
190
- .map(href => `<link rel="stylesheet" href="${esc(href)}">`)
191
- .join('\n')
192
-
193
- const scriptTags = o.scripts
194
- .map(src => `<script type="module" src="${esc(src)}"></script>`)
195
- .join('\n')
196
-
197
- return [
144
+ const head = [
198
145
  '<!DOCTYPE html>',
199
- `<html ${htmlAttrStr}>`,
146
+ `<html ${attrs}>`,
200
147
  '<head>',
201
148
  '<meta charset="UTF-8">',
202
149
  '<meta name="viewport" content="width=device-width,initial-scale=1">',
203
- `<title>${esc(o.title)}</title>`,
204
- o.metaHtml,
205
- generateHydrationScript(),
150
+ `<title>${esc(title)}</title>`,
151
+ metaHtml,
206
152
  styleLinks,
207
153
  '</head>',
208
154
  '<body>',
209
- `<div id="fnetro-app">${o.bodyHtml}</div>`,
155
+ '<div id="fnetro-app">',
156
+ ].filter(Boolean).join('\n')
157
+
158
+ const tail = [
159
+ '</div>',
210
160
  '<script>',
211
- `window.${STATE_KEY}=${o.stateJson};`,
212
- `window.${PARAMS_KEY}=${o.paramsJson};`,
213
- `window.${SEO_KEY}=${o.seoJson};`,
161
+ `window.${STATE_KEY}=${stateJson};`,
162
+ `window.${PARAMS_KEY}=${paramsJson};`,
163
+ `window.${SEO_KEY}=${seoJson};`,
214
164
  '</script>',
215
165
  scriptTags,
216
166
  '</body>',
217
167
  '</html>',
218
- ]
219
- .filter(Boolean)
220
- .join('\n')
168
+ ].join('\n')
169
+
170
+ return { head, tail }
221
171
  }
222
172
 
223
- // ══════════════════════════════════════════════════════════════════════════════
224
- // § 5 SolidJS SSR renderer
225
- // ══════════════════════════════════════════════════════════════════════════════
173
+ // ── Async component resolution ────────────────────────────────────────────────
226
174
 
227
- type AnyComponent = Parameters<typeof createComponent>[0]
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
+ }
228
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
+ * Performance approach:
191
+ * 1. Run loader + build <head> HTML synchronously.
192
+ * 2. Return a streaming Response so the browser receives and processes
193
+ * <head> (CSS links, critical scripts) while the body is still rendering.
194
+ */
229
195
  async function renderPage(
230
196
  route: ResolvedRoute,
231
197
  data: object,
232
198
  url: string,
233
199
  params: Record<string, string>,
234
200
  appLayout: LayoutDef | undefined,
235
- ): Promise<string> {
201
+ ): Promise<ReadableStream<Uint8Array>> {
236
202
  const layout = route.layout !== undefined ? route.layout : appLayout
237
203
 
238
- return renderToStringAsync(() => {
239
- const pageEl = createComponent(route.page.Page as AnyComponent, { ...data, url, params })
240
- if (!layout) return pageEl as any
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: 'FNetroRoute',
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)
241
219
 
242
- return createComponent(layout.Component as AnyComponent, {
243
- url,
244
- params,
245
- get children() { return pageEl },
246
- }) as any
220
+ const router = createRouter({
221
+ history: createMemoryHistory(),
222
+ routes: [{ path: toVueRouterPath(route.fullPath), component: routeComp }],
247
223
  })
224
+ app.use(router)
225
+
226
+ await router.push(url)
227
+ await router.isReady()
228
+
229
+ // renderToWebStream streams body chunks as Uint8Array — lower TTFB vs
230
+ // renderToString (which buffers the entire body before responding).
231
+ return renderToWebStream(app)
248
232
  }
249
233
 
250
- async function renderFullPage(
251
- route: ResolvedRoute,
252
- data: object,
253
- url: string,
254
- params: Record<string, string>,
255
- config: AppConfig,
256
- assets: ResolvedAssets,
257
- ): Promise<string> {
258
- const pageSEO = typeof route.page.seo === 'function'
259
- ? route.page.seo(data as any, params)
260
- : route.page.seo
261
- const seo = mergeSEO(config.seo, pageSEO)
262
- const title = seo.title ?? 'FNetro'
263
-
264
- const bodyHtml = await renderPage(route, data, url, params, config.layout)
265
-
266
- return buildShell({
267
- title,
268
- metaHtml: buildHeadMeta(seo, config.head),
269
- bodyHtml,
270
- stateJson: JSON.stringify({ [url]: data }),
271
- paramsJson: JSON.stringify(params),
272
- seoJson: JSON.stringify(seo),
273
- scripts: assets.scripts,
274
- styles: assets.styles,
275
- htmlAttrs: config.htmlAttrs,
276
- })
234
+ /** Prepend `head` and append `tail` around Vue's streaming body. */
235
+ function buildResponseStream(
236
+ headHtml: string,
237
+ bodyStream: ReadableStream<Uint8Array>,
238
+ tailHtml: string,
239
+ ): ReadableStream<Uint8Array> {
240
+ const enc = new TextEncoder()
241
+ const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()
242
+
243
+ ;(async () => {
244
+ const writer = writable.getWriter()
245
+ try {
246
+ await writer.write(enc.encode(headHtml))
247
+ const reader = bodyStream.getReader()
248
+ while (true) {
249
+ const { done, value } = await reader.read()
250
+ if (done) break
251
+ await writer.write(value)
252
+ }
253
+ await writer.write(enc.encode(tailHtml))
254
+ await writer.close()
255
+ } catch (err) {
256
+ await writer.abort(err)
257
+ }
258
+ })()
259
+
260
+ return readable
277
261
  }
278
262
 
279
- // ══════════════════════════════════════════════════════════════════════════════
280
- // § 6 createFNetro
281
- // ══════════════════════════════════════════════════════════════════════════════
263
+ // ── createFNetro ──────────────────────────────────────────────────────────────
282
264
 
283
265
  export interface FNetroOptions extends AppConfig {
284
- /**
285
- * Production asset configuration.
286
- * In dev mode `@hono/vite-dev-server` injects assets automatically — ignored.
287
- */
288
266
  assets?: AssetConfig
289
267
  }
290
268
 
291
269
  export interface FNetroApp {
292
- /** The underlying Hono instance — attach custom routes, error handlers, etc. */
270
+ /** The Hono instance — attach extra routes, error handlers, middleware. */
293
271
  app: Hono
294
- /** Fetch handler for edge runtimes */
272
+ /** WinterCG-compatible fetch handler for edge runtimes. */
295
273
  handler: typeof Hono.prototype.fetch
296
274
  }
297
275
 
298
276
  export function createFNetro(config: FNetroOptions): FNetroApp {
299
277
  const app = new Hono()
300
278
 
301
- // Global middleware
279
+ // Global middleware (runs before every route)
302
280
  for (const mw of config.middleware ?? []) app.use('*', mw)
303
281
 
304
282
  const { pages, apis } = resolveRoutes(config.routes, {
305
- layout: config.layout,
283
+ ...(config.layout !== undefined && { layout: config.layout }),
306
284
  middleware: [],
307
285
  })
308
286
 
309
- // Pre-compile all route paths
287
+ // Pre-compile path patterns — avoids recompiling on every request
310
288
  const compiled = pages.map(r => ({ route: r, cp: compilePath(r.fullPath) }))
311
289
 
312
290
  // Register API sub-apps before the catch-all page handler
@@ -316,14 +294,13 @@ export function createFNetro(config: FNetroOptions): FNetroApp {
316
294
  app.route(api.path, sub)
317
295
  }
318
296
 
319
- // Catch-all page handler — must come AFTER API routes
320
297
  app.all('*', async (c) => {
321
298
  const url = new URL(c.req.url)
322
299
  const pathname = url.pathname
323
300
  const isSPA = c.req.header(SPA_HEADER) === '1'
324
301
  const isDev = process.env['NODE_ENV'] !== 'production'
325
302
 
326
- // Match route
303
+ // Route matching
327
304
  let matched: { route: ResolvedRoute; params: Record<string, string> } | null = null
328
305
  for (const { route, cp } of compiled) {
329
306
  const params = matchPath(cp, pathname)
@@ -332,13 +309,8 @@ export function createFNetro(config: FNetroOptions): FNetroApp {
332
309
 
333
310
  if (!matched) {
334
311
  if (config.notFound) {
335
- const html = await renderToStringAsync(
336
- () => createComponent(config.notFound as AnyComponent, {}) as any,
337
- )
338
- return c.html(
339
- `<!DOCTYPE html><html lang="en"><body>${html}</body></html>`,
340
- 404,
341
- )
312
+ const html = await renderToString(createSSRApp(config.notFound))
313
+ return c.html(`<!DOCTYPE html><html lang="en"><body>${html}</body></html>`, 404)
342
314
  }
343
315
  return c.text('Not Found', 404)
344
316
  }
@@ -352,25 +324,24 @@ export function createFNetro(config: FNetroOptions): FNetroApp {
352
324
  ? (params[key] ?? origParam(key))
353
325
  : { ...origParam(), ...params }
354
326
 
355
- // Route-level middleware chain (Hono onion model)
356
- let early: Response | undefined
357
- const handlers = [...route.middleware]
327
+ // Route-level middleware chain (run in order, short-circuit on early response)
328
+ let earlyResponse: Response | undefined
358
329
  let idx = 0
359
330
  const runNext = async (): Promise<void> => {
360
- const mw = handlers[idx++]
331
+ const mw = route.middleware[idx++]
361
332
  if (!mw) return
362
333
  const res = await mw(c, runNext)
363
- if (res instanceof Response && !early) early = res
334
+ if (res instanceof Response && !earlyResponse) earlyResponse = res
364
335
  }
365
336
  await runNext()
366
- if (early) return early
337
+ if (earlyResponse) return earlyResponse
367
338
 
368
339
  // Run loader
369
340
  const rawData = route.page.loader ? await route.page.loader(c) : {}
370
341
  const data = (rawData ?? {}) as object
371
342
 
343
+ // ── SPA navigation: return JSON only ─────────────────────────────────────
372
344
  if (isSPA) {
373
- // SPA navigation — return JSON payload only
374
345
  const pageSEO = typeof route.page.seo === 'function'
375
346
  ? route.page.seo(data as any, params)
376
347
  : route.page.seo
@@ -382,27 +353,44 @@ export function createFNetro(config: FNetroOptions): FNetroApp {
382
353
  })
383
354
  }
384
355
 
385
- // Full SSR resolve assets
386
- // Dev: inject the client entry as a module script. Vite intercepts the
387
- // request, applies the SolidJS transform, and injects HMR.
388
- // @hono/vite-dev-server only adds /@vite/client — it does NOT add
389
- // your app's client.ts, so we must do it here.
390
- // Prod: read hashed filenames from the Vite manifest.
356
+ // ── Full SSR: stream HTML response ────────────────────────────────────────
391
357
  const clientEntry = config.assets?.manifestEntry ?? 'client.ts'
392
358
  const assets = isDev
393
- ? { scripts: [`/${clientEntry}`], styles: [] }
359
+ ? { scripts: [`/${clientEntry}`], styles: [] as string[] }
394
360
  : await resolveAssets(config.assets ?? {}, clientEntry)
395
361
 
396
- const html = await renderFullPage(route, data, pathname, params, config, assets)
397
- return c.html(html)
362
+ const pageSEO = typeof route.page.seo === 'function'
363
+ ? route.page.seo(data as any, params)
364
+ : route.page.seo
365
+ const seo = mergeSEO(config.seo, pageSEO)
366
+ const title = seo.title ?? 'FNetro'
367
+
368
+ const { head, tail } = buildShellParts(
369
+ title,
370
+ buildHeadMeta(seo, config.head),
371
+ JSON.stringify({ [pathname]: data }),
372
+ JSON.stringify(params),
373
+ JSON.stringify(seo),
374
+ assets.scripts,
375
+ assets.styles,
376
+ config.htmlAttrs,
377
+ )
378
+
379
+ // Render the body asynchronously while the head is already on the wire
380
+ const bodyStream = await renderPage(route, data, pathname, params, config.layout)
381
+ const stream = buildResponseStream(head, bodyStream, tail)
382
+
383
+ return c.body(stream, 200, {
384
+ 'Content-Type': 'text/html; charset=UTF-8',
385
+ 'Transfer-Encoding': 'chunked',
386
+ 'X-Content-Type-Options': 'nosniff',
387
+ })
398
388
  })
399
389
 
400
390
  return { app, handler: app.fetch.bind(app) }
401
391
  }
402
392
 
403
- // ══════════════════════════════════════════════════════════════════════════════
404
- // § 7 Multi-runtime serve()
405
- // ══════════════════════════════════════════════════════════════════════════════
393
+ // ── serve() ───────────────────────────────────────────────────────────────────
406
394
 
407
395
  export type Runtime = 'node' | 'bun' | 'deno' | 'edge'
408
396
 
@@ -418,7 +406,7 @@ export interface ServeOptions {
418
406
  port?: number
419
407
  hostname?: string
420
408
  runtime?: Runtime
421
- /** Root directory for static file serving. @default `'./dist'` */
409
+ /** Root directory that contains the built assets and public files. */
422
410
  staticDir?: string
423
411
  }
424
412
 
@@ -428,9 +416,7 @@ export async function serve(opts: ServeOptions): Promise<void> {
428
416
  const hostname = opts.hostname ?? '0.0.0.0'
429
417
  const staticDir = opts.staticDir ?? './dist'
430
418
  const displayHost = hostname === '0.0.0.0' ? 'localhost' : hostname
431
-
432
- const logReady = () =>
433
- console.log(`\n🔥 FNetro [${runtime}] ready → http://${displayHost}:${port}\n`)
419
+ const logReady = () => console.log(`\n🔥 FNetro [${runtime}] → http://${displayHost}:${port}\n`)
434
420
 
435
421
  switch (runtime) {
436
422
  case 'node': {
@@ -444,169 +430,112 @@ export async function serve(opts: ServeOptions): Promise<void> {
444
430
  logReady()
445
431
  break
446
432
  }
447
- case 'bun': {
433
+ case 'bun':
448
434
  ;(globalThis as any)['Bun'].serve({ fetch: opts.app.handler, port, hostname })
449
435
  logReady()
450
436
  break
451
- }
452
- case 'deno': {
437
+ case 'deno':
453
438
  ;(globalThis as any)['Deno'].serve({ port, hostname }, opts.app.handler)
454
439
  logReady()
455
440
  break
456
- }
457
441
  default:
458
- console.warn(
459
- '[fnetro] serve() is a no-op on edge runtimes — export `fnetro.handler` instead.',
460
- )
442
+ console.warn('[fnetro] serve() is a no-op on edge — export fnetro.handler instead.')
461
443
  }
462
444
  }
463
445
 
464
- // ══════════════════════════════════════════════════════════════════════════════
465
- // § 8 Vite plugin
466
- // ══════════════════════════════════════════════════════════════════════════════
446
+ // ── Vite plugin ───────────────────────────────────────────────────────────────
447
+ //
448
+ // Design:
449
+ // • The user's vite.config.ts already includes vue() from @vitejs/plugin-vue.
450
+ // That plugin handles .vue transforms in both dev mode and the server build.
451
+ // • fnetroVitePlugin() only handles build orchestration:
452
+ // - `vite build` → server SSR bundle (dist/server/server.js)
453
+ // - `closeBundle` → client SPA bundle (dist/assets/… + .vite/manifest.json)
454
+ //
455
+ // This keeps the plugin simple and avoids fragile hook-proxying.
467
456
 
468
457
  const NODE_BUILTINS =
469
458
  /^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)$/
470
459
 
471
460
  export interface FNetroPluginOptions {
472
- /** Server entry file. @default `'server.ts'` */
461
+ /** Server entry file. @default 'server.ts' */
473
462
  serverEntry?: string
474
- /** Client entry file. @default `'client.ts'` */
463
+ /** Client entry file. @default 'client.ts' */
475
464
  clientEntry?: string
476
- /** Server bundle output directory. @default `'dist/server'` */
465
+ /** Server bundle output dir. @default 'dist/server' */
477
466
  serverOutDir?: string
478
- /** Client assets output directory. @default `'dist/assets'` */
467
+ /** Client assets output dir. @default 'dist/assets' */
479
468
  clientOutDir?: string
480
- /** Extra packages to mark external in the server bundle. */
469
+ /** Extra packages external to the server bundle. */
481
470
  serverExternal?: string[]
482
- /** Extra options forwarded to `vite-plugin-solid`. */
483
- solidOptions?: Record<string, unknown>
471
+ /** Options forwarded to @vitejs/plugin-vue in the client build. */
472
+ vueOptions?: Record<string, unknown>
484
473
  }
485
474
 
486
- type SolidFactory = (opts?: Record<string, unknown>) => Plugin | Plugin[]
487
-
488
- async function loadSolid(): Promise<SolidFactory> {
489
- try {
490
- const mod = await import('vite-plugin-solid' as string)
491
- return (mod.default ?? mod) as SolidFactory
492
- } catch {
493
- throw new Error(
494
- '[fnetro] vite-plugin-solid is required.\n Install it: npm i -D vite-plugin-solid',
495
- )
496
- }
497
- }
498
-
499
- function toPlugins(v: Plugin | Plugin[]): Plugin[] {
500
- return Array.isArray(v) ? v : [v]
501
- }
502
-
503
- export function fnetroVitePlugin(opts: FNetroPluginOptions = {}): Plugin[] {
475
+ export function fnetroVitePlugin(opts: FNetroPluginOptions = {}): Plugin {
504
476
  const {
505
- serverEntry = 'server.ts',
506
- clientEntry = 'client.ts',
507
- serverOutDir = 'dist/server',
508
- clientOutDir = 'dist/assets',
477
+ serverEntry = 'server.ts',
478
+ clientEntry = 'client.ts',
479
+ serverOutDir = 'dist/server',
480
+ clientOutDir = 'dist/assets',
509
481
  serverExternal = [],
510
- solidOptions = {},
482
+ vueOptions = {},
511
483
  } = opts
512
484
 
513
- let _solid: SolidFactory | null = null
514
- let _solidPlugins: Plugin[] = []
515
-
516
- // ── Plugin 1: JSX config + lazy solid plugin load ─────────────────────────
517
- const jsxPlugin: Plugin = {
518
- name: 'fnetro:jsx',
519
- enforce: 'pre',
520
-
521
- // Sync config hook — must return Omit<UserConfig, 'plugins'> | null
522
- config(_cfg: UserConfig, _env: ConfigEnv): Omit<UserConfig, 'plugins'> | null {
523
- return {
524
- esbuild: {
525
- jsx: 'automatic',
526
- jsxImportSource: 'solid-js',
527
- },
528
- }
529
- },
530
-
531
- async buildStart() {
532
- if (!_solid) {
533
- _solid = await loadSolid()
534
- // ssr: true tells vite-plugin-solid to output hydratable markup
535
- _solidPlugins = toPlugins(_solid({ ssr: true, ...solidOptions }))
536
- }
537
- },
538
- }
539
-
540
- // ── Plugin 2: proxy solid transform hooks ────────────────────────────────
541
- const solidProxy: Plugin = {
542
- name: 'fnetro:solid-proxy',
543
- enforce: 'pre',
544
-
545
- async transform(code: string, id: string, options?: { ssr?: boolean }) {
546
- if (!_solidPlugins[0]?.transform) return null
547
- const hook = _solidPlugins[0].transform
548
- const fn = typeof hook === 'function' ? hook : (hook as any).handler
549
- if (!fn) return null
550
- return (fn as Function).call(this as any, code, id, options)
551
- },
552
-
553
- async resolveId(id: string) {
554
- if (!_solidPlugins[0]?.resolveId) return null
555
- const hook = _solidPlugins[0].resolveId
556
- const fn = typeof hook === 'function' ? hook : (hook as any).handler
557
- if (!fn) return null
558
- return (fn as Function).call(this as any, id, undefined, {})
559
- },
560
-
561
- async load(id: string) {
562
- if (!_solidPlugins[0]?.load) return null
563
- const hook = _solidPlugins[0].load
564
- const fn = typeof hook === 'function' ? hook : (hook as any).handler
565
- if (!fn) return null
566
- return (fn as Function).call(this as any, id, {})
567
- },
568
- }
569
-
570
- // ── Plugin 3: server SSR build + client build trigger ────────────────────
571
- const buildPlugin: Plugin = {
485
+ return {
572
486
  name: 'fnetro:build',
573
487
  apply: 'build',
574
488
  enforce: 'pre',
575
489
 
576
- // Sync config hook — Omit<UserConfig, 'plugins'> satisfies the ObjectHook constraint
577
- config(_cfg: UserConfig, _env: ConfigEnv): Omit<UserConfig, 'plugins'> {
490
+ // Server (SSR) bundle configuration
491
+ config(): Omit<UserConfig, 'plugins'> {
578
492
  return {
579
493
  build: {
580
494
  ssr: serverEntry,
581
495
  outDir: serverOutDir,
582
496
  rollupOptions: {
583
497
  input: serverEntry,
584
- output: {
585
- format: 'es',
586
- entryFileNames: 'server.js',
587
- },
498
+ output: { format: 'es', entryFileNames: 'server.js' },
588
499
  external: (id: string) =>
589
- NODE_BUILTINS.test(id) ||
590
- id === '@hono/node-server' ||
591
- id === '@hono/node-server/serve-static' ||
592
- serverExternal.includes(id),
500
+ NODE_BUILTINS.test(id)
501
+ || id === 'vue' || id.startsWith('vue/')
502
+ || id === 'vue-router'
503
+ || id === '@vue/server-renderer'
504
+ || id === '@vitejs/plugin-vue'
505
+ || id === '@hono/node-server'
506
+ || id === '@hono/node-server/serve-static'
507
+ || serverExternal.includes(id),
593
508
  },
594
509
  },
595
510
  }
596
511
  },
597
512
 
513
+ // After the server bundle is written, trigger the client SPA build
598
514
  async closeBundle() {
599
515
  console.log('\n⚡ FNetro: building client bundle…\n')
600
516
 
601
- const solid = _solid ?? await loadSolid()
602
- const { build } = await import('vite')
517
+ let vuePlugin: Plugin | Plugin[]
518
+ try {
519
+ const mod = await import('@vitejs/plugin-vue' as string)
520
+ const factory = (mod.default ?? mod) as (opts?: Record<string, unknown>) => Plugin | Plugin[]
521
+ vuePlugin = factory(vueOptions)
522
+ } catch {
523
+ throw new Error(
524
+ '[fnetro] @vitejs/plugin-vue is required for the client build.\n' +
525
+ ' Install: npm i -D @vitejs/plugin-vue',
526
+ )
527
+ }
528
+
529
+ const plugins = (
530
+ Array.isArray(vuePlugin) ? vuePlugin : [vuePlugin]
531
+ ) as NonNullable<InlineConfig['plugins']>
603
532
 
604
- // Client build — no SSR flag, solid compiles reactive primitives normally
605
- await (build as (c: InlineConfig) => Promise<unknown>)({
606
- configFile: false,
607
- plugins: toPlugins(solid({ ...solidOptions })) as InlineConfig['plugins'],
533
+ await build({
534
+ configFile: false as const,
535
+ plugins,
608
536
  build: {
609
537
  outDir: clientOutDir,
538
+ // Vite 5+ writes manifest to <outDir>/.vite/manifest.json
610
539
  manifest: true,
611
540
  rollupOptions: {
612
541
  input: clientEntry,
@@ -623,22 +552,18 @@ export function fnetroVitePlugin(opts: FNetroPluginOptions = {}): Plugin[] {
623
552
  console.log('✅ FNetro: both bundles ready\n')
624
553
  },
625
554
  }
626
-
627
- return [jsxPlugin, solidProxy, buildPlugin]
628
555
  }
629
556
 
630
- // ══════════════════════════════════════════════════════════════════════════════
631
- // § 9 Re-exports
632
- // ══════════════════════════════════════════════════════════════════════════════
557
+ // ── Re-exports ────────────────────────────────────────────────────────────────
633
558
 
634
559
  export {
635
- definePage, defineGroup, defineLayout, defineApiRoute,
636
- resolveRoutes, compilePath, matchPath,
637
- SPA_HEADER, STATE_KEY, PARAMS_KEY, SEO_KEY,
560
+ definePage, defineGroup, defineLayout, defineApiRoute, isAsyncLoader,
561
+ resolveRoutes, compilePath, matchPath, toVueRouterPath,
562
+ SPA_HEADER, STATE_KEY, PARAMS_KEY, SEO_KEY, DATA_KEY,
638
563
  } from './core'
639
564
 
640
565
  export type {
641
566
  AppConfig, PageDef, GroupDef, LayoutDef, ApiRouteDef, Route,
642
- PageProps, LayoutProps, SEOMeta, HonoMiddleware, LoaderCtx,
643
- ResolvedRoute, CompiledPath, ClientMiddleware,
644
- } from './core'
567
+ SEOMeta, HonoMiddleware, LoaderCtx, ResolvedRoute, CompiledPath,
568
+ ClientMiddleware, AsyncLoader,
569
+ } from './core'