@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/README.md +185 -878
- package/client.ts +218 -236
- package/core.ts +74 -175
- package/dist/client.d.ts +71 -49
- package/dist/client.js +176 -169
- package/dist/core.d.ts +57 -40
- package/dist/core.js +50 -28
- package/dist/server.d.ts +69 -66
- package/dist/server.js +178 -192
- package/dist/types.d.ts +99 -0
- package/package.json +21 -18
- package/server.ts +262 -337
- package/types.ts +125 -0
package/server.ts
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
2
|
// FNetro · server.ts
|
|
3
|
-
// Hono app factory ·
|
|
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 {
|
|
8
|
-
import {
|
|
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
|
|
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
|
-
|
|
49
|
-
if (seo.
|
|
50
|
-
if (seo.
|
|
51
|
-
if (seo.
|
|
52
|
-
if (seo.
|
|
53
|
-
if (seo.
|
|
54
|
-
if (seo.
|
|
55
|
-
|
|
56
|
-
if (seo.
|
|
57
|
-
if (seo.
|
|
58
|
-
if (seo.
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
85
|
-
parts.push(`<script type="application/ld+json">${JSON.stringify(
|
|
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
|
|
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-
|
|
122
|
-
let
|
|
85
|
+
// Process-level cache — resolved once on first production request.
|
|
86
|
+
let _assetsCache: ResolvedAssets | null = null
|
|
123
87
|
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
105
|
+
_assetsCache = {
|
|
153
106
|
scripts: [`/assets/${entry.file}`],
|
|
154
107
|
styles: (entry.css ?? []).map((f: string) => `/assets/${f}`),
|
|
155
108
|
}
|
|
156
|
-
return
|
|
109
|
+
return _assetsCache
|
|
157
110
|
}
|
|
158
|
-
} catch { /*
|
|
111
|
+
} catch { /* manifest missing or malformed — fall through */ }
|
|
159
112
|
}
|
|
160
113
|
|
|
161
|
-
|
|
114
|
+
_assetsCache = {
|
|
162
115
|
scripts: cfg.scripts ?? ['/assets/client.js'],
|
|
163
116
|
styles: cfg.styles ?? [],
|
|
164
117
|
}
|
|
165
|
-
return
|
|
118
|
+
return _assetsCache
|
|
166
119
|
}
|
|
167
120
|
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
185
|
-
|
|
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
|
|
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 ${
|
|
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(
|
|
204
|
-
|
|
205
|
-
generateHydrationScript(),
|
|
150
|
+
`<title>${esc(title)}</title>`,
|
|
151
|
+
metaHtml,
|
|
206
152
|
styleLinks,
|
|
207
153
|
'</head>',
|
|
208
154
|
'<body>',
|
|
209
|
-
|
|
155
|
+
'<div id="fnetro-app">',
|
|
156
|
+
].filter(Boolean).join('\n')
|
|
157
|
+
|
|
158
|
+
const tail = [
|
|
159
|
+
'</div>',
|
|
210
160
|
'<script>',
|
|
211
|
-
`window.${STATE_KEY}=${
|
|
212
|
-
`window.${PARAMS_KEY}=${
|
|
213
|
-
`window.${SEO_KEY}=${
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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<
|
|
201
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
236
202
|
const layout = route.layout !== undefined ? route.layout : appLayout
|
|
237
203
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
270
|
+
/** The Hono instance — attach extra routes, error handlers, middleware. */
|
|
293
271
|
app: Hono
|
|
294
|
-
/**
|
|
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:
|
|
283
|
+
...(config.layout !== undefined && { layout: config.layout }),
|
|
306
284
|
middleware: [],
|
|
307
285
|
})
|
|
308
286
|
|
|
309
|
-
// Pre-compile
|
|
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
|
-
//
|
|
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
|
|
336
|
-
|
|
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 (
|
|
356
|
-
let
|
|
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 =
|
|
331
|
+
const mw = route.middleware[idx++]
|
|
361
332
|
if (!mw) return
|
|
362
333
|
const res = await mw(c, runNext)
|
|
363
|
-
if (res instanceof Response && !
|
|
334
|
+
if (res instanceof Response && !earlyResponse) earlyResponse = res
|
|
364
335
|
}
|
|
365
336
|
await runNext()
|
|
366
|
-
if (
|
|
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
|
|
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
|
|
397
|
-
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
461
|
+
/** Server entry file. @default 'server.ts' */
|
|
473
462
|
serverEntry?: string
|
|
474
|
-
/** Client entry file.
|
|
463
|
+
/** Client entry file. @default 'client.ts' */
|
|
475
464
|
clientEntry?: string
|
|
476
|
-
/** Server bundle output
|
|
465
|
+
/** Server bundle output dir. @default 'dist/server' */
|
|
477
466
|
serverOutDir?: string
|
|
478
|
-
/** Client assets output
|
|
467
|
+
/** Client assets output dir. @default 'dist/assets' */
|
|
479
468
|
clientOutDir?: string
|
|
480
|
-
/** Extra packages
|
|
469
|
+
/** Extra packages external to the server bundle. */
|
|
481
470
|
serverExternal?: string[]
|
|
482
|
-
/**
|
|
483
|
-
|
|
471
|
+
/** Options forwarded to @vitejs/plugin-vue in the client build. */
|
|
472
|
+
vueOptions?: Record<string, unknown>
|
|
484
473
|
}
|
|
485
474
|
|
|
486
|
-
|
|
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
|
|
506
|
-
clientEntry
|
|
507
|
-
serverOutDir
|
|
508
|
-
clientOutDir
|
|
477
|
+
serverEntry = 'server.ts',
|
|
478
|
+
clientEntry = 'client.ts',
|
|
479
|
+
serverOutDir = 'dist/server',
|
|
480
|
+
clientOutDir = 'dist/assets',
|
|
509
481
|
serverExternal = [],
|
|
510
|
-
|
|
482
|
+
vueOptions = {},
|
|
511
483
|
} = opts
|
|
512
484
|
|
|
513
|
-
|
|
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
|
-
//
|
|
577
|
-
config(
|
|
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 === '
|
|
591
|
-
id === '
|
|
592
|
-
|
|
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
|
-
|
|
602
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
} from './core'
|
|
567
|
+
SEOMeta, HonoMiddleware, LoaderCtx, ResolvedRoute, CompiledPath,
|
|
568
|
+
ClientMiddleware, AsyncLoader,
|
|
569
|
+
} from './core'
|