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