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