@netrojs/fnetro 0.2.21 → 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,79 +1,51 @@
1
1
  // ─────────────────────────────────────────────────────────────────────────────
2
2
  // FNetro · client.ts
3
- // SolidJS hydration · @solidjs/router SPA routing · client middleware · SEO
3
+ // Vue 3 SSR hydration · Vue Router SPA · reactive page data · SEO sync
4
4
  // ─────────────────────────────────────────────────────────────────────────────
5
5
 
6
- import { createSignal, createComponent, lazy, Suspense } from 'solid-js'
7
- import { hydrate } from 'solid-js/web'
8
- import { Router, Route } from '@solidjs/router'
9
6
  import {
10
- resolveRoutes, compilePath, matchPath,
11
- SPA_HEADER, STATE_KEY, PARAMS_KEY, SEO_KEY,
12
- type AppConfig, type ResolvedRoute, type CompiledPath,
13
- 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,
14
37
  } from './core'
15
38
 
16
- // ══════════════════════════════════════════════════════════════════════════════
17
- // § 1 Compiled route cache (module-level, populated on boot)
18
- // ══════════════════════════════════════════════════════════════════════════════
19
-
20
- interface CRoute { route: ResolvedRoute; cp: CompiledPath }
21
-
22
- let _routes: CRoute[] = []
23
- let _appLayout: LayoutDef | undefined
24
-
25
- function findRoute(pathname: string) {
26
- for (const { route, cp } of _routes) {
27
- const params = matchPath(cp, pathname)
28
- if (params !== null) return { route, params }
29
- }
30
- return null
31
- }
32
-
33
- // ══════════════════════════════════════════════════════════════════════════════
34
- // § 2 Client middleware
35
- // ══════════════════════════════════════════════════════════════════════════════
36
-
37
- const _mw: ClientMiddleware[] = []
38
-
39
- /**
40
- * Register a client-side navigation middleware.
41
- * Must be called **before** `boot()`.
42
- *
43
- * @example
44
- * useClientMiddleware(async (url, next) => {
45
- * if (!isLoggedIn() && url.startsWith('/dashboard')) {
46
- * await navigate('/login')
47
- * return // cancel original navigation
48
- * }
49
- * await next()
50
- * })
51
- */
52
- export function useClientMiddleware(mw: ClientMiddleware): void {
53
- _mw.push(mw)
54
- }
55
-
56
- async function runMiddleware(url: string, done: () => Promise<void>): Promise<void> {
57
- const chain = [..._mw, async (_u: string, next: () => Promise<void>) => { await done(); await next() }]
58
- let i = 0
59
- const run = async (): Promise<void> => {
60
- const fn = chain[i++]
61
- if (fn) await fn(url, run)
62
- }
63
- await run()
64
- }
65
-
66
- // ══════════════════════════════════════════════════════════════════════════════
67
- // § 3 SEO — client-side <head> sync
68
- // ══════════════════════════════════════════════════════════════════════════════
39
+ // ── SEO ───────────────────────────────────────────────────────────────────────
69
40
 
70
- function setMeta(selector: string, attr: string, val: string | undefined): void {
41
+ function setMeta(selector: string, attr: string, val?: string): void {
71
42
  if (!val) { document.querySelector(selector)?.remove(); return }
72
43
  let el = document.querySelector<HTMLMetaElement>(selector)
73
44
  if (!el) {
74
45
  el = document.createElement('meta')
75
- const m = /\[(\w+[:-]?\w*)="([^"]+)"\]/.exec(selector)
76
- 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)
77
49
  document.head.appendChild(el)
78
50
  }
79
51
  el.setAttribute(attr, val)
@@ -81,7 +53,6 @@ function setMeta(selector: string, attr: string, val: string | undefined): void
81
53
 
