@netrojs/fnetro 0.1.6 → 0.2.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,227 +1,203 @@
1
1
  // ─────────────────────────────────────────────────────────────────────────────
2
2
  // FNetro · client.ts
3
- // SPA runtime · hook patching · navigation · prefetch · lifecycle
3
+ // SolidJS hydration · SPA routing · client middleware · SEO sync · prefetch
4
4
  // ─────────────────────────────────────────────────────────────────────────────
5
5
 
6
- import { render } from 'hono/jsx/dom'
7
- import { jsx } from 'hono/jsx'
6
+ import { createSignal, createMemo, createComponent } from 'solid-js'
7
+ import { hydrate } from 'solid-js/web'
8
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,
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,
17
13
  } from './core'
18
- import { resolveRoutes } from './core'
19
14
 
20
15
  // ══════════════════════════════════════════════════════════════════════════════
21
- // § 1 Patch reactivity hooks for hono/jsx/dom
16
+ // § 1 Compiled route cache (module-level, populated on boot)
22
17
  // ══════════════════════════════════════════════════════════════════════════════
23
18
 
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
- }
19
+ interface CRoute { route: ResolvedRoute; cp: CompiledPath }
43
20
 
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
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
58
30
  }
59
31
 
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
- }, [])
32
+ // ══════════════════════════════════════════════════════════════════════════════
33
+ // § 2 Navigation state signal
34
+ // ══════════════════════════════════════════════════════════════════════════════
78
35
 
79
- return proxy
36
+ interface NavState {
37
+ path: string
38
+ data: Record<string, unknown>
39
+ params: Record<string, string>
80
40
  }
81
41
 
82
- // Patch the module-level hook table
83
- Object.assign(__hooks, {
84
- useValue: clientUseValue,
85
- useLocalRef: clientUseLocalRef,
86
- useLocalReactive: clientUseLocalReactive,
87
- })
42
+ // Populated by createAppRoot(); exposed so navigate() can update it.
43
+ let _setNav: ((s: NavState) => void) | null = null
88
44
 
89
45
  // ══════════════════════════════════════════════════════════════════════════════
90
- // § 2 Path matching (mirrors server)
46
+ // § 3 Client middleware
91
47
  // ══════════════════════════════════════════════════════════════════════════════
92
48
 
93
- interface CompiledRoute {
94
- route: ResolvedRoute
95
- re: RegExp
96
- keys: string[]
97
- }
49
+ const _mw: ClientMiddleware[] = []
98
50
 
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 }
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)
106
66
  }
107
67
 
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
- }
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)
116
74
  }
117
- return null
75
+ await run()
118
76
  }
119
77
 
120
78
  // ══════════════════════════════════════════════════════════════════════════════
121
- // § 3 Navigation lifecycle hooks
79
+ // § 4 SEO client-side <head> sync
122
80
  // ══════════════════════════════════════════════════════════════════════════════
123
81
 
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)
82
+ function setMeta(selector: string, attr: string, val: string | undefined): void {
83
+ if (!val) { document.querySelector(selector)?.remove(); return }
84
+ let el = document.querySelector<HTMLMetaElement>(selector)
85
+ if (!el) {
86
+ el = document.createElement('meta')
87
+ const m = /\[(\w+[:-]?\w*)="([^"]+)"\]/.exec(selector)
88
+ if (m) el.setAttribute(m[1], m[2])
89
+ document.head.appendChild(el)
90
+ }
91
+ el.setAttribute(attr, val)
132
92
  }
