@netrojs/fnetro 0.1.2

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/package.json ADDED
@@ -0,0 +1,91 @@
1
+ {
2
+ "name": "@netrojs/fnetro",
3
+ "version": "0.1.2",
4
+ "description": "Full-stack Hono framework — SSR, SPA, Vue-like reactivity, route groups, middleware and API routes",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "MD Ashikur Rahman<info@netrosolutions.com>",
8
+ "homepage": "https://github.com/netrosolutions/fnetro",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/netrosolutions/fnetro.git",
12
+ "directory": "packages/fnetro"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/netrosolutions/fnetro/issues"
16
+ },
17
+ "keywords": [
18
+ "fnetro",
19
+ "hono",
20
+ "framework",
21
+ "fullstack",
22
+ "ssr",
23
+ "spa",
24
+ "reactivity",
25
+ "signals",
26
+ "jsx",
27
+ "tsx",
28
+ "node",
29
+ "bun",
30
+ "deno",
31
+ "cloudflare-workers",
32
+ "edge"
33
+ ],
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/core.d.ts",
37
+ "import": "./dist/core.js"
38
+ },
39
+ "./core": {
40
+ "types": "./dist/core.d.ts",
41
+ "import": "./dist/core.js"
42
+ },
43
+ "./server": {
44
+ "types": "./dist/server.d.ts",
45
+ "import": "./dist/server.js"
46
+ },
47
+ "./client": {
48
+ "types": "./dist/client.d.ts",
49
+ "import": "./dist/client.js"
50
+ },
51
+ "./vite": {
52
+ "types": "./dist/server.d.ts",
53
+ "import": "./dist/server.js"
54
+ }
55
+ },
56
+ "main": "./dist/core.js",
57
+ "types": "./dist/core.d.ts",
58
+ "files": [
59
+ "dist",
60
+ "core.ts",
61
+ "server.ts",
62
+ "client.ts",
63
+ "README.md",
64
+ "LICENSE"
65
+ ],
66
+ "scripts": {
67
+ "build": "tsup",
68
+ "build:watch": "tsup --watch",
69
+ "typecheck": "tsc --noEmit",
70
+ "clean": "rimraf dist",
71
+ "prepublishOnly": "npm run clean && npm run build && npm run typecheck"
72
+ },
73
+ "peerDependencies": {
74
+ "hono": ">=4.0.0",
75
+ "vite": ">=5.0.0"
76
+ },
77
+ "peerDependenciesMeta": {
78
+ "vite": {
79
+ "optional": true
80
+ }
81
+ },
82
+ "devDependencies": {
83
+ "@hono/node-server": "^1.19.11",
84
+ "@types/node": "^25.5.0",
85
+ "hono": "^4.12.8",
86
+ "rimraf": "^6.1.3",
87
+ "tsup": "^8.5.1",
88
+ "typescript": "^5.9.3",
89
+ "vite": "^8.0.0"
90
+ }
91
+ }
package/server.ts ADDED
@@ -0,0 +1,415 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // FNetro · server.ts
3
+ // Hono server integration · SSR renderer · Vite plugin (dual-build)
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+
6
+ import { Hono } from 'hono'
7
+ import { jsx } from 'hono/jsx'
8
+ import { renderToString } from 'hono/jsx/dom/server'
9
+ import {
10
+ resolveRoutes,
11
+ SPA_HEADER, STATE_KEY, PARAMS_KEY,
12
+ type AppConfig, type ResolvedRoute, type LayoutDef,
13
+ type PageDef, type ApiRouteDef,
14
+ } from './core'
15
+ import type { Plugin, InlineConfig } from 'vite'
16
+ import type { MiddlewareHandler } from 'hono'
17
+
18
+ // ══════════════════════════════════════════════════════════════════════════════
19
+ // § 1 Path matching
20
+ // ══════════════════════════════════════════════════════════════════════════════
21
+
22
+ interface CompiledPath {
23
+ re: RegExp
24
+ keys: string[]
25
+ original: string
26
+ }
27
+
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 }
35
+ }
36
+
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
43
+ }
44
+
45
+ // ══════════════════════════════════════════════════════════════════════════════
46
+ // § 2 SSR Renderer
47
+ // ══════════════════════════════════════════════════════════════════════════════
48
+ // § 2 SSR Renderer
49
+ // ══════════════════════════════════════════════════════════════════════════════
50
+
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
55
+ 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="/assets/client.js"></script>
70
+ </body>
71
+ </html>`
72
+ }
73
+
74
+ function escHtml(s: string): string {
75
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
76
+ }
77
+
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 })
86
+
87
+ 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
+
92
+ return renderToString(wrapped as any)
93
+ }
94
+
95
+ async function renderFullPage(
96
+ route: ResolvedRoute,
97
+ data: object,
98
+ url: string,
99
+ params: Record<string, string>,
100
+ appLayout: LayoutDef | undefined,
101
+ title = 'FNetro'
102
+ ): Promise<string> {
103
+ const pageHtml = await renderInner(route, data, url, params, appLayout)
104
+ return buildShell({
105
+ title,
106
+ stateJson: JSON.stringify({ [url]: data }),
107
+ paramsJson: JSON.stringify(params),
108
+ pageHtml,
109
+ })
110
+ }
111
+
112
+ // ══════════════════════════════════════════════════════════════════════════════
113
+ // § 3 createFNetro — assemble the Hono app
114
+ // ══════════════════════════════════════════════════════════════════════════════
115
+
116
+ 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']
121
+ }
122
+
123
+ export function createFNetro(config: AppConfig): FNetroApp {
124
+ const app = new Hono()
125
+
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
+ // Global middleware
133
+ ;(config.middleware ?? []).forEach(mw => app.use('*', mw))
134
+
135
+ // Resolve all routes
136
+ const { pages, apis } = resolveRoutes(config.routes, {
137
+ layout: config.layout,
138
+ middleware: [],
139
+ })
140
+
141
+ // Pre-compile paths
142
+ const compiled = pages.map(r => ({
143
+ route: r,
144
+ compiled: compilePath(r.fullPath),
145
+ }))
146
+
147
+ // Register API routes
148
+ apis.forEach(api => {
149
+ const sub = new Hono()
150
+ api.register(sub, config.middleware ?? [])
151
+ app.route(api.path, sub)
152
+ })
153
+
154
+ // Page handler (catch-all, after API routes)
155
+ app.all('*', async (c) => {
156
+ const url = new URL(c.req.url)
157
+ const pathname = url.pathname
158
+ const isSPA = c.req.header(SPA_HEADER) === '1'
159
+
160
+ // Find matching page
161
+ let matched: { route: ResolvedRoute; params: Record<string, string> } | null = null
162
+ for (const { route, compiled: cp } of compiled) {
163
+ const params = matchPath(cp, pathname)
164
+ if (params !== null) {
165
+ matched = { route, params }
166
+ break
167
+ }
168
+ }
169
+
170
+ if (!matched) {
171
+ 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)
174
+ }
175
+ return c.text('Not Found', 404)
176
+ }
177
+
178
+ const { route, params } = matched
179
+
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() }
184
+
185
+ // Run route-level middleware chain (mirrors Hono's own onion model)
186
+ let earlyResponse: Response | undefined
187
+ const handlers = [...route.middleware]
188
+ let idx = 0
189
+
190
+ const runMiddleware = async (): Promise<void> => {
191
+ const mw = handlers[idx++]
192
+ 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
196
+ }
197
+
198
+ await runMiddleware()
199
+
200
+ if (earlyResponse) return earlyResponse
201
+
202
+ // Run loader
203
+ const data = route.page.loader ? await route.page.loader(c) : {}
204
+ const safeData = data ?? {}
205
+
206
+ if (isSPA) {
207
+ // SPA navigation — return JSON
208
+ const html = await renderInner(route, safeData, pathname, params, config.layout)
209
+ return c.json({
210
+ html,
211
+ state: safeData,
212
+ params,
213
+ url: pathname,
214
+ })
215
+ }
216
+
217
+ // Full SSR
218
+ const fullHtml = await renderFullPage(route, safeData, pathname, params, config.layout)
219
+ return c.html(fullHtml)
220
+ })
221
+
222
+ return { app, handler: app.fetch }
223
+ }
224
+
225
+ // ══════════════════════════════════════════════════════════════════════════════
226
+ // § 4 Universal serve() — auto-detects Node / Bun / Deno / edge
227
+ // ══════════════════════════════════════════════════════════════════════════════
228
+
229
+ export type Runtime = 'node' | 'bun' | 'deno' | 'edge' | 'unknown'
230
+
231
+ export function detectRuntime(): Runtime {
232
+ if (typeof (globalThis as any).Bun !== 'undefined') return 'bun'
233
+ if (typeof (globalThis as any).Deno !== 'undefined') return 'deno'
234
+ if (typeof process !== 'undefined' && process.versions?.node) return 'node'
235
+ return 'edge'
236
+ }
237
+
238
+ 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' */
245
+ staticDir?: string
246
+ }
247
+
248
+ 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`)
255
+
256
+ switch (runtime) {
257
+ case 'node': {
258
+ const [{ serve: nodeServe }, { serveStatic }] = await Promise.all([
259
+ import('@hono/node-server'),
260
+ import('@hono/node-server/serve-static'),
261
+ ])
262
+ opts.app.app.use('/assets/*', serveStatic({ root: staticDir }))
263
+ nodeServe({ fetch: opts.app.handler, port, hostname })
264
+ logReady()
265
+ break
266
+ }
267
+ case 'bun': {
268
+ ;(globalThis as any).Bun.serve({ fetch: opts.app.handler, port, hostname })
269
+ logReady()
270
+ break
271
+ }
272
+ case 'deno': {
273
+ ;(globalThis as any).Deno.serve({ port, hostname }, opts.app.handler)
274
+ logReady()
275
+ break
276
+ }
277
+ default:
278
+ console.warn('[fnetro] serve() is a no-op on edge runtimes. Export `app.handler` instead.')
279
+ }
280
+ }
281
+
282
+ // ══════════════════════════════════════════════════════════════════════════════
283
+ // § 5 Vite plugin — automatic dual build (server + client)
284
+ // ══════════════════════════════════════════════════════════════════════════════
285
+
286
+ 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
+ */
311
+ serverExternal?: string[]
312
+ /**
313
+ * Emit type declarations for framework types.
314
+ * @default false
315
+ */
316
+ dts?: boolean
317
+ }
318
+
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)$/
320
+
321
+ export function fnetroVitePlugin(opts: FNetroPluginOptions = {}): Plugin[] {
322
+ const {
323
+ serverEntry = 'app/server.ts',
324
+ clientEntry = 'app/client.ts',
325
+ serverOutDir = 'dist/server',
326
+ clientOutDir = 'dist/assets',
327
+ serverExternal = [],
328
+ } = opts
329
+
330
+ let isServerBuild = true // first pass = server
331
+
332
+ const sharedEsbuild = {
333
+ jsx: 'automatic' as const,
334
+ jsxImportSource: 'hono/jsx',
335
+ }
336
+
337
+ // Common JSX transform for all .tsx files
338
+ const jsxPlugin: Plugin = {
339
+ name: 'fnetro:jsx',
340
+ config: () => ({ esbuild: sharedEsbuild }),
341
+ }
342
+
343
+ // Server build plugin
344
+ const serverPlugin: Plugin = {
345
+ name: 'fnetro:server',
346
+ apply: 'build',
347
+ enforce: 'pre',
348
+
349
+ config() {
350
+ // No alias needed: hono/jsx and hono/jsx/dom produce compatible nodes.
351
+ // renderToString (server) and render() (client) both accept them.
352
+ return {
353
+ build: {
354
+ outDir: serverOutDir,
355
+ ssr: true,
356
+ target: 'node18',
357
+ lib: {
358
+ entry: serverEntry,
359
+ formats: ['es'],
360
+ fileName: 'server',
361
+ },
362
+ rollupOptions: {
363
+ external: (id: string) =>
364
+ NODE_BUILTINS.test(id) ||
365
+ id === '@hono/node-server' ||
366
+ serverExternal.includes(id),
367
+ },
368
+ },
369
+ esbuild: sharedEsbuild,
370
+ }
371
+ },
372
+
373
+ async closeBundle() {
374
+ console.log('\n⚡ FNetro: building client bundle…\n')
375
+
376
+ const { build } = await import('vite')
377
+ await build({
378
+ configFile: false,
379
+ esbuild: sharedEsbuild,
380
+ build: {
381
+ outDir: clientOutDir,
382
+ lib: {
383
+ entry: clientEntry,
384
+ formats: ['es'],
385
+ fileName: 'client',
386
+ },
387
+ rollupOptions: {
388
+ output: { entryFileNames: '[name].js' },
389
+ },
390
+ },
391
+ } satisfies InlineConfig)
392
+
393
+ console.log('\n✅ FNetro: both bundles ready\n')
394
+ },
395
+ }
396
+
397
+ return [jsxPlugin, serverPlugin]
398
+ }
399
+
400
+ // ══════════════════════════════════════════════════════════════════════════════
401
+ // § 6 Re-export core for convenience when only server.ts is imported
402
+ // ══════════════════════════════════════════════════════════════════════════════
403
+ export {
404
+ definePage, defineGroup, defineLayout, defineMiddleware, defineApiRoute,
405
+ ref, shallowRef, reactive, shallowReactive, readonly,
406
+ computed, effect, watch, watchEffect, effectScope,
407
+ toRef, toRefs, unref, isRef, isReactive, isReadonly, markRaw, toRaw,
408
+ triggerRef, use, useLocalRef, useLocalReactive,
409
+ SPA_HEADER, STATE_KEY,
410
+ } from './core'
411
+ export type {
412
+ AppConfig, PageDef, GroupDef, LayoutDef, ApiRouteDef, MiddlewareDef,
413
+ Ref, ComputedRef, WritableComputedRef, WatchSource, WatchOptions,
414
+ LoaderCtx, FNetroMiddleware, AnyJSX,
415
+ } from './core'