82
54
  export function syncSEO(seo: SEOMeta): void {
83
55
  if (seo.title) document.title = seo.title
84
-
85
56
  setMeta('[name="description"]', 'content', seo.description)
86
57
  setMeta('[name="keywords"]', 'content', seo.keywords)
87
58
  setMeta('[name="robots"]', 'content', seo.robots)
@@ -96,243 +67,243 @@ export function syncSEO(seo: SEOMeta): void {
96
67
  setMeta('[name="twitter:description"]','content', seo.twitterDescription)
97
68
  setMeta('[name="twitter:image"]', 'content', seo.twitterImage)
98
69
 
99
- // Canonical link
100
- const canon = seo.canonical
101
- let linkEl = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
102
- if (canon) {
103
- if (!linkEl) {
104
- linkEl = document.createElement('link')
105
- linkEl.rel = 'canonical'
106
- 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)
107
76
  }
108
- linkEl.href = canon
77
+ link.href = seo.canonical
109
78
  } else {
110
- linkEl?.remove()
79
+ link?.remove()
111
80
  }
112
81
  }
113
82
 
114
- // ══════════════════════════════════════════════════════════════════════════════
115
- // § 4 Prefetch cache + SPA data fetching
116
- // ══════════════════════════════════════════════════════════════════════════════
83
+ // ── SPA data fetch + prefetch cache ──────────────────────────────────────────
117
84
 
118
- interface NavPayload {
85
+ interface SpaPayload {
119
86
  state: Record<string, unknown>
120
87
  params: Record<string, string>
121
88
  seo: SEOMeta
122
- url: string
123
89
  }
124
90
 
125
- 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>>()
126
93
 
127
- export function fetchPayload(href: string): Promise<NavPayload> {
128
- if (!_cache.has(href)) {
129
- _cache.set(
94
+ function fetchSPA(href: string): Promise<SpaPayload> {
95
+ if (!_fetchCache.has(href)) {
96
+ _fetchCache.set(
130
97
  href,
131
- fetch(href, { headers: { [SPA_HEADER]: '1' } })
132
- .then(r => {
133
- if (!r.ok) throw new Error(`${r.status} ${r.statusText}`)
134
- return r.json() as Promise<NavPayload>
135
- }),
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
+ }),
136
102
  )
137
103
  }
138
- return _cache.get(href)!
104
+ return _fetchCache.get(href)!
139
105
  }
140
106
 
141
- /** Warm the prefetch cache for a URL on hover/focus/etc. */
142
107
  export function prefetch(url: string): void {
143
108
  try {
144
109
  const u = new URL(url, location.origin)
145
- if (u.origin !== location.origin || !findRoute(u.pathname)) return
146
- fetchPayload(u.toString())
147
- } catch { /* ignore invalid URLs */ }
110
+ if (u.origin === location.origin) fetchSPA(u.toString())
111
+ } catch { /* ignore malformed URLs */ }
148
112
  }
149
113
 
150
- // ══════════════════════════════════════════════════════════════════════════════
151
- // § 5 Route components with data loading for @solidjs/router
152
- // ══════════════════════════════════════════════════════════════════════════════
114
+ // ── Client middleware ─────────────────────────────────────────────────────────
115
+
116
+ const _mw: ClientMiddleware[] = []
153
117
 
154
118
  /**
155
- * Creates a solid-router-compatible route component that:
156
- * 1. On first render: uses server-injected state (no network request)
157
- * 2. On SPA navigation: fetches data from the FNetro server handler
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
+ * })
158
130
  */
159
- function makeRouteComponent(
160
- route: ResolvedRoute,
161
- appLayout: LayoutDef | undefined,
162
- initialState: Record<string, unknown>,
163
- initialParams: Record<string, string>,
164
- initialSeo: SEOMeta,
165
- prefetchOnHover: boolean,
166
- ) {
167
- // The component returned here is used as @solidjs/router's <Route component>
168
- return function FNetroRouteComponent(routerProps: any) {
169
- // routerProps.params comes from @solidjs/router's URL matching
170
- const routeParams: Record<string, string> = routerProps.params ?? {}
171
- const pathname: string = routerProps.location?.pathname ?? location.pathname
172
-
173
- // Determine the data source:
174
- // - If this matches the server's initial state key, use it directly (no fetch needed on first load)
175
- // - Otherwise fetch from the server via the SPA JSON endpoint
176
- const serverData = initialState[pathname] as Record<string, unknown> | undefined
177
- const [data, setData] = createSignal<Record<string, unknown>>(serverData ?? {})
178
- const [params, setParams] = createSignal<Record<string, string>>(serverData ? initialParams : routeParams)
179
-
180
- // Load data if we don't have it yet from the server
181
- if (!serverData) {
182
- const url = new URL(pathname, location.origin).toString()
183
- fetchPayload(url).then(payload => {
184
- setData(payload.state ?? {})
185
- setParams(payload.params ?? {})
186
- syncSEO(payload.seo ?? {})
187
- }).catch(err => {
188
- console.error('[fnetro] Failed to load route data:', err)
189
- })
190
- } else {
191
- // Sync SEO for the initial page from server-injected data
192
- syncSEO(initialSeo)
193
- }
194
-
195
- // Render the page (and optional layout wrapper)
196
- const layout = route.layout !== undefined ? route.layout : appLayout
197
-
198
- const pageEl = () => createComponent(route.page.Page as any, {
199
- ...data(),
200
- url: pathname,
201
- params: params(),
202
- })
203
-
204
- if (!layout) return pageEl()
131
+ export function useClientMiddleware(mw: ClientMiddleware): void {
132
+ _mw.push(mw)
133
+ }
205
134
 
206
- return createComponent(layout.Component as any, {
207
- url: pathname,
208
- params: params(),
209
- get children() { return pageEl() },
210
- })
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)
211
144
  }
145
+ await run()
212
146
  }