133
93
 
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)
94
+ function syncSEO(seo: SEOMeta): void {
95
+ if (seo.title) document.title = seo.title
96
+
97
+ setMeta('[name="description"]', 'content', seo.description)
98
+ setMeta('[name="keywords"]', 'content', seo.keywords)
99
+ setMeta('[name="robots"]', 'content', seo.robots)
100
+ setMeta('[name="theme-color"]', 'content', seo.themeColor)
101
+ setMeta('[property="og:title"]', 'content', seo.ogTitle)
102
+ setMeta('[property="og:description"]', 'content', seo.ogDescription)
103
+ setMeta('[property="og:image"]', 'content', seo.ogImage)
104
+ setMeta('[property="og:url"]', 'content', seo.ogUrl)
105
+ setMeta('[property="og:type"]', 'content', seo.ogType)
106
+ setMeta('[name="twitter:card"]', 'content', seo.twitterCard)
107
+ setMeta('[name="twitter:title"]', 'content', seo.twitterTitle)
108
+ setMeta('[name="twitter:description"]','content', seo.twitterDescription)
109
+ setMeta('[name="twitter:image"]', 'content', seo.twitterImage)
110
+
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)
119
+ }
120
+ linkEl.href = canon
121
+ } else {
122
+ linkEl?.remove()
123
+ }
138
124
  }
139
125
 
140
126
  // ══════════════════════════════════════════════════════════════════════════════
141
- // § 4 SPA navigation
127
+ // § 5 Prefetch cache
142
128
  // ══════════════════════════════════════════════════════════════════════════════
143
129
 
144
- let compiled: CompiledRoute[] = []
145
- let currentConfig: AppConfig
146
- let currentLayout: LayoutDef | undefined
147
- const prefetchCache = new Map<string, Promise<any>>()
130
+ interface NavPayload {
131
+ state: Record<string, unknown>
132
+ params: Record<string, string>
133
+ seo: SEOMeta
134
+ url: string
135
+ }
148
136
 
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()))
137
+ const _cache = new Map<string, Promise<NavPayload>>()
138
+
139
+ function fetchPayload(href: string): Promise<NavPayload> {
140
+ if (!_cache.has(href)) {
141
+ _cache.set(
142
+ 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
+ }),
148
+ )
154
149
  }
155
- return prefetchCache.get(url)!
150
+ return _cache.get(href)!
156
151
  }
157
152
 
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
- }
153
+ // ══════════════════════════════════════════════════════════════════════════════
154
+ // § 6 navigate / prefetch
155
+ // ══════════════════════════════════════════════════════════════════════════════
172
156
 
173
157
  export interface NavigateOptions {
174
158
  replace?: boolean
175
- scroll?: boolean
159
+ scroll?: boolean
176
160
  }
177
161
 
178
- export async function navigate(
179
- to: string,
180
- opts: NavigateOptions = {}
181
- ): Promise<void> {
162
+ export async function navigate(to: string, opts: NavigateOptions = {}): Promise<void> {
182
163
  const u = new URL(to, location.origin)
183
164
  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 ?? {}
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
201
180
  }
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
- }
181
+ })
207
182
  }
208
183
 
209
- /** Warm the prefetch cache for a URL (call on hover / mousedown). */
184
+ /** Warm the prefetch cache for a URL on hover/focus/etc. */
210
185
  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())
186
+ try {
187
+ 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 */ }
215
191
  }
216
192
 
217
193
  // ══════════════════════════════════════════════════════════════════════════════
218
- // § 5 Click interceptor + popstate
194
+ // § 7 DOM event intercepts
219
195
  // ══════════════════════════════════════════════════════════════════════════════
220
196
 
