@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 +21 -0
- package/README.md +179 -0
- package/client.ts +307 -0
- package/core.ts +734 -0
- package/dist/client.d.ts +196 -0
- package/dist/client.js +673 -0
- package/dist/core.d.ts +200 -0
- package/dist/core.js +495 -0
- package/dist/server.d.ts +231 -0
- package/dist/server.js +720 -0
- package/package.json +91 -0
- package/server.ts +415 -0
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
|
+
[](https://www.npmjs.com/package/@netrojs/fnetro)
|
|
6
|
+
[](https://www.npmjs.com/package/@netrojs/create-fnetro)
|
|
7
|
+
[](https://github.com/@netrojs/fnetro/actions/workflows/ci.yml)
|
|
8
|
+
[](./LICENSE)
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Packages
|
|
13
|
+
|
|
14
|
+
| Package | Version | Description |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| [`fnetro`](./packages/fnetro) | [](https://npmjs.com/package/@netrojs/fnetro) | The framework — core, server, client |
|
|
17
|
+
| [`create-fnetro`](./packages/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'
|