@netrojs/fnetro 0.2.20 → 0.3.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/client.ts CHANGED
@@ -1,99 +1,58 @@
1
1
  // ─────────────────────────────────────────────────────────────────────────────
2
2
  // FNetro · client.ts
3
- // SolidJS hydration · SPA routing · client middleware · SEO sync · prefetch
3
+ // Vue 3 SSR hydration · Vue Router SPA · reactive page data · SEO sync
4
4
  // ─────────────────────────────────────────────────────────────────────────────
5
5
 
6
- import { createSignal, createMemo, createComponent } from 'solid-js'
7
- import { hydrate } from 'solid-js/web'
8
6
  import {
9
- resolveRoutes, compilePath, matchPath,
10
- SPA_HEADER, STATE_KEY, PARAMS_KEY, SEO_KEY,
11
- type AppConfig, type ResolvedRoute, type CompiledPath,
12
- type LayoutDef, type SEOMeta, type ClientMiddleware,
7
+ createSSRApp,
8
+ defineAsyncComponent,
9
+ defineComponent,
10
+ h,
11
+ inject,
12
+ reactive,
13
+ readonly,
14
+ type Component,
15
+ type InjectionKey,
16
+ } from 'vue'
17
+ import {
18
+ createRouter,
19
+ createWebHistory,
20
+ RouterView,
21
+ } from 'vue-router'
22
+ import {
23
+ isAsyncLoader,
24
+ resolveRoutes,
25
+ toVueRouterPath,
26
+ compilePath,
27
+ matchPath,
28
+ SPA_HEADER,
29
+ STATE_KEY,
30
+ PARAMS_KEY,
31
+ SEO_KEY,
32
+ DATA_KEY,
33
+ type AppConfig,
34
+ type LayoutDef,
35
+ type SEOMeta,
36
+ type ClientMiddleware,
13
37
  } from './core'
14
38
 
15
- // ══════════════════════════════════════════════════════════════════════════════
16
- // § 1 Compiled route cache (module-level, populated on boot)
17
- // ══════════════════════════════════════════════════════════════════════════════
18
-
19
- interface CRoute { route: ResolvedRoute; cp: CompiledPath }
20
-
21
- let _routes: CRoute[] = []
22
- let _appLayout: LayoutDef | undefined
23
-
24
- function findRoute(pathname: string) {
25
- for (const { route, cp } of _routes) {
26
- const params = matchPath(cp, pathname)
27
- if (params !== null) return { route, params }
28
- }
29
- return null
30
- }
31
-
32
- // ══════════════════════════════════════════════════════════════════════════════
33
- // § 2 Navigation state signal
34
- // ══════════════════════════════════════════════════════════════════════════════
35
-
36
- interface NavState {
37
- path: string
38
- data: Record<string, unknown>
39
- params: Record<string, string>
40
- }
41
-
42
- // Populated by createAppRoot(); exposed so navigate() can update it.
43
- let _setNav: ((s: NavState) => void) | null = null
44
-
45
- // ══════════════════════════════════════════════════════════════════════════════
46
- // § 3 Client middleware
47
- // ══════════════════════════════════════════════════════════════════════════════
48
-
49
- const _mw: ClientMiddleware[] = []
50
-
51
- /**
52
- * Register a client-side navigation middleware.
53
- * Must be called **before** `boot()`.
54
- *
55
- * @example
56
- * useClientMiddleware(async (url, next) => {
57
- * if (!isLoggedIn() && url.startsWith('/dashboard')) {
58
- * await navigate('/login')
59
- * return // cancel original navigation
60
- * }
61
- * await next()
62
- * })
63
- */
64
- export function useClientMiddleware(mw: ClientMiddleware): void {
65
- _mw.push(mw)
66
- }
39
+ // ── SEO ───────────────────────────────────────────────────────────────────────
67
40
 