221
- function interceptClicks(e: MouseEvent) {
197
+ function onLinkClick(e: MouseEvent): void {
222
198
  if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
223
199
  const a = e.composedPath().find(
224
- (el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement
200
+ (el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement,
225
201
  )
226
202
  if (!a?.href) return
227
203
  if (a.target && a.target !== '_self') return
@@ -232,76 +208,120 @@ function interceptClicks(e: MouseEvent) {
232
208
  navigate(a.href)
233
209
  }
234
210
 
235
- function interceptHover(e: MouseEvent) {
211
+ function onLinkHover(e: MouseEvent): void {
236
212
  const a = e.composedPath().find(
237
- (el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement
213
+ (el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement,
238
214
  )
239
215
  if (a?.href) prefetch(a.href)
240
216
  }
241
217
 
242
- function onPopState() {
218
+ function onPopState(): void {
243
219
  navigate(location.href, { replace: true, scroll: false })
244
220
  }
245
221
 
246
222
  // ══════════════════════════════════════════════════════════════════════════════
247
- // § 6 boot()
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
244
+
245
+ return createComponent(layout.Component as any, {
246
+ url: path,
247
+ params,
248
+ get children() { return pageEl },
249
+ })
250
+ })
251
+
252
+ return view
253
+ }
254
+
255
+ // ══════════════════════════════════════════════════════════════════════════════
256
+ // § 9 boot()
248
257
  // ══════════════════════════════════════════════════════════════════════════════
249
258
 
250
259
  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
- */
260
+ /** Enable hover-based prefetching. @default true */
255
261
  prefetchOnHover?: boolean
256
262
  }
257
263
 
258
264
  export async function boot(options: BootOptions): Promise<void> {
259
265
  const { pages } = resolveRoutes(options.routes, {
260
- layout: options.layout,
266
+ layout: options.layout,
261
267
  middleware: [],
262
268
  })
263
269
 
264
- compiled = pages.map(compileRoute)
265
- currentConfig = options
266
- currentLayout = options.layout
270
+ _routes = pages.map(r => ({ route: r, cp: compilePath(r.fullPath) }))
271
+ _appLayout = options.layout
267
272
 
268
273
  const pathname = location.pathname
269
- const match = matchRoute(compiled, 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)
280
+ 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
+ const seoData = (window as any)[SEO_KEY] as SEOMeta ?? {}
283
+
284
+ const initial: NavState = {
285
+ path: pathname,
286
+ data: stateMap[pathname] ?? {},
287
+ params: paramsMap,
288
+ }
270
289
 
271
- if (!match) {
272
- console.warn(`[fnetro] No route matched "${pathname}" — not hydrating`)
290
+ const container = document.getElementById('fnetro-app')
291
+ if (!container) {
292
+ console.error('[fnetro] #fnetro-app not found — aborting hydration')
273
293
  return
274
294
  }
275
295
 
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] ?? {}
296
+ // Sync initial SEO (document.title etc.)
297
+ syncSEO(seoData)
280
298
 
281
- await renderPage(match.route, data, pathname, paramsMap)
299
+ // Hydrate the server-rendered HTML with SolidJS
300
+ hydrate(
301
+ () => createComponent(AppRoot as any, { initial, appLayout: _appLayout }) as any,
302
+ container,
303
+ )
282
304
 
283
- // Wire up navigation
284
- document.addEventListener('click', interceptClicks)
305
+ // Wire up SPA navigation
306
+ document.addEventListener('click', onLinkClick)
285
307
  if (options.prefetchOnHover !== false) {
286
- document.addEventListener('mouseover', interceptHover)
308
+ document.addEventListener('mouseover', onLinkHover)
287
309
  }
288
310
  window.addEventListener('popstate', onPopState)
289
-
290
- for (const fn of afterNavListeners) await fn(pathname)
291
311
  }
292
312
 
293
313
  // ══════════════════════════════════════════════════════════════════════════════
294
- // § 7 Re-export core for client code that imports only client.ts
314
+ // § 10 Re-exports
295
315
  // ══════════════════════════════════════════════════════════════════════════════
316
+
296
317
  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,
318
+ definePage, defineGroup, defineLayout, defineApiRoute,
319
+ resolveRoutes, compilePath, matchPath,
320
+ SPA_HEADER, STATE_KEY, PARAMS_KEY, SEO_KEY,
302
321
  } from './core'
322
+
303
323
  export type {
304
- Ref, ComputedRef, WritableComputedRef,
305
- AppConfig, PageDef, GroupDef, LayoutDef, ApiRouteDef,
306
- WatchSource, WatchOptions,
324
+ AppConfig, PageDef, GroupDef, LayoutDef, ApiRouteDef, Route,
325
+ PageProps, LayoutProps, SEOMeta, HonoMiddleware, LoaderCtx,
326
+ ResolvedRoute, CompiledPath, ClientMiddleware,
307
327
  } from './core'