@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/README.md +185 -878
- package/client.ts +213 -242
- package/core.ts +74 -175
- package/dist/client.d.ts +69 -60
- package/dist/client.js +170 -177
- 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 -199
- package/dist/types.d.ts +99 -0
- package/package.json +21 -20
- package/server.ts +263 -350
- package/types.ts +125 -0
package/server.ts
CHANGED
|
@@ -1,23 +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 {
|
|
9
|
-
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'
|
|
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
|
|
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
|
-
|
|
50
|
-
if (seo.
|
|
51
|
-
if (seo.
|
|
52
|
-
if (seo.
|
|
53
|
-
if (seo.
|
|
54
|
-
if (seo.
|
|
55
|
-
if (seo.
|
|
56
|
-
|
|
57
|
-
if (seo.
|
|
58
|
-
if (seo.
|
|
59
|
-
if (seo.
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
86
|
-
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>`)
|
|
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
|
|
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-
|
|
123
|
-
let
|
|
85
|
+
// Process-level cache — resolved once on first production request.
|
|
86
|
+
let _assetsCache: ResolvedAssets | null = null
|
|
124
87
|
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
105
|
+
_assetsCache = {
|
|
154
106
|
scripts: [`/assets/${entry.file}`],
|
|
155
107
|
styles: (entry.css ?? []).map((f: string) => `/assets/${f}`),
|
|
156
108
|
}
|
|
157
|
-
return
|
|
109
|
+
return _assetsCache
|
|
158
110
|
}
|
|
159
|
-
} catch { /*
|
|
111
|
+
} catch { /* manifest missing or malformed — fall through */ }
|
|
160
112
|
}
|
|
161
113
|
|
|
162
|
-
|
|
114
|
+
_assetsCache = {
|
|
163
115
|
scripts: cfg.scripts ?? ['/assets/client.js'],
|
|
164
116
|
styles: cfg.styles ?? [],
|
|
165
117
|
}
|
|
166
|
-
return
|
|
118
|
+
return _assetsCache
|
|
167
119
|
}
|
|
168
120
|
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
186
|
-
|
|
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
|
|
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 ${
|
|
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(
|
|
205
|
-
|
|
206
|
-
generateHydrationScript(),
|
|
150
|
+
`<title>${esc(title)}</title>`,
|
|
151
|
+
metaHtml,
|
|
207
152
|
styleLinks,
|
|
208
153
|
'</head>',
|
|
209
154
|
'<body>',
|
|
210
|
-
|
|
155
|
+
'<div id="fnetro-app">',
|
|
156
|
+
].filter(Boolean).join('\n')
|
|
157
|
+
|
|
158
|
+
const tail = [
|
|
159
|
+
'</div>',
|
|
211
160
|
'<script>',
|
|
212
|
-
`window.${STATE_KEY}=${
|
|
213
|
-
`window.${PARAMS_KEY}=${
|
|
214
|
-
`window.${SEO_KEY}=${
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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<
|
|
201
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
237
202
|
const layout = route.layout !== undefined ? route.layout : appLayout
|
|
238
203
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
270
|
+
/** The Hono instance — attach extra routes, error handlers, middleware. */
|
|
301
271
|
app: Hono
|
|
302
|
-
/**
|
|
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:
|
|
283
|
+
...(config.layout !== undefined && { layout: config.layout }),
|
|
314
284
|
middleware: [],
|
|
315
285
|
})
|
|
316
286
|
|
|
317
|
-
// Pre-compile
|
|
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
|
-
//
|
|
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
|
|
344
|
-
|
|
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 (
|
|
364
|
-
let
|
|
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 =
|
|
331
|
+
const mw = route.middleware[idx++]
|
|
369
332
|
if (!mw) return
|
|
370
333
|
const res = await mw(c, runNext)
|
|
371
|
-
if (res instanceof Response && !
|
|
334
|
+
if (res instanceof Response && !earlyResponse) earlyResponse = res
|
|
372
335
|
}
|
|
373
336
|
await runNext()
|
|
374
|
-
if (
|
|
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
|
|
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
|
|
405
|
-
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
461
|
+
/** Server entry file. @default 'server.ts' */
|
|
481
462
|
serverEntry?: string
|
|
482
|
-
/** Client entry file.
|
|
463
|
+
/** Client entry file. @default 'client.ts' */
|
|
483
464
|
clientEntry?: string
|
|
484
|
-
/** Server bundle output
|
|
465
|
+
/** Server bundle output dir. @default 'dist/server' */
|
|
485
466
|
serverOutDir?: string
|
|
486
|
-
/** Client assets output
|
|
467
|
+
/** Client assets output dir. @default 'dist/assets' */
|
|
487
468
|
clientOutDir?: string
|
|
488
|
-
/** Extra packages
|
|
469
|
+
/** Extra packages external to the server bundle. */
|
|
489
470
|
serverExternal?: string[]
|
|
490
|
-
/**
|
|
491
|
-
|
|
471
|
+
/** Options forwarded to @vitejs/plugin-vue in the client build. */
|
|
472
|
+
vueOptions?: Record<string, unknown>
|
|
492
473
|
}
|
|
493
474
|
|
|
494
|
-
|
|
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
|
|
514
|
-
clientEntry
|
|
515
|
-
serverOutDir
|
|
516
|
-
clientOutDir
|
|
477
|
+
serverEntry = 'server.ts',
|
|
478
|
+
clientEntry = 'client.ts',
|
|
479
|
+
serverOutDir = 'dist/server',
|
|
480
|
+
clientOutDir = 'dist/assets',
|
|
517
481
|
serverExternal = [],
|
|
518
|
-
|
|
482
|
+
vueOptions = {},
|
|
519
483
|
} = opts
|
|
520
484
|
|
|
521
|
-
|
|
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
|
-
//
|
|
587
|
-
config(
|
|
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 === '
|
|
601
|
-
id === '
|
|
602
|
-
id === '@
|
|
603
|
-
id
|
|
604
|
-
|
|
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
|
-
|
|
614
|
-
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
} from './core'
|
|
567
|
+
SEOMeta, HonoMiddleware, LoaderCtx, ResolvedRoute, CompiledPath,
|
|
568
|
+
ClientMiddleware, AsyncLoader,
|
|
569
|
+
} from './core'
|