68
- async function runMiddleware(url: string, done: () => Promise<void>): Promise<void> {
69
- const chain = [..._mw, async (_u: string, next: () => Promise<void>) => { await done(); await next() }]
70
- let i = 0
71
- const run = async (): Promise<void> => {
72
- const fn = chain[i++]
73
- if (fn) await fn(url, run)
74
- }
75
- await run()
76
- }
77
-
78
- // ══════════════════════════════════════════════════════════════════════════════
79
- // § 4 SEO — client-side <head> sync
80
- // ══════════════════════════════════════════════════════════════════════════════
81
-
82
- function setMeta(selector: string, attr: string, val: string | undefined): void {
41
+ function setMeta(selector: string, attr: string, val?: string): void {
83
42
  if (!val) { document.querySelector(selector)?.remove(); return }
84
43
  let el = document.querySelector<HTMLMetaElement>(selector)
85
44
  if (!el) {
86
45
  el = document.createElement('meta')
87
- const m = /\[(\w+[:-]?\w*)="([^"]+)"\]/.exec(selector)
88
- if (m) el.setAttribute(m[1], m[2])
46
+ // Destructuring with defaults avoids string|undefined from noUncheckedIndexedAccess
47
+ const [, attrName = '', attrVal = ''] = /\[([^=]+)="([^"]+)"\]/.exec(selector) ?? []
48
+ if (attrName) el.setAttribute(attrName, attrVal)
89
49
  document.head.appendChild(el)
90
50
  }
91
51
  el.setAttribute(attr, val)
92
52
  }
93
53
 
