@netrojs/fnetro 0.1.6 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.ts CHANGED
@@ -1,257 +1,433 @@
1
1
  // ─────────────────────────────────────────────────────────────────────────────
2
2
  // FNetro · server.ts
3
- // Hono server integration · SSR renderer · Vite plugin (dual-build)
3
+ // Hono app factory · SolidJS SSR · SEO head · asset manifest · Vite plugin
4
4
  // ─────────────────────────────────────────────────────────────────────────────
5
5
 
6
6
  import { Hono } from 'hono'
7
- import { jsx } from 'hono/jsx'
8
- import { renderToString } from 'hono/jsx/dom/server'
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 PageDef, type ApiRouteDef,
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 Path matching
18
+ // § 1 HTML helpers
20
19
  // ══════════════════════════════════════════════════════════════════════════════
21
20
 
22
- interface CompiledPath {
23
- re: RegExp
24
- keys: string[]
25
- original: string
21
+ function esc(s: string): string {
22
+ return s
23
+ .replace(/&/g, '&')
24
+ .replace(/</g, '&lt;')
25
+ .replace(/>/g, '&gt;')
26
+ .replace(/"/g, '&quot;')
26
27
  }
27
28
 
28
- function compilePath(path: string): CompiledPath {
29
- const keys: string[] = []
30
- const src = path
31
- .replace(/\[\.\.\.([^\]]+)\]/g, (_: string, k: string) => { keys.push(k); return '(.*)' }) // [...slug]
32
- .replace(/\[([^\]]+)\]/g, (_: string, k: string) => { keys.push(k); return '([^/]+)' }) // [id]
33
- .replace(/\*/g, '(.*)')
34
- return { re: new RegExp(`^${src}$`), keys, original: path }
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 matchPath(compiled: CompiledPath, pathname: string): Record<string, string> | null {
38
- const m = pathname.match(compiled.re)
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
- // § 2 SSR Renderer
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
- // § 2 SSR Renderer
169
+ // § 4 HTML shell
49
170
  // ══════════════════════════════════════════════════════════════════════════════
50
171
 
51
- /** Build the outer HTML shell as a plain string — faster than JSX for static structure */
52
- function buildShell(opts: {
53
- title: string
54
- stateJson: string
172
+ interface ShellOpts {
173
+ title: string
174
+ metaHtml: string
175
+ bodyHtml: string
176
+ stateJson: string
55
177
  paramsJson: string
56
- pageHtml: string
57
- }): string {
58
- return `<!DOCTYPE html>
59
- <html lang="en">
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="/client.ts"></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 escHtml(s: string): string {
75
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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
- async function renderInner(
79
- route: ResolvedRoute,
80
- data: object,
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 renderToString(wrapped as any)
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: ResolvedRoute,
97
- data: object,
98
- url: string,
99
- params: Record<string, string>,
100
- appLayout: LayoutDef | undefined,
101
- title = 'FNetro'
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 pageHtml = await renderInner(route, data, url, params, appLayout)
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
- stateJson: JSON.stringify({ [url]: data }),
268
+ metaHtml: buildHeadMeta(seo, config.head),
269
+ bodyHtml,
270
+ stateJson: JSON.stringify({ [url]: data }),
107
271
  paramsJson: JSON.stringify(params),
108
- pageHtml,
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
- // § 3 createFNetro — assemble the Hono app
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 — add raw routes, custom error handlers, etc. */
118
- app: Hono
119
- /** Hono fetch handler — export this as default for edge runtimes */
120
- handler: Hono['fetch']
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: AppConfig): FNetroApp {
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
- ;(config.middleware ?? []).forEach(mw => app.use('*', mw))
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: config.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 routes
148
- apis.forEach(api => {
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
- // Page handler (catch-all, after API routes)
319
+ // Catch-all page handler must come AFTER API routes
155
320
  app.all('*', async (c) => {
156
- const url = new URL(c.req.url)
321
+ const url = new URL(c.req.url)
157
322
  const pathname = url.pathname
158
- const isSPA = c.req.header(SPA_HEADER) === '1'
323
+ const isSPA = c.req.header(SPA_HEADER) === '1'
324
+ const isDev = process.env['NODE_ENV'] !== 'production'
159
325
 
160
- // Find matching page
326
+ // Match route
161
327
  let matched: { route: ResolvedRoute; params: Record<string, string> } | null = null
162
- for (const { route, compiled: cp } of compiled) {
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 renderToString(jsx(config.notFound as any, {}))
173
- return c.html(`<!DOCTYPE html><html><body>${html}</body></html>`, 404)
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 via c.req — patch temporarily
181
- const origParam = c.req.param.bind(c.req)
182
- ;(c.req as any).param = (key?: string) =>
183
- key ? (params[key] ?? origParam(key)) : { ...params, ...origParam() }
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
- // Run route-level middleware chain (mirrors Hono's own onion model)
186
- let earlyResponse: Response | undefined
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, runMiddleware)
194
- // If middleware returned a Response and didn't call next(), use it
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
- await runMiddleware()
199
-
200
- if (earlyResponse) return earlyResponse
365
+ await runNext()
366
+ if (early) return early
201
367
 
202
368
  // Run loader
203
- const data = route.page.loader ? await route.page.loader(c) : {}
204
- const safeData = data ?? {}
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 html = await renderInner(route, safeData, pathname, params, config.layout)
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
- html,
211
- state: safeData,
378
+ state: data,
212
379
  params,
213
- url: pathname,
380
+ url: pathname,
381
+ seo: mergeSEO(config.seo, pageSEO),
214
382
  })
215
383
  }
216
384
 
217
- // Full SSR
218
- const fullHtml = await renderFullPage(route, safeData, pathname, params, config.layout)
219
- return c.html(fullHtml)
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
- // § 4 Universal serve() — auto-detects Node / Bun / Deno / edge
401
+ // § 7 Multi-runtime serve()
227
402
  // ══════════════════════════════════════════════════════════════════════════════
228
403
 
229
- export type Runtime = 'node' | 'bun' | 'deno' | 'edge' | 'unknown'
404
+ export type Runtime = 'node' | 'bun' | 'deno' | 'edge'
230
405
 
231
406
  export function detectRuntime(): Runtime {
232
- if (typeof (globalThis as any).Bun !== 'undefined') return 'bun'
233
- if (typeof (globalThis as any).Deno !== 'undefined') return 'deno'
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: FNetroApp
240
- port?: number
241
- hostname?: string
242
- /** Override auto-detected runtime. */
243
- runtime?: Runtime
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 = opts.runtime ?? detectRuntime()
250
- const port = opts.port ?? Number((globalThis as any).process?.env?.PORT ?? 3000)
251
- const hostname = opts.hostname ?? '0.0.0.0'
252
- const staticDir = opts.staticDir ?? './dist'
253
- const addr = `http://${hostname === '0.0.0.0' ? 'localhost' : hostname}:${port}`
254
- const logReady = () => console.log(`\n🔥 FNetro [${runtime}] ready → ${addr}\n`)
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).Bun.serve({ fetch: opts.app.handler, port, hostname })
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).Deno.serve({ port, hostname }, opts.app.handler)
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('[fnetro] serve() is a no-op on edge runtimes. Export `app.handler` instead.')
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
- // § 5 Vite plugin — automatic dual build (server + client)
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
- * Server entry file (exports the Hono app / calls serve()).
289
- * @default 'app/server.ts'
290
- */
291
- serverEntry?: string
292
- /**
293
- * Client entry file (calls boot()).
294
- * @default 'app/client.ts'
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
- * Emit type declarations for framework types.
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
- const NODE_BUILTINS = /^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)$/
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 = 'app/server.ts',
324
- clientEntry = 'app/client.ts',
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 isServerBuild = true // first pass = server
510
+ let _solid: SolidFactory | null = null
511
+ let _solidPlugins: Plugin[] = []
331
512
 
332
- // JSX transform options.
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: 'fnetro:jsx',
346
- config: () => ({
347
- // Vite ≤7 (esbuild transform)
348
- esbuild: jsxTransform,
349
- // Vite 8+ (oxc / rolldown transform) jsx property uses JsxOptions object shape
350
- oxc: {
351
- jsx: {
352
- runtime: 'automatic',
353
- importSource: 'hono/jsx',
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
- } as any, // `as any` because JsxOptions typing varies across Vite 8 patch releases
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
- // Server build plugin
360
- const serverPlugin: Plugin = {
361
- name: 'fnetro:server',
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
- config() {
366
- // No alias needed: hono/jsx and hono/jsx/dom produce compatible nodes.
367
- // renderToString (server) and render() (client) both accept them.
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
- await build({
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
- // Vite ≤7 fallback (oxc is set by jsxPlugin for Vite 8+)
397
- esbuild: jsxTransform,
604
+ plugins: toPlugins(solid({ ...solidOptions })) as InlineConfig['plugins'],
398
605
  build: {
399
- outDir: clientOutDir,
400
- lib: {
401
- entry: clientEntry,
402
- formats: ['es'],
403
- fileName: 'client',
404
- },
606
+ outDir: clientOutDir,
607
+ manifest: true,
405
608
  rollupOptions: {
406
- output: { entryFileNames: '[name].js' },
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
- } satisfies InlineConfig)
618
+ })
410
619
 
411
- console.log('\n✅ FNetro: both bundles ready\n')
620
+ console.log('✅ FNetro: both bundles ready\n')
412
621
  },
413
622
  }
414
623
 
415
- return [jsxPlugin, serverPlugin]
624
+ return [jsxPlugin, solidProxy, buildPlugin]
416
625
  }
417
626
 
418
627
  // ══════════════════════════════════════════════════════════════════════════════
419
- // § 6 Re-export core for convenience when only server.ts is imported
628
+ // § 9 Re-exports
420
629
  // ══════════════════════════════════════════════════════════════════════════════
630
+
421
631
  export {
422
- definePage, defineGroup, defineLayout, defineMiddleware, defineApiRoute,
423
- ref, shallowRef, reactive, shallowReactive, readonly,
424
- computed, effect, watch, watchEffect, effectScope,
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, MiddlewareDef,
431
- Ref, ComputedRef, WritableComputedRef, WatchSource, WatchOptions,
432
- LoaderCtx, FNetroMiddleware, AnyJSX,
433
- } from './core'
638
+ AppConfig, PageDef, GroupDef, LayoutDef, ApiRouteDef, Route,
639
+ PageProps, LayoutProps, SEOMeta, HonoMiddleware, LoaderCtx,
640
+ ResolvedRoute, CompiledPath, ClientMiddleware,
641
+ } from './core'