213
147
 
214
- // ══════════════════════════════════════════════════════════════════════════════
215
- // § 6 navigate / prefetch (convenience exports, wraps solid-router navigate)
216
- // ══════════════════════════════════════════════════════════════════════════════
217
-
218
- export interface NavigateOptions {
219
- replace?: boolean
220
- scroll?: boolean
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)
221
165
  }
222
166
 
223
167
  /**
224
- * Programmatic navigation delegates to history API and triggers
225
- * @solidjs/router's reactive location update.
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
226
174
  */
227
- export async function navigate(to: string, opts: NavigateOptions = {}): Promise<void> {
228
- const u = new URL(to, location.origin)
229
- if (u.origin !== location.origin) { location.href = to; return }
230
- if (!findRoute(u.pathname)) { location.href = to; return }
231
-
232
- await runMiddleware(u.pathname, async () => {
233
- try {
234
- // Prefetch/cache the payload so the route component can use it
235
- const payload = await fetchPayload(u.toString())
236
- history[opts.replace ? 'replaceState' : 'pushState'](
237
- { url: u.pathname }, '', u.pathname,
238
- )
239
- if (opts.scroll !== false) window.scrollTo(0, 0)
240
- syncSEO(payload.seo ?? {})
241
- // Dispatch a popstate-like event so @solidjs/router's location signal updates
242
- window.dispatchEvent(new PopStateEvent('popstate', { state: history.state }))
243
- } catch (err) {
244
- console.error('[fnetro] Navigation error:', err)
245
- location.href = to
246
- }
247
- })
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
248
182
  }
249
183
 
250
- // ══════════════════════════════════════════════════════════════════════════════
251
- // § 7 boot()
252
- // ══════════════════════════════════════════════════════════════════════════════
184
+ // ── boot() ────────────────────────────────────────────────────────────────────
253
185
 
254
186
  export interface BootOptions extends AppConfig {
255
- /** Enable hover-based prefetching. @default true */
187
+ /** Warm fetch cache on link hover. @default true */
256
188
  prefetchOnHover?: boolean
257
189
  }
258
190
 
259
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
+
260
198
  const { pages } = resolveRoutes(options.routes, {
261
- layout: options.layout,
199
+ ...(options.layout !== undefined && { layout: options.layout }),
262
200
  middleware: [],
263
201
  })