94
- function syncSEO(seo: SEOMeta): void {
54
+ export function syncSEO(seo: SEOMeta): void {
95
55
  if (seo.title) document.title = seo.title
96
-
97
56
  setMeta('[name="description"]', 'content', seo.description)
98
57
  setMeta('[name="keywords"]', 'content', seo.keywords)
99
58
  setMeta('[name="robots"]', 'content', seo.robots)
@@ -108,220 +67,243 @@ function syncSEO(seo: SEOMeta): void {
108
67
  setMeta('[name="twitter:description"]','content', seo.twitterDescription)
109
68
  setMeta('[name="twitter:image"]', 'content', seo.twitterImage)
110
69
 
111
- // Canonical link
112
- const canon = seo.canonical
113
- let linkEl = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
114
- if (canon) {
115
- if (!linkEl) {
116
- linkEl = document.createElement('link')
117
- linkEl.rel = 'canonical'
118
- document.head.appendChild(linkEl)
70
+ let link = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
71
+ if (seo.canonical) {
72
+ if (!link) {
73
+ link = document.createElement('link')
74
+ link.rel = 'canonical'
75
+ document.head.appendChild(link)
119
76
  }
120
- linkEl.href = canon
77
+ link.href = seo.canonical
121
78
  } else {
122
- linkEl?.remove()
79
+ link?.remove()
123
80
  }
124
81
  }
125
82
 
126
- // ══════════════════════════════════════════════════════════════════════════════
127
- // § 5 Prefetch cache
128
- // ══════════════════════════════════════════════════════════════════════════════
83
+ // ── SPA data fetch + prefetch cache ──────────────────────────────────────────
129
84
 
130
- interface NavPayload {
85
+ interface SpaPayload {
131
86
  state: Record<string, unknown>
132
87
  params: Record<string, string>
133
88
  seo: SEOMeta
134
- url: string
135
89
  }
136
90
 
137
- const _cache = new Map<string, Promise<NavPayload>>()
91
+ // Module-level cache so repeated visits to the same URL don't re-fetch
92
+ const _fetchCache = new Map<string, Promise<SpaPayload>>()
138
93
 
139
- function fetchPayload(href: string): Promise<NavPayload> {
140
- if (!_cache.has(href)) {
141
- _cache.set(
94
+ function fetchSPA(href: string): Promise<SpaPayload> {
95
+ if (!_fetchCache.has(href)) {
96
+ _fetchCache.set(
142
97
  href,
143
- fetch(href, { headers: { [SPA_HEADER]: '1' } })
144
- .then(r => {
145
- if (!r.ok) throw new Error(`${r.status} ${r.statusText}`)
146
- return r.json() as Promise<NavPayload>
147
- }),
98
+ fetch(href, { headers: { [SPA_HEADER]: '1' } }).then(r => {
99
+ if (!r.ok) throw new Error(`[fnetro] ${r.status} ${r.statusText} — ${href}`)
100
+ return r.json() as Promise<SpaPayload>
101
+ }),
148
102
  )
149
103
  }
150
- return _cache.get(href)!
151
- }
152
-
153
- // ══════════════════════════════════════════════════════════════════════════════
154
- // § 6 navigate / prefetch
155
- // ══════════════════════════════════════════════════════════════════════════════
156
-
157
- export interface NavigateOptions {
158
- replace?: boolean
159
- scroll?: boolean
160
- }
161
-
162
- export async function navigate(to: string, opts: NavigateOptions = {}): Promise<void> {
163
- const u = new URL(to, location.origin)
164
- if (u.origin !== location.origin) { location.href = to; return }
165
- if (!findRoute(u.pathname)) { location.href = to; return }
166
-
167
- await runMiddleware(u.pathname, async () => {
168
- try {
169
- const payload = await fetchPayload(u.toString())
170
- history[opts.replace ? 'replaceState' : 'pushState'](
171
- { url: u.pathname }, '', u.pathname,
172
- )
173
- if (opts.scroll !== false) window.scrollTo(0, 0)
174
-
175
- _setNav?.({ path: u.pathname, data: payload.state ?? {}, params: payload.params ?? {} })
176
- syncSEO(payload.seo ?? {})
177
- } catch (err) {
178
- console.error('[fnetro] Navigation error:', err)
179
- location.href = to
180
- }
181
- })
104
+ return _fetchCache.get(href)!
182
105
  }
183
106
 
184
- /** Warm the prefetch cache for a URL on hover/focus/etc. */
185
107
  export function prefetch(url: string): void {
186
108
  try {
187
109
  const u = new URL(url, location.origin)
188
- if (u.origin !== location.origin || !findRoute(u.pathname)) return
189
- fetchPayload(u.toString())
190
- } catch { /* ignore invalid URLs */ }
110
+ if (u.origin === location.origin) fetchSPA(u.toString())
111
+ } catch { /* ignore malformed URLs */ }
191
112
  }
192
113
 
193
- // ══════════════════════════════════════════════════════════════════════════════
194
- // § 7 DOM event intercepts
195
- // ══════════════════════════════════════════════════════════════════════════════
196
-
197
- function onLinkClick(e: MouseEvent): void {
198
- if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
199
- const a = e.composedPath().find(
200
- (el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement,
201
- )
202
- if (!a?.href) return
203
- if (a.target && a.target !== '_self') return
204
- if (a.hasAttribute('data-no-spa') || a.rel?.includes('external')) return
205
- const u = new URL(a.href)
206
- if (u.origin !== location.origin) return
207
- e.preventDefault()
208
- navigate(a.href)
209
- }
114
+ // ── Client middleware ─────────────────────────────────────────────────────────
210
115
 
211
- function onLinkHover(e: MouseEvent): void {
212
- const a = e.composedPath().find(
213
- (el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement,
214
- )
215
- if (a?.href) prefetch(a.href)
216
- }
116
+ const _mw: ClientMiddleware[] = []
217
117
 
218
- function onPopState(): void {
219
- navigate(location.href, { replace: true, scroll: false })
118
+ /**
119
+ * Register a client-side navigation middleware.
120
+ * Must be called **before** `boot()`.
121
+ *
122
+ * @example
123
+ * useClientMiddleware(async (url, next) => {
124
+ * if (!isLoggedIn() && url.startsWith('/dashboard')) {
125
+ * await navigate('/login')
126
+ * return
127
+ * }
128
+ * await next()
129
+ * })
130
+ */
131
+ export function useClientMiddleware(mw: ClientMiddleware): void {
132
+ _mw.push(mw)
220
133
  }
221
134
 
222
- // ══════════════════════════════════════════════════════════════════════════════
223
- // § 8 App root component (created inside hydrate's reactive owner)
224
- // ══════════════════════════════════════════════════════════════════════════════
225
-
226
- function AppRoot(props: { initial: NavState; appLayout: LayoutDef | undefined }): any {
227
- const [nav, setNav] = createSignal<NavState>(props.initial)
228
- // Expose setter so navigate() can trigger re-renders
229
- _setNav = setNav
230
-
231
- const view = createMemo(() => {
232
- const { path, data, params } = nav()
233
- const m = findRoute(path)
234
-
235
- if (!m) {
236
- // No match client-side — shouldn't happen but handle gracefully
237
- return null as any
238
- }
239
-
240
- const layout = m.route.layout !== undefined ? m.route.layout : props.appLayout
241
- const pageEl = createComponent(m.route.page.Page as any, { ...data, url: path, params })
242
-
243
- if (!layout) return pageEl
135
+ async function runMw(url: string, done: () => Promise<void>): Promise<void> {
136
+ const chain: ClientMiddleware[] = [
137
+ ..._mw,
138
+ async (_: string, next: () => Promise<void>) => { await done(); await next() },
139
+ ]
140
+ let i = 0
141
+ const run = async (): Promise<void> => {
142
+ const fn = chain[i++]
143
+ if (fn) await fn(url, run)
144
+ }
145
+ await run()
146
+ }
244
147
 
245
- return createComponent(layout.Component as any, {
246
- url: path,
247
- params,
248
- get children() { return pageEl },
249
- })
250
- })
148
+ // ── Reactive page data ────────────────────────────────────────────────────────
149
+ //
150
+ // A single module-level reactive object that lives for the app's lifetime.
151
+ // On SPA navigation it is updated in-place so page components re-render
152
+ // reactively without being unmounted.
153
+ //
154
+ // The app provides it as readonly via DATA_KEY so page components cannot
155
+ // mutate it directly.
156
+
157
+ const _pageData = reactive<Record<string, unknown>>({})
158
+
159
+ function updatePageData(newData: Record<string, unknown>): void {
160
+ // Delete keys that are no longer present in the new data
161
+ for (const k of Object.keys(_pageData)) {
162
+ if (!(k in newData)) delete _pageData[k]
163
+ }
164
+ Object.assign(_pageData, newData)
165
+ }
251
166
 
252
- return view
167
+ /**
168
+ * Access the current page's loader data inside any Vue component.
169
+ * The returned object is reactive — it updates automatically on navigation.
170
+ *
171
+ * @example
172
+ * const data = usePageData<{ title: string; posts: Post[] }>()
173
+ * // data.title is typed and reactive
174
+ */
175
+ export function usePageData<T extends Record<string, unknown> = Record<string, unknown>>(): T {
176
+ // DATA_KEY is typed as symbol; cast to InjectionKey for strong inference
177
+ const data = inject(DATA_KEY as InjectionKey<T>)
178
+ if (data === undefined) {
179
+ throw new Error('[fnetro] usePageData() must be called inside a component setup().')
180
+ }
181
+ return data
253
182
  }
254
183
 
255
- // ══════════════════════════════════════════════════════════════════════════════
256
- // § 9 boot()
257
- // ══════════════════════════════════════════════════════════════════════════════
184
+ // ── boot() ────────────────────────────────────────────────────────────────────
258
185
 
259
186
  export interface BootOptions extends AppConfig {
260
- /** Enable hover-based prefetching. @default true */
187
+ /** Warm fetch cache on link hover. @default true */
261
188
  prefetchOnHover?: boolean
262
189
  }
263
190
 
264
191
  export async function boot(options: BootOptions): Promise<void> {
192
+ const container = document.getElementById('fnetro-app')
193
+ if (!container) {
194
+ console.error('[fnetro] #fnetro-app not found — aborting hydration.')
195
+ return
196
+ }
197
+
265
198
  const { pages } = resolveRoutes(options.routes, {
266
- layout: options.layout,
199
+ ...(options.layout !== undefined && { layout: options.layout }),
267
200
  middleware: [],
268
201
  })
269
202
 
270
- _routes = pages.map(r => ({ route: r, cp: compilePath(r.fullPath) }))
271
- _appLayout = options.layout
272
-
273
- const pathname = location.pathname
274
- if (!findRoute(pathname)) {
275
- console.warn(`[fnetro] No route matched "${pathname}" — skipping hydration`)
276
- return
277
- }
278
-
279
- // Server-injected initial state (no refetch needed on first load)
203
+ // Read server-injected bootstrap data
280
204
  const stateMap = (window as any)[STATE_KEY] as Record<string, Record<string, unknown>> ?? {}
281
- const paramsMap = (window as any)[PARAMS_KEY] as Record<string, string> ?? {}
282
205
  const seoData = (window as any)[SEO_KEY] as SEOMeta ?? {}
206
+ const pathname = location.pathname
283
207
 
284
- const initial: NavState = {
285
- path: pathname,
286
- data: stateMap[pathname] ?? {},
287
- params: paramsMap,
288
- }
208
+ // Seed reactive store and sync SEO from server data (no network request)
209
+ updatePageData(stateMap[pathname] ?? {})
210
+ syncSEO(seoData)
289
211
 
290
- const container = document.getElementById('fnetro-app')
291
- if (!container) {
292
- console.error('[fnetro] #fnetro-app not found aborting hydration')
293
- return
212
+ // Build Vue Router route table
213
+ // Async loaders are wrapped with defineAsyncComponent for code splitting.
214
+ const vueRoutes = pages.map(r => {
215
+ const layout = r.layout !== undefined ? r.layout : options.layout
216
+ const comp = r.page.component
217
+
218
+ const PageComp: Component = isAsyncLoader(comp)
219
+ ? defineAsyncComponent(comp)
220
+ : comp as Component
221
+
222
+ const routeComp: Component = layout
223
+ ? defineComponent({
224
+ name: 'FNetroRoute',
225
+ setup: () => () => h((layout as LayoutDef).component as Component, null, {
226
+ default: () => h(PageComp),
227
+ }),
228
+ })
229
+ : PageComp
230
+
231
+ return { path: toVueRouterPath(r.fullPath), component: routeComp }
232
+ })
233
+
234
+ // Pre-load the current route's async chunk BEFORE hydrating to guarantee the
235
+ // client VDOM matches the SSR HTML (avoids hydration mismatch on first load).
236
+ const currentRoute = pages.find(r => matchPath(compilePath(r.fullPath), pathname) !== null)
237
+ if (currentRoute && isAsyncLoader(currentRoute.page.component)) {
238
+ await currentRoute.page.component()
294
239
  }
295
240
 
296
- // Sync initial SEO (document.title etc.)
297
- syncSEO(seoData)
241
+ // createSSRApp: tells Vue to hydrate existing DOM instead of re-rendering
242
+ const app = createSSRApp({ name: 'FNetroApp', render: () => h(RouterView) })
243
+ app.provide(DATA_KEY as InjectionKey<typeof _pageData>, readonly(_pageData))
244
+
245
+ const router = createRouter({ history: createWebHistory(), routes: vueRoutes })
246
+
247
+ // Track whether this is the initial (server-hydrated) navigation.
248
+ // We skip data fetching for the first navigation — the server already
249
+ // injected the data into window.__FNETRO_STATE__.
250
+ let isInitialNav = true
251
+
252
+ router.beforeEach(async (to, _from, next) => {
253
+ if (isInitialNav) {
254
+ isInitialNav = false
255
+ return next()
256
+ }
298
257
 
299
- // Hydrate the server-rendered HTML with SolidJS
300
- hydrate(
301
- () => createComponent(AppRoot as any, { initial, appLayout: _appLayout }) as any,
302
- container,
303
- )
258
+ const href = new URL(to.fullPath, location.origin).toString()
304
259
 
305
- // Wire up SPA navigation
306
- document.addEventListener('click', onLinkClick)
260
+ try {
261
+ await runMw(to.fullPath, async () => {
262
+ const payload = await fetchSPA(href)
263
+ updatePageData(payload.state ?? {})
264
+ syncSEO(payload.seo ?? {})
265
+ window.scrollTo(0, 0)
266
+ })
267
+ next()
268
+ } catch (err) {
269
+ console.error('[fnetro] Navigation error:', err)
270
+ // Hard navigate as fallback — the server will handle the request
271
+ location.href = to.fullPath
272
+ }
273
+ })
274
+
275
+ app.use(router)
276
+ await router.isReady()
277
+ app.mount(container)
278
+
279
+ // Hover prefetch — warm the fetch cache before the user clicks
307
280
  if (options.prefetchOnHover !== false) {
308
- document.addEventListener('mouseover', onLinkHover)
281
+ document.addEventListener('mouseover', (e) => {
282
+ const a = (e as MouseEvent).composedPath()
283
+ .find((el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement)
284
+ if (a?.href) prefetch(a.href)
285
+ })
309
286
  }
310
- window.addEventListener('popstate', onPopState)
311
287
  }
312
288
 
313
- // ══════════════════════════════════════════════════════════════════════════════
314
- // § 10 Re-exports
315
- // ══════════════════════════════════════════════════════════════════════════════
289
+ // ── Re-exports ────────────────────────────────────────────────────────────────
316
290
 
317
291
  export {
318
- definePage, defineGroup, defineLayout, defineApiRoute,
319
- resolveRoutes, compilePath, matchPath,
320
- SPA_HEADER, STATE_KEY, PARAMS_KEY, SEO_KEY,
292
+ definePage, defineGroup, defineLayout, defineApiRoute, isAsyncLoader,
293
+ resolveRoutes, compilePath, matchPath, toVueRouterPath,
294
+ SPA_HEADER, STATE_KEY, PARAMS_KEY, SEO_KEY, DATA_KEY,
321
295
  } from './core'
322
296
 
323
297
  export type {
324
298
  AppConfig, PageDef, GroupDef, LayoutDef, ApiRouteDef, Route,
325
- PageProps, LayoutProps, SEOMeta, HonoMiddleware, LoaderCtx,
326
- ResolvedRoute, CompiledPath, ClientMiddleware,
299
+ SEOMeta, HonoMiddleware, LoaderCtx, ResolvedRoute, CompiledPath,
300
+ ClientMiddleware, AsyncLoader,
327
301
  } from './core'
302
+
303
+ // Vue Router composables re-exported for convenience
304
+ export {
305
+ useRoute,
306
+ useRouter,
307
+ RouterLink,
308
+ RouterView,
309
+ } from 'vue-router'