@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MD Ashikur Rahman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # ⬡ FNetro
2
+
3
+ > Full-stack [Hono](https://hono.dev) framework — SSR, SPA, Vue-like reactivity, route groups, middleware and raw API routes in **3 files**.
4
+
5
+ [![npm](https://img.shields.io/npm/v/@netrojs/fnetro?color=6b8cff&label=fnetro)](https://www.npmjs.com/package/@netrojs/fnetro)
6
+ [![npm](https://img.shields.io/npm/v/@netrojs/create-fnetro?color=3ecf8e&label=create-fnetro)](https://www.npmjs.com/package/@netrojs/create-fnetro)
7
+ [![CI](https://github.com/@netrojs/fnetro/actions/workflows/ci.yml/badge.svg)](https://github.com/@netrojs/fnetro/actions/workflows/ci.yml)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
9
+
10
+ ---
11
+
12
+ ## Packages
13
+
14
+ | Package | Version | Description |
15
+ |---|---|---|
16
+ | [`fnetro`](./packages/fnetro) | [![npm](https://img.shields.io/npm/v/@netrojs/fnetro)](https://npmjs.com/package/@netrojs/fnetro) | The framework — core, server, client |
17
+ | [`create-fnetro`](./packages/create-fnetro) | [![npm](https://img.shields.io/npm/v/@netrojs/create-fnetro)](https://npmjs.com/package/@netrojs/create-fnetro) | Interactive project scaffolder |
18
+
19
+ ---
20
+
21
+ ## Quick start
22
+
23
+ ```bash
24
+ npm create @netrojs/fnetro@latest
25
+ # or
26
+ npx @netrojs/create-fnetro my-app
27
+ # or
28
+ bunx @netrojs/create-fnetro my-app
29
+ # or
30
+ pnpm create @netrojs/fnetro my-app
31
+ ```
32
+
33
+ The CLI will ask for your **runtime** (Node, Bun, Deno, Cloudflare Workers, or generic), **template** (minimal or full), and package manager — then scaffold a working app and install dependencies.
34
+
35
+ ---
36
+
37
+ ## Core concepts
38
+
39
+ ```
40
+ fnetro/core Reactivity engine + route/layout/middleware types
41
+ fnetro/server Hono integration, SSR renderer, Vite plugin
42
+ fnetro/client SPA boot, navigation, prefetch, hook patching
43
+ ```
44
+
45
+ ### `definePage` — unified route file
46
+
47
+ ```tsx
48
+ // app/routes/post.tsx
49
+ import { definePage, ref, use } from 'fnetro/core'
50
+
51
+ const views = ref(0) // module-level signal — survives SPA navigation
52
+
53
+ export default definePage({
54
+ path: '/posts/[slug]',
55
+
56
+ // Runs on the server — return value becomes Page props.
57
+ // Zero-refetch: serialized into window.__FNETRO_STATE__ and read by client on boot.
58
+ async loader(c) {
59
+ const slug = c.req.param('slug')
60
+ return { post: await db.findPost(slug) }
61
+ },
62
+
63
+ // Same JSX, two runtimes:
64
+ // • Server → hono/jsx → renderToString()
65
+ // • Client → hono/jsx/dom → render()
66
+ Page({ post, params }) {
67
+ const n = use(views)
68
+ return (
69
+ <article>
70
+ <h1>{post.title}</h1>
71
+ <p>Viewed {n} times this session</p>
72
+ <button onClick={() => views.value++}>👁</button>
73
+ </article>
74
+ )
75
+ },
76
+ })
77
+ ```
78
+
79
+ ### `defineGroup` — route composition with layout + middleware
80
+
81
+ ```tsx
82
+ export const adminGroup = defineGroup({
83
+ prefix: '/admin',
84
+ layout: AdminLayout, // overrides app layout
85
+ middleware: [requireAuth, auditLog], // applied to every route in group
86
+ routes: [dashboard, users, settings],
87
+ })
88
+ ```
89
+
90
+ ### `defineApiRoute` — raw Hono routes
91
+
92
+ ```tsx
93
+ export const api = defineApiRoute('/api', (app) => {
94
+ app.get('/posts', (c) => c.json(posts))
95
+ app.post('/posts', async (c) => { ... })
96
+ app.route('/admin', adminRpc)
97
+ })
98
+ ```
99
+
100
+ ### Vue-like reactivity
101
+
102
+ ```ts
103
+ import { ref, reactive, computed, watch, watchEffect } from 'fnetro/core'
104
+
105
+ const count = ref(0)
106
+ const doubled = computed(() => count.value * 2)
107
+
108
+ watch(count, (n, prev) => console.log(prev, '→', n))
109
+ watchEffect(() => document.title = `Count: ${count.value}`)
110
+
111
+ count.value++ // triggers computed + watcher + effect
112
+ ```
113
+
114
+ ---
115
+
116
+ ## Runtime support
117
+
118
+ | Runtime | How |
119
+ |---|---|
120
+ | **Node.js** | `serve()` → `@hono/node-server` |
121
+ | **Bun** | `serve()` → `Bun.serve()` |
122
+ | **Deno** | `serve()` → `Deno.serve()` |
123
+ | **Cloudflare Workers** | `export default { fetch: fnetro.handler }` |
124
+ | **Generic / WinterCG** | `export const handler = fnetro.handler` |
125
+
126
+ `serve()` auto-detects the runtime — no config needed.
127
+
128
+ ---
129
+
130
+ ## Monorepo structure
131
+
132
+ ```
133
+ fnetro/
134
+ ├── packages/
135
+ │ ├── fnetro/ # Framework package
136
+ │ │ ├── core.ts # Reactivity + type definitions
137
+ │ │ ├── server.ts # Hono app factory + SSR + Vite plugin
138
+ │ │ ├── client.ts # SPA runtime + navigation + hook patching
139
+ │ │ └── package.json
140
+ │ └── create-fnetro/ # CLI scaffolder
141
+ │ ├── src/index.ts
142
+ │ └── package.json
143
+ ├── .github/
144
+ │ └── workflows/
145
+ │ ├── ci.yml # Typecheck + build on every PR
146
+ │ └── publish.yml # Publish via Changesets on merge to main
147
+ └── .changeset/
148
+ └── config.json
149
+ ```
150
+
151
+ ---
152
+
153
+ ## Contributing
154
+
155
+ ```bash
156
+ git clone https://github.com/@netrojs/fnetro.git
157
+ cd fnetro
158
+ npm install # install all workspaces
159
+ npm run build # build fnetro + create-fnetro
160
+ npm run typecheck # run tsc --noEmit across all packages
161
+ ```
162
+
163
+ ### Creating a release
164
+
165
+ FNetro uses [Changesets](https://github.com/changesets/changesets) for versioning:
166
+
167
+ ```bash
168
+ npx changeset # describe your change
169
+ npx changeset version # bump versions and update changelogs
170
+ npx changeset publish # publish to npm
171
+ ```
172
+
173
+ Or push to `main` — the [Release workflow](./.github/workflows/publish.yml) will open a version PR automatically, then publish when merged.
174
+
175
+ ---
176
+
177
+ ## License
178
+
179
+ MIT — see [LICENSE](./LICENSE).
package/client.ts ADDED
@@ -0,0 +1,307 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // FNetro · client.ts
3
+ // SPA runtime · hook patching · navigation · prefetch · lifecycle
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+
6
+ import { render } from 'hono/jsx/dom'
7
+ import { jsx } from 'hono/jsx'
8
+ import {
9
+ useState, useEffect, useMemo, useRef as useHonoRef,
10
+ useSyncExternalStore,
11
+ } from 'hono/jsx'
12
+ import {
13
+ __hooks, ref, reactive, computed, watchEffect, isRef,
14
+ SPA_HEADER, STATE_KEY, PARAMS_KEY,
15
+ type Ref, type AppConfig, type ResolvedRoute,
16
+ type LayoutDef,
17
+ } from './core'
18
+ import { resolveRoutes } from './core'
19
+
20
+ // ══════════════════════════════════════════════════════════════════════════════
21
+ // § 1 Patch reactivity hooks for hono/jsx/dom
22
+ // ══════════════════════════════════════════════════════════════════════════════
23
+
24
+ /**
25
+ * Connect a Ref (or computed getter) to the current JSX component.
26
+ * Re-renders whenever the source changes.
27
+ */
28
+ function clientUseValue<T>(source: Ref<T> | (() => T)): T {
29
+ if (isRef(source)) {
30
+ // Fast path: useSyncExternalStore is ideal for refs
31
+ return useSyncExternalStore(
32
+ (notify) => (source as any).subscribe(notify),
33
+ () => (source as any).peek?.() ?? source.value,
34
+ )
35
+ }
36
+ // Getter: wrap in a computed ref, then subscribe
37
+ const c = useMemo(() => computed(source as () => T), [source])
38
+ return useSyncExternalStore(
39
+ (notify) => (c as any).subscribe(notify),
40
+ () => (c as any).peek?.() ?? c.value,
41
+ )
42
+ }
43
+
44
+ /**
45
+ * Component-local Ref — stable across re-renders, lost on unmount.
46
+ */
47
+ function clientUseLocalRef<T>(init: T): Ref<T> {
48
+ // Create the ref once (stable ref object via hono's useRef)
49
+ const stableRef = useHonoRef<Ref<T> | null>(null)
50
+ if (stableRef.current === null) stableRef.current = ref(init)
51
+ const r = stableRef.current!
52
+ // Subscribe so mutations trigger re-render
53
+ useSyncExternalStore(
54
+ (notify) => (r as any).subscribe(notify),
55
+ () => (r as any).peek?.() ?? r.value,
56
+ )
57
+ return r
58
+ }
59
+
60
+ /**
61
+ * Component-local reactive object — deep proxy, re-renders on any mutation.
62
+ */
63
+ function clientUseLocalReactive<T extends object>(init: T): T {
64
+ const stableRef = useHonoRef<T | null>(null)
65
+ if (stableRef.current === null) stableRef.current = reactive(init)
66
+ const proxy = stableRef.current!
67
+
68
+ // watchEffect to re-render whenever any tracked key changes
69
+ const [tick, setTick] = useState(0)
70
+ useEffect(() => {
71
+ return watchEffect(() => {
72
+ // Touch all keys to establish tracking
73
+ JSON.stringify(proxy)
74
+ // Schedule re-render (not on first run)
75
+ setTick(t => t + 1)
76
+ })
77
+ }, [])
78
+
79
+ return proxy
80
+ }
81
+
82
+ // Patch the module-level hook table
83
+ Object.assign(__hooks, {
84
+ useValue: clientUseValue,
85
+ useLocalRef: clientUseLocalRef,
86
+ useLocalReactive: clientUseLocalReactive,
87
+ })
88
+
89
+ // ══════════════════════════════════════════════════════════════════════════════
90
+ // § 2 Path matching (mirrors server)
91
+ // ══════════════════════════════════════════════════════════════════════════════
92
+
93
+ interface CompiledRoute {
94
+ route: ResolvedRoute
95
+ re: RegExp
96
+ keys: string[]
97
+ }
98
+
99
+ function compileRoute(r: ResolvedRoute): CompiledRoute {
100
+ const keys: string[] = []
101
+ const src = r.fullPath
102
+ .replace(/\[\.\.\.([^\]]+)\]/g, (_: string, k: string) => { keys.push(k); return '(.*)' })
103
+ .replace(/\[([^\]]+)\]/g, (_: string, k: string) => { keys.push(k); return '([^/]+)' })
104
+ .replace(/\*/g, '(.*)')
105
+ return { route: r, re: new RegExp(`^${src}$`), keys }
106
+ }
107
+
108
+ function matchRoute(compiled: CompiledRoute[], pathname: string) {
109
+ for (const c of compiled) {
110
+ const m = pathname.match(c.re)
111
+ if (m) {
112
+ const params: Record<string, string> = {}
113
+ c.keys.forEach((k, i) => { params[k] = decodeURIComponent(m[i + 1]) })
114
+ return { route: c.route, params }
115
+ }
116
+ }
117
+ return null
118
+ }
119
+
120
+ // ══════════════════════════════════════════════════════════════════════════════
121
+ // § 3 Navigation lifecycle hooks
122
+ // ══════════════════════════════════════════════════════════════════════════════
123
+
124
+ type NavListener = (url: string) => void | Promise<void>
125
+ const beforeNavListeners: NavListener[] = []
126
+ const afterNavListeners: NavListener[] = []
127
+
128
+ /** Called before each SPA navigation. Returning false cancels. */
129
+ export function onBeforeNavigate(fn: NavListener): () => void {
130
+ beforeNavListeners.push(fn)
131
+ return () => beforeNavListeners.splice(beforeNavListeners.indexOf(fn), 1)
132
+ }
133
+
134
+ /** Called after each SPA navigation (including initial boot). */
135
+ export function onAfterNavigate(fn: NavListener): () => void {
136
+ afterNavListeners.push(fn)
137
+ return () => afterNavListeners.splice(afterNavListeners.indexOf(fn), 1)
138
+ }
139
+
140
+ // ══════════════════════════════════════════════════════════════════════════════
141
+ // § 4 SPA navigation
142
+ // ══════════════════════════════════════════════════════════════════════════════
143
+
144
+ let compiled: CompiledRoute[] = []
145
+ let currentConfig: AppConfig
146
+ let currentLayout: LayoutDef | undefined
147
+ const prefetchCache = new Map<string, Promise<any>>()
148
+
149
+ function fetchPage(url: string): Promise<any> {
150
+ if (!prefetchCache.has(url)) {
151
+ prefetchCache.set(url, fetch(url, {
152
+ headers: { [SPA_HEADER]: '1' }
153
+ }).then(r => r.json()))
154
+ }
155
+ return prefetchCache.get(url)!
156
+ }
157
+
158
+ async function renderPage(
159
+ route: ResolvedRoute,
160
+ data: object,
161
+ url: string,
162
+ params: Record<string, string>
163
+ ) {
164
+ const container = document.getElementById('fnetro-app')!
165
+ const pageNode = (jsx as any)(route.page.Page, { ...data, url, params })
166
+ const layout = route.layout !== undefined ? route.layout : currentLayout
167
+ const tree = layout
168
+ ? (jsx as any)(layout.Component, { url, params, children: pageNode })
169
+ : pageNode
170
+ render(tree, container)
171
+ }
172
+
173
+ export interface NavigateOptions {
174
+ replace?: boolean
175
+ scroll?: boolean
176
+ }
177
+
178
+ export async function navigate(
179
+ to: string,
180
+ opts: NavigateOptions = {}
181
+ ): Promise<void> {
182
+ const u = new URL(to, location.origin)
183
+ if (u.origin !== location.origin) { location.href = to; return }
184
+
185
+ // Run before-nav hooks
186
+ for (const fn of beforeNavListeners) await fn(u.pathname)
187
+
188
+ const match = matchRoute(compiled, u.pathname)
189
+ if (!match) { location.href = to; return }
190
+
191
+ try {
192
+ const payload = await fetchPage(u.toString())
193
+ const method = opts.replace ? 'replaceState' : 'pushState'
194
+ history[method]({ url: u.pathname }, '', u.pathname)
195
+ if (opts.scroll !== false) window.scrollTo(0, 0)
196
+ await renderPage(match.route, payload.state ?? {}, u.pathname, payload.params ?? {})
197
+ // Cache state for popstate
198
+ ;(window as any)[STATE_KEY] = {
199
+ ...(window as any)[STATE_KEY],
200
+ [u.pathname]: payload.state ?? {}
201
+ }
202
+ for (const fn of afterNavListeners) await fn(u.pathname)
203
+ } catch (e) {
204
+ console.error('[fnetro] Navigation failed:', e)
205
+ location.href = to
206
+ }
207
+ }
208
+
209
+ /** Warm the prefetch cache for a URL (call on hover / mousedown). */
210
+ export function prefetch(url: string): void {
211
+ const u = new URL(url, location.origin)
212
+ if (u.origin !== location.origin) return
213
+ if (!matchRoute(compiled, u.pathname)) return
214
+ fetchPage(u.toString())
215
+ }
216
+
217
+ // ══════════════════════════════════════════════════════════════════════════════
218
+ // § 5 Click interceptor + popstate
219
+ // ══════════════════════════════════════════════════════════════════════════════
220
+
221
+ function interceptClicks(e: MouseEvent) {
222
+ if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
223
+ const a = e.composedPath().find(
224
+ (el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement
225
+ )
226
+ if (!a?.href) return
227
+ if (a.target && a.target !== '_self') return
228
+ if (a.hasAttribute('data-no-spa') || a.rel?.includes('external')) return
229
+ const u = new URL(a.href)
230
+ if (u.origin !== location.origin) return
231
+ e.preventDefault()
232
+ navigate(a.href)
233
+ }
234
+
235
+ function interceptHover(e: MouseEvent) {
236
+ const a = e.composedPath().find(
237
+ (el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement
238
+ )
239
+ if (a?.href) prefetch(a.href)
240
+ }
241
+
242
+ function onPopState() {
243
+ navigate(location.href, { replace: true, scroll: false })
244
+ }
245
+
246
+ // ══════════════════════════════════════════════════════════════════════════════
247
+ // § 6 boot()
248
+ // ══════════════════════════════════════════════════════════════════════════════
249
+
250
+ export interface BootOptions extends AppConfig {
251
+ /**
252
+ * Enable hover-based prefetching (default: true).
253
+ * Fires a SPA fetch when the user hovers any <a> that matches a route.
254
+ */
255
+ prefetchOnHover?: boolean
256
+ }
257
+
258
+ export async function boot(options: BootOptions): Promise<void> {
259
+ const { pages } = resolveRoutes(options.routes, {
260
+ layout: options.layout,
261
+ middleware: [],
262
+ })
263
+
264
+ compiled = pages.map(compileRoute)
265
+ currentConfig = options
266
+ currentLayout = options.layout
267
+
268
+ const pathname = location.pathname
269
+ const match = matchRoute(compiled, pathname)
270
+
271
+ if (!match) {
272
+ console.warn(`[fnetro] No route matched "${pathname}" — not hydrating`)
273
+ return
274
+ }
275
+
276
+ // Read server-injected state (no refetch!)
277
+ const stateMap: Record<string, object> = (window as any)[STATE_KEY] ?? {}
278
+ const paramsMap: Record<string, string> = (window as any)[PARAMS_KEY] ?? {}
279
+ const data = stateMap[pathname] ?? {}
280
+
281
+ await renderPage(match.route, data, pathname, paramsMap)
282
+
283
+ // Wire up navigation
284
+ document.addEventListener('click', interceptClicks)
285
+ if (options.prefetchOnHover !== false) {
286
+ document.addEventListener('mouseover', interceptHover)
287
+ }
288
+ window.addEventListener('popstate', onPopState)
289
+
290
+ for (const fn of afterNavListeners) await fn(pathname)
291
+ }
292
+
293
+ // ══════════════════════════════════════════════════════════════════════════════
294
+ // § 7 Re-export core for client code that imports only client.ts
295
+ // ══════════════════════════════════════════════════════════════════════════════
296
+ export {
297
+ ref, shallowRef, reactive, shallowReactive, readonly,
298
+ computed, effect, watch, watchEffect, effectScope,
299
+ toRef, toRefs, unref, isRef, isReactive, isReadonly, markRaw, toRaw,
300
+ triggerRef, use, useLocalRef, useLocalReactive,
301
+ definePage, defineGroup, defineLayout, defineMiddleware, defineApiRoute,
302
+ } from './core'
303
+ export type {
304
+ Ref, ComputedRef, WritableComputedRef,
305
+ AppConfig, PageDef, GroupDef, LayoutDef, ApiRouteDef,
306
+ WatchSource, WatchOptions,
307
+ } from './core'