264
202
 
265
- _routes = pages.map(r => ({ route: r, cp: compilePath(r.fullPath) }))
266
- _appLayout = options.layout
267
-
268
- const pathname = location.pathname
269
- if (!findRoute(pathname)) {
270
- console.warn(`[fnetro] No route matched "${pathname}" — skipping hydration`)
271
- return
272
- }
273
-
274
- // Server-injected initial state (no refetch needed on first load)
203
+ // Read server-injected bootstrap data
275
204
  const stateMap = (window as any)[STATE_KEY] as Record<string, Record<string, unknown>> ?? {}
276
- const paramsMap = (window as any)[PARAMS_KEY] as Record<string, string> ?? {}
277
205
  const seoData = (window as any)[SEO_KEY] as SEOMeta ?? {}
206
+ const pathname = location.pathname
207
+
208
+ // Seed reactive store and sync SEO from server data (no network request)
209
+ updatePageData(stateMap[pathname] ?? {})
210
+ syncSEO(seoData)
211
+
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
+ })
278
233
 
279
- const container = document.getElementById('fnetro-app')
280
- if (!container) {
281
- console.error('[fnetro] #fnetro-app not found aborting hydration')
282
- return
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()
283
239
  }
284
240
 
285
- const prefetchOnHover = options.prefetchOnHover !== false
286
-
287
- // Build @solidjs/router <Route> elements for each resolved page
288
- const routeElements = pages.map(route =>
289
- createComponent(Route, {
290
- path: route.fullPath,
291
- component: makeRouteComponent(
292
- route,
293
- _appLayout,
294
- stateMap,
295
- paramsMap,
296
- seoData,
297
- prefetchOnHover,
298
- ),
299
- }) as any
300
- )
301
-
302
- // Hydrate with @solidjs/router wrapping all routes
303
- hydrate(
304
- () => createComponent(Router as any, {
305
- get children() { return routeElements },
306
- }) as any,
307
- container,
308
- )
309
-
310
- // Hover prefetch
311
- if (prefetchOnHover) {
312
- document.addEventListener('mouseover', (e: MouseEvent) => {
313
- const a = e.composedPath().find(
314
- (el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement,
315
- )
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
+ }
257
+
258
+ const href = new URL(to.fullPath, location.origin).toString()
259
+
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
280
+ if (options.prefetchOnHover !== false) {
281
+ document.addEventListener('mouseover', (e) => {
282
+ const a = (e as MouseEvent).composedPath()
283
+ .find((el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement)
316
284
  if (a?.href) prefetch(a.href)
317
285
  })
318
286
  }
319
287
  }
320
288
 
321
- // ══════════════════════════════════════════════════════════════════════════════
322
- // § 8 Re-exports
323
- // ══════════════════════════════════════════════════════════════════════════════
289
+ // ── Re-exports ────────────────────────────────────────────────────────────────
324
290
 
325
291
  export {
326
- definePage, defineGroup, defineLayout, defineApiRoute,
327
- resolveRoutes, compilePath, matchPath,
328
- 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,
329
295
  } from './core'
330
296
 
331
297
  export type {
332
298
  AppConfig, PageDef, GroupDef, LayoutDef, ApiRouteDef, Route,
333
- PageProps, LayoutProps, SEOMeta, HonoMiddleware, LoaderCtx,
334
- ResolvedRoute, CompiledPath, ClientMiddleware,
299
+ SEOMeta, HonoMiddleware, LoaderCtx, ResolvedRoute, CompiledPath,
300
+ ClientMiddleware, AsyncLoader,
335
301
  } from './core'
336
302
 
337
- // Re-export solid-router primitives for convenience
338
- export { useNavigate, useParams, useLocation, A, useSearchParams } from '@solidjs/router'
303
+ // Vue Router composables re-exported for convenience
304
+ export {
305
+ useRoute,
306
+ useRouter,
307
+ RouterLink,
308
+ RouterView,
309
+ } from 'vue-router'