@netrojs/fnetro 0.1.2 → 0.1.5

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.
Files changed (7) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +1194 -179
  3. package/client.ts +307 -307
  4. package/core.ts +734 -734
  5. package/dist/server.js +17 -4
  6. package/package.json +91 -91
  7. package/server.ts +433 -415
package/core.ts CHANGED
@@ -1,734 +1,734 @@
1
- // ─────────────────────────────────────────────────────────────────────────────
2
- // FNetro · core.ts
3
- // Full Vue-like reactivity + route / layout / middleware definitions
4
- // ─────────────────────────────────────────────────────────────────────────────
5
-
6
- import type { Context, MiddlewareHandler, Hono } from 'hono'
7
-
8
- // ══════════════════════════════════════════════════════════════════════════════
9
- // § 1 Dependency tracking
10
- // ══════════════════════════════════════════════════════════════════════════════
11
-
12
- export type EffectFn = () => void | (() => void)
13
-
14
- const RAW = Symbol('raw')
15
- const IS_REACTIVE = Symbol('isReactive')
16
- const IS_READONLY = Symbol('isReadonly')
17
- const IS_REF = Symbol('isRef')
18
- const MARK_RAW = Symbol('markRaw')
19
-
20
- // Per-target, per-key subscriber sets
21
- const targetMap = new WeakMap<object, Map<PropertyKey, Set<ReactiveEffect>>>()
22
-
23
- let activeEffect: ReactiveEffect | null = null
24
- let shouldTrack = true
25
- let trackStack: boolean[] = []
26
-
27
- function pauseTracking() { trackStack.push(shouldTrack); shouldTrack = false }
28
- function resetTracking() { shouldTrack = trackStack.pop() ?? true }
29
-
30
- function track(target: object, key: PropertyKey) {
31
- if (!shouldTrack || !activeEffect) return
32
- let depsMap = targetMap.get(target)
33
- if (!depsMap) targetMap.set(target, (depsMap = new Map()))
34
- let dep = depsMap.get(key)
35
- if (!dep) depsMap.set(key, (dep = new Set()))
36
- trackEffect(activeEffect, dep)
37
- }
38
-
39
- function trackEffect(effect: ReactiveEffect, dep: Set<ReactiveEffect>) {
40
- if (!dep.has(effect)) {
41
- dep.add(effect)
42
- effect.deps.push(dep)
43
- }
44
- }
45
-
46
- function trigger(target: object, key: PropertyKey, newVal?: unknown, oldVal?: unknown) {
47
- const depsMap = targetMap.get(target)
48
- if (!depsMap) return
49
- const effects: ReactiveEffect[] = []
50
- const computedEffects: ReactiveEffect[] = []
51
-
52
- depsMap.get(key)?.forEach(e => {
53
- if (e !== activeEffect) {
54
- e.computed ? computedEffects.push(e) : effects.push(e)
55
- }
56
- })
57
- // Computed run first so dependents see fresh values
58
- ;[...computedEffects, ...effects].forEach(e => {
59
- if (e.active) e.scheduler ? e.scheduler() : e.run()
60
- })
61
- }
62
-
63
- // ══════════════════════════════════════════════════════════════════════════════
64
- // § 2 ReactiveEffect
65
- // ══════════════════════════════════════════════════════════════════════════════
66
-
67
- export class ReactiveEffect {
68
- deps: Set<ReactiveEffect>[] = []
69
- active = true
70
- cleanup?: () => void
71
- computed = false
72
-
73
- constructor(
74
- public fn: () => any,
75
- public scheduler?: () => void,
76
- public scope?: EffectScope,
77
- ) {
78
- scope?.effects.push(this)
79
- }
80
-
81
- run(): any {
82
- if (!this.active) return this.fn()
83
- const prevEffect = activeEffect
84
- const prevShouldTrack = shouldTrack
85
- shouldTrack = true
86
- activeEffect = this
87
- this.cleanup?.()
88
- this.cleanup = undefined
89
- this.deps.length = 0
90
- try {
91
- const result = this.fn()
92
- if (typeof result === 'function') this.cleanup = result
93
- return result
94
- } finally {
95
- activeEffect = prevEffect
96
- shouldTrack = prevShouldTrack
97
- }
98
- }
99
-
100
- stop() {
101
- if (this.active) {
102
- cleanupEffect(this)
103
- this.active = false
104
- }
105
- }
106
- }
107
-
108
- function cleanupEffect(e: ReactiveEffect) {
109
- e.deps.forEach(dep => dep.delete(e))
110
- e.deps.length = 0
111
- }
112
-
113
- // ══════════════════════════════════════════════════════════════════════════════
114
- // § 3 EffectScope
115
- // ══════════════════════════════════════════════════════════════════════════════
116
-
117
- let activeScope: EffectScope | undefined
118
-
119
- export class EffectScope {
120
- effects: ReactiveEffect[] = []
121
- cleanups: (() => void)[] = []
122
- active = true
123
-
124
- run<T>(fn: () => T): T {
125
- const prev = activeScope
126
- activeScope = this
127
- try { return fn() }
128
- finally { activeScope = prev }
129
- }
130
-
131
- stop() {
132
- if (this.active) {
133
- this.effects.forEach(e => e.stop())
134
- this.cleanups.forEach(fn => fn())
135
- this.active = false
136
- }
137
- }
138
-
139
- onCleanup(fn: () => void) { this.cleanups.push(fn) }
140
- }
141
-
142
- export function effectScope(): EffectScope {
143
- return new EffectScope()
144
- }
145
-
146
- export function getCurrentScope(): EffectScope | undefined {
147
- return activeScope
148
- }
149
-
150
- export function onScopeDispose(fn: () => void) {
151
- activeScope?.onCleanup(fn)
152
- }
153
-
154
- // ══════════════════════════════════════════════════════════════════════════════
155
- // § 4 effect / watchEffect
156
- // ══════════════════════════════════════════════════════════════════════════════
157
-
158
- export function effect(fn: EffectFn): () => void {
159
- const e = new ReactiveEffect(fn, undefined, activeScope)
160
- e.run()
161
- return () => e.stop()
162
- }
163
-
164
- export interface WatchEffectOptions {
165
- flush?: 'sync' | 'post'
166
- onTrack?: (e: any) => void
167
- onTrigger?: (e: any) => void
168
- }
169
-
170
- export function watchEffect(fn: EffectFn, opts?: WatchEffectOptions): () => void {
171
- const e = new ReactiveEffect(fn, undefined, activeScope)
172
- e.run()
173
- return () => e.stop()
174
- }
175
-
176
- // ══════════════════════════════════════════════════════════════════════════════
177
- // § 5 Ref
178
- // ══════════════════════════════════════════════════════════════════════════════
179
-
180
- export interface Ref<T = unknown> {
181
- value: T
182
- readonly [IS_REF]: true
183
- }
184
-
185
- const refTarget = Symbol('refTarget')
186
-
187
- class RefImpl<T> implements Ref<T> {
188
- readonly [IS_REF] = true as const
189
- private _value: T
190
- private _subscribers = new Set<() => void>()
191
-
192
- constructor(value: T, private readonly shallow = false) {
193
- this._value = shallow ? value : toReactive(value)
194
- }
195
-
196
- get value(): T {
197
- track(this as any, refTarget)
198
- this._subscribers.forEach(fn => { /* for useSyncExternalStore */ })
199
- return this._value
200
- }
201
-
202
- set value(next: T) {
203
- const newVal = this.shallow ? next : toReactive(next)
204
- if (!hasChanged(newVal, this._value)) return
205
- this._value = newVal
206
- trigger(this as any, refTarget, newVal, this._value)
207
- this._subscribers.forEach(fn => fn())
208
- }
209
-
210
- /** Subscribe for useSyncExternalStore */
211
- subscribe(fn: () => void): () => void {
212
- this._subscribers.add(fn)
213
- return () => this._subscribers.delete(fn)
214
- }
215
-
216
- peek(): T { return this._value }
217
- }
218
-
219
- export function ref<T>(value: T): Ref<T> {
220
- return isRef(value) ? value as Ref<T> : new RefImpl(value)
221
- }
222
-
223
- export function shallowRef<T>(value: T): Ref<T> {
224
- return new RefImpl(value, true)
225
- }
226
-
227
- export function triggerRef(r: Ref): void {
228
- if (r instanceof RefImpl) {
229
- trigger(r as any, refTarget)
230
- ;(r as any)._subscribers.forEach((fn: () => void) => fn())
231
- }
232
- }
233
-
234
- export function isRef<T = unknown>(r: unknown): r is Ref<T> {
235
- return !!r && typeof r === 'object' && (r as any)[IS_REF] === true
236
- }
237
-
238
- export function unref<T>(r: T | Ref<T>): T {
239
- return isRef(r) ? r.value : r
240
- }
241
-
242
- export function toRef<T extends object, K extends keyof T>(obj: T, key: K): Ref<T[K]> {
243
- const r = new RefImpl<T[K]>(undefined as T[K], false)
244
- Object.defineProperty(r, 'value', {
245
- get() { track(r as any, refTarget); return obj[key] },
246
- set(v: T[K]) { obj[key] = v; trigger(r as any, refTarget, v, obj[key]) }
247
- })
248
- return r
249
- }
250
-
251
- export function toRefs<T extends object>(obj: T): { [K in keyof T]: Ref<T[K]> } {
252
- const result = {} as { [K in keyof T]: Ref<T[K]> }
253
- for (const key in obj) result[key] = toRef(obj, key)
254
- return result
255
- }
256
-
257
- // ══════════════════════════════════════════════════════════════════════════════
258
- // § 6 Computed
259
- // ══════════════════════════════════════════════════════════════════════════════
260
-
261
- export interface WritableComputedRef<T> extends Ref<T> {
262
- readonly effect: ReactiveEffect
263
- }
264
-
265
- export interface ComputedRef<T> extends WritableComputedRef<T> {
266
- readonly value: T
267
- }
268
-
269
- class ComputedRefImpl<T> implements Ref<T> {
270
- readonly [IS_REF] = true as const
271
- readonly effect: ReactiveEffect
272
- private _value!: T
273
- private _dirty = true
274
- private _subscribers = new Set<() => void>()
275
-
276
- constructor(getter: () => T, private setter?: (v: T) => void) {
277
- this.effect = new ReactiveEffect(getter, () => {
278
- if (!this._dirty) {
279
- this._dirty = true
280
- trigger(this as any, refTarget)
281
- this._subscribers.forEach(fn => fn())
282
- }
283
- }, activeScope)
284
- this.effect.computed = true
285
- }
286
-
287
- get value(): T {
288
- track(this as any, refTarget)
289
- if (this._dirty) {
290
- this._dirty = false
291
- this._value = this.effect.run()
292
- }
293
- return this._value
294
- }
295
-
296
- set value(v: T) {
297
- this.setter?.(v)
298
- }
299
-
300
- subscribe(fn: () => void): () => void {
301
- this._subscribers.add(fn)
302
- return () => this._subscribers.delete(fn)
303
- }
304
-
305
- peek(): T { return this._value }
306
- }
307
-
308
- export function computed<T>(getter: () => T): ComputedRef<T>
309
- export function computed<T>(opts: { get: () => T; set: (v: T) => void }): WritableComputedRef<T>
310
- export function computed<T>(arg: (() => T) | { get: () => T; set: (v: T) => void }): ComputedRef<T> {
311
- if (typeof arg === 'function') {
312
- return new ComputedRefImpl(arg) as ComputedRef<T>
313
- }
314
- return new ComputedRefImpl(arg.get, arg.set) as ComputedRef<T>
315
- }
316
-
317
- // ══════════════════════════════════════════════════════════════════════════════
318
- // § 7 Reactive proxy
319
- // ══════════════════════════════════════════════════════════════════════════════
320
-
321
- const reactiveMap = new WeakMap<object, object>()
322
- const readonlyMap = new WeakMap<object, object>()
323
- const shallowReactiveMap = new WeakMap<object, object>()
324
-
325
- function toReactive<T>(value: T): T {
326
- return value !== null && typeof value === 'object' ? reactive(value as object) as T : value
327
- }
328
-
329
- const arrayInstrumentations: Record<string, Function> = {}
330
- ;['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
331
- arrayInstrumentations[method] = function (this: unknown[], ...args: unknown[]) {
332
- const arr = toRaw(this) as unknown[]
333
- for (let i = 0; i < this.length; i++) track(arr, i)
334
- let res = (arr as any)[method](...args)
335
- if (res === -1 || res === false) res = (arr as any)[method](...args.map(toRaw))
336
- return res
337
- }
338
- })
339
- ;['push', 'pop', 'shift', 'unshift', 'splice'].forEach(method => {
340
- arrayInstrumentations[method] = function (this: unknown[], ...args: unknown[]) {
341
- pauseTracking()
342
- const res = (toRaw(this) as any)[method].apply(this, args)
343
- resetTracking()
344
- return res
345
- }
346
- })
347
-
348
- function createHandler(shallow = false, readonly = false): ProxyHandler<object> {
349
- return {
350
- get(target, key, receiver) {
351
- if (key === RAW) return target
352
- if (key === IS_REACTIVE) return !readonly
353
- if (key === IS_READONLY) return readonly
354
- if (key === MARK_RAW) return (target as any)[MARK_RAW]
355
-
356
- const isArray = Array.isArray(target)
357
- if (!readonly && isArray && hasOwn(arrayInstrumentations, key as string)) {
358
- return Reflect.get(arrayInstrumentations, key, receiver)
359
- }
360
-
361
- const res = Reflect.get(target, key, receiver)
362
- if (typeof key === 'symbol' || key === '__proto__') return res
363
-
364
- if (!readonly) track(target, key)
365
-
366
- if (shallow) return res
367
- if (isRef(res)) return isArray ? res : res.value
368
- return res !== null && typeof res === 'object' && !res[MARK_RAW]
369
- ? readonly ? readonlyProxy(res) : reactive(res)
370
- : res
371
- },
372
- set(target, key, value, receiver) {
373
- if (readonly) {
374
- console.warn(`[fnetro] Cannot set "${String(key)}" on readonly object`)
375
- return true
376
- }
377
- const oldVal = (target as any)[key]
378
- const result = Reflect.set(target, key, value, receiver)
379
- if (hasChanged(value, oldVal)) trigger(target, key, value, oldVal)
380
- return result
381
- },
382
- deleteProperty(target, key) {
383
- if (readonly) return true
384
- const hadKey = hasOwn(target, key as string)
385
- const result = Reflect.deleteProperty(target, key)
386
- if (hadKey && result) trigger(target, key)
387
- return result
388
- },
389
- has(target, key) {
390
- const res = Reflect.has(target, key)
391
- track(target, key)
392
- return res
393
- },
394
- ownKeys(target) {
395
- track(target, Array.isArray(target) ? 'length' : '__iterate__')
396
- return Reflect.ownKeys(target)
397
- }
398
- }
399
- }
400
-
401
- export function reactive<T extends object>(target: T): T {
402
- if (isReadonly(target)) return target
403
- if ((target as any)[MARK_RAW]) return target
404
- if (reactiveMap.has(target)) return reactiveMap.get(target) as T
405
- const proxy = new Proxy(target, createHandler()) as T
406
- reactiveMap.set(target, proxy)
407
- return proxy
408
- }
409
-
410
- export function shallowReactive<T extends object>(target: T): T {
411
- if (shallowReactiveMap.has(target)) return shallowReactiveMap.get(target) as T
412
- const proxy = new Proxy(target, createHandler(true)) as T
413
- shallowReactiveMap.set(target, proxy)
414
- return proxy
415
- }
416
-
417
- function readonlyProxy<T extends object>(target: T): T {
418
- if (readonlyMap.has(target)) return readonlyMap.get(target) as T
419
- const proxy = new Proxy(target, createHandler(false, true)) as T
420
- readonlyMap.set(target, proxy)
421
- return proxy
422
- }
423
-
424
- export function readonly<T extends object>(target: T): Readonly<T> {
425
- return readonlyProxy(target)
426
- }
427
-
428
- export function markRaw<T extends object>(value: T): T {
429
- ;(value as any)[MARK_RAW] = true
430
- return value
431
- }
432
-
433
- export function toRaw<T>(observed: T): T {
434
- const raw = (observed as any)?.[RAW]
435
- return raw ? toRaw(raw) : observed
436
- }
437
-
438
- export function isReactive(value: unknown): boolean {
439
- if (isReadonly(value)) return isReactive((value as any)[RAW])
440
- return !!(value && (value as any)[IS_REACTIVE])
441
- }
442
-
443
- export function isReadonly(value: unknown): boolean {
444
- return !!(value && (value as any)[IS_READONLY])
445
- }
446
-
447
- // ══════════════════════════════════════════════════════════════════════════════
448
- // § 8 watch
449
- // ══════════════════════════════════════════════════════════════════════════════
450
-
451
- export type WatchSource<T = unknown> = Ref<T> | ComputedRef<T> | (() => T)
452
- export type MultiSource = WatchSource[] | readonly WatchSource[]
453
- type MapSources<T, Immediate = false> = {
454
- [K in keyof T]: T[K] extends WatchSource<infer V>
455
- ? Immediate extends true ? V | undefined : V
456
- : T[K] extends object ? T[K] : never
457
- }
458
-
459
- export interface WatchOptions<Immediate = boolean> {
460
- immediate?: Immediate
461
- deep?: boolean
462
- once?: boolean
463
- }
464
-
465
- type StopHandle = () => void
466
- type CleanupFn = (fn: () => void) => void
467
-
468
- function traverse(value: unknown, seen = new Set()): unknown {
469
- if (!value || typeof value !== 'object' || seen.has(value)) return value
470
- seen.add(value)
471
- if (isRef(value)) { traverse(value.value, seen); return value }
472
- if (Array.isArray(value)) { value.forEach(v => traverse(v, seen)); return value }
473
- for (const key in value as object) traverse((value as any)[key], seen)
474
- return value
475
- }
476
-
477
- function normalizeSource<T>(src: WatchSource<T> | MultiSource): () => any {
478
- if (Array.isArray(src)) return () => src.map(s => isRef(s) ? s.value : s())
479
- if (isRef(src)) return () => src.value
480
- return src as () => T
481
- }
482
-
483
- export function watch<T>(
484
- source: WatchSource<T>,
485
- cb: (val: T, old: T | undefined, cleanup: CleanupFn) => void,
486
- opts?: WatchOptions
487
- ): StopHandle
488
- export function watch<T extends MultiSource>(
489
- source: T,
490
- cb: (val: MapSources<T>, old: MapSources<T, true>, cleanup: CleanupFn) => void,
491
- opts?: WatchOptions
492
- ): StopHandle
493
- export function watch(source: any, cb: any, opts: WatchOptions = {}): StopHandle {
494
- const getter = opts.deep
495
- ? () => traverse(normalizeSource(source)())
496
- : normalizeSource(source)
497
-
498
- let oldVal: any = undefined
499
- let cleanupFn: (() => void) | undefined
500
-
501
- const cleanup: CleanupFn = (fn) => { cleanupFn = fn }
502
-
503
- const job = () => {
504
- if (!effect.active) return
505
- cleanupFn?.(); cleanupFn = undefined
506
- const newVal = effect.run()
507
- if (opts.deep || hasChanged(newVal, oldVal)) {
508
- cb(newVal, oldVal, cleanup)
509
- oldVal = newVal
510
- }
511
- if (opts.once) effect.stop()
512
- }
513
-
514
- const effect = new ReactiveEffect(getter, job, activeScope)
515
-
516
- if (opts.immediate) {
517
- cleanupFn?.(); cleanupFn = undefined
518
- const val = effect.run()
519
- cb(val, oldVal, cleanup)
520
- oldVal = val
521
- } else {
522
- oldVal = effect.run()
523
- }
524
-
525
- return () => effect.stop()
526
- }
527
-
528
- // ══════════════════════════════════════════════════════════════════════════════
529
- // § 9 Component hooks (JSX-aware)
530
- // Server: returns plain values. Client: patched by client.ts
531
- // ══════════════════════════════════════════════════════════════════════════════
532
-
533
- interface FNetroHooks {
534
- useValue<T>(r: Ref<T> | (() => T)): T
535
- useLocalRef<T>(init: T): Ref<T>
536
- useLocalReactive<T extends object>(init: T): T
537
- }
538
-
539
- // SSR fallbacks — no re-renders needed on server
540
- export const __hooks: FNetroHooks = {
541
- useValue: (r) => isRef(r) ? r.value : r(),
542
- useLocalRef: (init) => ref(init),
543
- useLocalReactive: (init) => reactive(init),
544
- }
545
-
546
- /**
547
- * Subscribe to a Ref or computed getter inside a JSX component.
548
- * On the server, returns the current value (no reactivity needed).
549
- * On the client, re-renders the component whenever the value changes.
550
- *
551
- * @example
552
- * const count = ref(0)
553
- * function Counter() {
554
- * const n = use(count)
555
- * return <button onClick={() => count.value++}>{n}</button>
556
- * }
557
- */
558
- export function use<T>(source: Ref<T> | (() => T)): T {
559
- return __hooks.useValue(source)
560
- }
561
-
562
- /**
563
- * Create a component-local reactive Ref.
564
- * Unlike module-level `ref()`, this is scoped to the component lifecycle.
565
- *
566
- * @example
567
- * function Input() {
568
- * const text = useLocalRef('')
569
- * return <input value={use(text)} onInput={e => text.value = e.target.value} />
570
- * }
571
- */
572
- export function useLocalRef<T>(init: T): Ref<T> {
573
- return __hooks.useLocalRef(init)
574
- }
575
-
576
- /**
577
- * Create a component-local reactive object.
578
- * @example
579
- * function Form() {
580
- * const form = useLocalReactive({ name: '', email: '' })
581
- * return <input value={form.name} onInput={e => form.name = e.target.value} />
582
- * }
583
- */
584
- export function useLocalReactive<T extends object>(init: T): T {
585
- return __hooks.useLocalReactive(init)
586
- }
587
-
588
- // ══════════════════════════════════════════════════════════════════════════════
589
- // § 10 Route / App definitions
590
- // ══════════════════════════════════════════════════════════════════════════════
591
-
592
- export type LoaderCtx = Context
593
- export type FNetroMiddleware = MiddlewareHandler
594
- export type AnyJSX = any
595
-
596
- export interface PageDef<TData extends object = {}> {
597
- readonly __type: 'page'
598
- path: string
599
- /** Middleware applied only to this route */
600
- middleware?: FNetroMiddleware[]
601
- /** Server-side data loader. Return value becomes Page props. */
602
- loader?: (c: LoaderCtx) => TData | Promise<TData>
603
- /** Override the group/app layout for this page. Pass `false` to use no layout. */
604
- layout?: LayoutDef | false
605
- /** The JSX page component */
606
- Page: (props: TData & { url: string; params: Record<string, string> }) => AnyJSX
607
- }
608
-
609
- export interface GroupDef {
610
- readonly __type: 'group'
611
- /** URL prefix — e.g. '/admin' */
612
- prefix: string
613
- /** Layout override for all pages in this group */
614
- layout?: LayoutDef | false
615
- /** Middleware applied to every route in the group */
616
- middleware?: FNetroMiddleware[]
617
- /** Pages and nested groups */
618
- routes: (PageDef<any> | GroupDef | ApiRouteDef)[]
619
- }
620
-
621
- export interface LayoutDef {
622
- readonly __type: 'layout'
623
- Component: (props: { children: AnyJSX; url: string; params: Record<string, string> }) => AnyJSX
624
- }
625
-
626
- export interface ApiRouteDef {
627
- readonly __type: 'api'
628
- /** Mount path — e.g. '/api' or '/api/admin' */
629
- path: string
630
- /** Register raw Hono routes on the provided sub-app */
631
- register: (app: Hono, middleware: FNetroMiddleware[]) => void
632
- }
633
-
634
- export interface MiddlewareDef {
635
- readonly __type: 'middleware'
636
- handler: FNetroMiddleware
637
- }
638
-
639
- export interface AppConfig {
640
- /** Default layout for all pages */
641
- layout?: LayoutDef
642
- /** Global middleware applied before every route */
643
- middleware?: FNetroMiddleware[]
644
- /** Top-level routes, groups, and API routes */
645
- routes: (PageDef<any> | GroupDef | ApiRouteDef)[]
646
- /** 404 page */
647
- notFound?: () => AnyJSX
648
- }
649
-
650
- // ── Builder functions ─────────────────────────────────────────────────────────
651
-
652
- export function definePage<TData extends object = {}>(
653
- def: Omit<PageDef<TData>, '__type'>
654
- ): PageDef<TData> {
655
- return { __type: 'page', ...def }
656
- }
657
-
658
- export function defineGroup(
659
- def: Omit<GroupDef, '__type'>
660
- ): GroupDef {
661
- return { __type: 'group', ...def }
662
- }
663
-
664
- export function defineLayout(
665
- Component: LayoutDef['Component']
666
- ): LayoutDef {
667
- return { __type: 'layout', Component }
668
- }
669
-
670
- export function defineMiddleware(handler: FNetroMiddleware): MiddlewareDef {
671
- return { __type: 'middleware', handler }
672
- }
673
-
674
- export function defineApiRoute(
675
- path: string,
676
- register: ApiRouteDef['register']
677
- ): ApiRouteDef {
678
- return { __type: 'api', path, register }
679
- }
680
-
681
- // ── Internal route resolution ─────────────────────────────────────────────────
682
-
683
- export interface ResolvedRoute {
684
- fullPath: string
685
- page: PageDef<any>
686
- layout: LayoutDef | false | undefined
687
- middleware: FNetroMiddleware[]
688
- }
689
-
690
- export function resolveRoutes(
691
- routes: (PageDef<any> | GroupDef | ApiRouteDef)[],
692
- options: {
693
- prefix?: string
694
- middleware?: FNetroMiddleware[]
695
- layout?: LayoutDef | false
696
- } = {}
697
- ): { pages: ResolvedRoute[]; apis: ApiRouteDef[] } {
698
- const pages: ResolvedRoute[] = []
699
- const apis: ApiRouteDef[] = []
700
-
701
- for (const route of routes) {
702
- if (route.__type === 'api') {
703
- apis.push({ ...route, path: (options.prefix ?? '') + route.path })
704
- } else if (route.__type === 'group') {
705
- const prefix = (options.prefix ?? '') + route.prefix
706
- const mw = [...(options.middleware ?? []), ...(route.middleware ?? [])]
707
- const layout = route.layout !== undefined ? route.layout : options.layout
708
- const sub = resolveRoutes(route.routes, { prefix, middleware: mw, layout })
709
- pages.push(...sub.pages)
710
- apis.push(...sub.apis)
711
- } else {
712
- const fullPath = (options.prefix ?? '') + route.path
713
- const layout = route.layout !== undefined ? route.layout : options.layout
714
- const middleware = [...(options.middleware ?? []), ...(route.middleware ?? [])]
715
- pages.push({ fullPath, page: route, layout, middleware })
716
- }
717
- }
718
-
719
- return { pages, apis }
720
- }
721
-
722
- // ── Shared constants ──────────────────────────────────────────────────────────
723
- export const SPA_HEADER = 'x-fnetro-spa'
724
- export const STATE_KEY = '__FNETRO_STATE__'
725
- export const PARAMS_KEY = '__FNETRO_PARAMS__'
726
-
727
- // ── Utilities ─────────────────────────────────────────────────────────────────
728
- function hasChanged(a: unknown, b: unknown): boolean {
729
- return !Object.is(a, b)
730
- }
731
-
732
- function hasOwn(obj: object, key: string): boolean {
733
- return Object.prototype.hasOwnProperty.call(obj, key)
734
- }
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // FNetro · core.ts
3
+ // Full Vue-like reactivity + route / layout / middleware definitions
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+
6
+ import type { Context, MiddlewareHandler, Hono } from 'hono'
7
+
8
+ // ══════════════════════════════════════════════════════════════════════════════
9
+ // § 1 Dependency tracking
10
+ // ══════════════════════════════════════════════════════════════════════════════
11
+
12
+ export type EffectFn = () => void | (() => void)
13
+
14
+ const RAW = Symbol('raw')
15
+ const IS_REACTIVE = Symbol('isReactive')
16
+ const IS_READONLY = Symbol('isReadonly')
17
+ const IS_REF = Symbol('isRef')
18
+ const MARK_RAW = Symbol('markRaw')
19
+
20
+ // Per-target, per-key subscriber sets
21
+ const targetMap = new WeakMap<object, Map<PropertyKey, Set<ReactiveEffect>>>()
22
+
23
+ let activeEffect: ReactiveEffect | null = null
24
+ let shouldTrack = true
25
+ let trackStack: boolean[] = []
26
+
27
+ function pauseTracking() { trackStack.push(shouldTrack); shouldTrack = false }
28
+ function resetTracking() { shouldTrack = trackStack.pop() ?? true }
29
+
30
+ function track(target: object, key: PropertyKey) {
31
+ if (!shouldTrack || !activeEffect) return
32
+ let depsMap = targetMap.get(target)
33
+ if (!depsMap) targetMap.set(target, (depsMap = new Map()))
34
+ let dep = depsMap.get(key)
35
+ if (!dep) depsMap.set(key, (dep = new Set()))
36
+ trackEffect(activeEffect, dep)
37
+ }
38
+
39
+ function trackEffect(effect: ReactiveEffect, dep: Set<ReactiveEffect>) {
40
+ if (!dep.has(effect)) {
41
+ dep.add(effect)
42
+ effect.deps.push(dep)
43
+ }
44
+ }
45
+
46
+ function trigger(target: object, key: PropertyKey, newVal?: unknown, oldVal?: unknown) {
47
+ const depsMap = targetMap.get(target)
48
+ if (!depsMap) return
49
+ const effects: ReactiveEffect[] = []
50
+ const computedEffects: ReactiveEffect[] = []
51
+
52
+ depsMap.get(key)?.forEach(e => {
53
+ if (e !== activeEffect) {
54
+ e.computed ? computedEffects.push(e) : effects.push(e)
55
+ }
56
+ })
57
+ // Computed run first so dependents see fresh values
58
+ ;[...computedEffects, ...effects].forEach(e => {
59
+ if (e.active) e.scheduler ? e.scheduler() : e.run()
60
+ })
61
+ }
62
+
63
+ // ══════════════════════════════════════════════════════════════════════════════
64
+ // § 2 ReactiveEffect
65
+ // ══════════════════════════════════════════════════════════════════════════════
66
+
67
+ export class ReactiveEffect {
68
+ deps: Set<ReactiveEffect>[] = []
69
+ active = true
70
+ cleanup?: () => void
71
+ computed = false
72
+
73
+ constructor(
74
+ public fn: () => any,
75
+ public scheduler?: () => void,
76
+ public scope?: EffectScope,
77
+ ) {
78
+ scope?.effects.push(this)
79
+ }
80
+
81
+ run(): any {
82
+ if (!this.active) return this.fn()
83
+ const prevEffect = activeEffect
84
+ const prevShouldTrack = shouldTrack
85
+ shouldTrack = true
86
+ activeEffect = this
87
+ this.cleanup?.()
88
+ this.cleanup = undefined
89
+ this.deps.length = 0
90
+ try {
91
+ const result = this.fn()
92
+ if (typeof result === 'function') this.cleanup = result
93
+ return result
94
+ } finally {
95
+ activeEffect = prevEffect
96
+ shouldTrack = prevShouldTrack
97
+ }
98
+ }
99
+
100
+ stop() {
101
+ if (this.active) {
102
+ cleanupEffect(this)
103
+ this.active = false
104
+ }
105
+ }
106
+ }
107
+
108
+ function cleanupEffect(e: ReactiveEffect) {
109
+ e.deps.forEach(dep => dep.delete(e))
110
+ e.deps.length = 0
111
+ }
112
+
113
+ // ══════════════════════════════════════════════════════════════════════════════
114
+ // § 3 EffectScope
115
+ // ══════════════════════════════════════════════════════════════════════════════
116
+
117
+ let activeScope: EffectScope | undefined
118
+
119
+ export class EffectScope {
120
+ effects: ReactiveEffect[] = []
121
+ cleanups: (() => void)[] = []
122
+ active = true
123
+
124
+ run<T>(fn: () => T): T {
125
+ const prev = activeScope
126
+ activeScope = this
127
+ try { return fn() }
128
+ finally { activeScope = prev }
129
+ }
130
+
131
+ stop() {
132
+ if (this.active) {
133
+ this.effects.forEach(e => e.stop())
134
+ this.cleanups.forEach(fn => fn())
135
+ this.active = false
136
+ }
137
+ }
138
+
139
+ onCleanup(fn: () => void) { this.cleanups.push(fn) }
140
+ }
141
+
142
+ export function effectScope(): EffectScope {
143
+ return new EffectScope()
144
+ }
145
+
146
+ export function getCurrentScope(): EffectScope | undefined {
147
+ return activeScope
148
+ }
149
+
150
+ export function onScopeDispose(fn: () => void) {
151
+ activeScope?.onCleanup(fn)
152
+ }
153
+
154
+ // ══════════════════════════════════════════════════════════════════════════════
155
+ // § 4 effect / watchEffect
156
+ // ══════════════════════════════════════════════════════════════════════════════
157
+
158
+ export function effect(fn: EffectFn): () => void {
159
+ const e = new ReactiveEffect(fn, undefined, activeScope)
160
+ e.run()
161
+ return () => e.stop()
162
+ }
163
+
164
+ export interface WatchEffectOptions {
165
+ flush?: 'sync' | 'post'
166
+ onTrack?: (e: any) => void
167
+ onTrigger?: (e: any) => void
168
+ }
169
+
170
+ export function watchEffect(fn: EffectFn, opts?: WatchEffectOptions): () => void {
171
+ const e = new ReactiveEffect(fn, undefined, activeScope)
172
+ e.run()
173
+ return () => e.stop()
174
+ }
175
+
176
+ // ══════════════════════════════════════════════════════════════════════════════
177
+ // § 5 Ref
178
+ // ══════════════════════════════════════════════════════════════════════════════
179
+
180
+ export interface Ref<T = unknown> {
181
+ value: T
182
+ readonly [IS_REF]: true
183
+ }
184
+
185
+ const refTarget = Symbol('refTarget')
186
+
187
+ class RefImpl<T> implements Ref<T> {
188
+ readonly [IS_REF] = true as const
189
+ private _value: T
190
+ private _subscribers = new Set<() => void>()
191
+
192
+ constructor(value: T, private readonly shallow = false) {
193
+ this._value = shallow ? value : toReactive(value)
194
+ }
195
+
196
+ get value(): T {
197
+ track(this as any, refTarget)
198
+ this._subscribers.forEach(fn => { /* for useSyncExternalStore */ })
199
+ return this._value
200
+ }
201
+
202
+ set value(next: T) {
203
+ const newVal = this.shallow ? next : toReactive(next)
204
+ if (!hasChanged(newVal, this._value)) return
205
+ this._value = newVal
206
+ trigger(this as any, refTarget, newVal, this._value)
207
+ this._subscribers.forEach(fn => fn())
208
+ }
209
+
210
+ /** Subscribe for useSyncExternalStore */
211
+ subscribe(fn: () => void): () => void {
212
+ this._subscribers.add(fn)
213
+ return () => this._subscribers.delete(fn)
214
+ }
215
+
216
+ peek(): T { return this._value }
217
+ }
218
+
219
+ export function ref<T>(value: T): Ref<T> {
220
+ return isRef(value) ? value as Ref<T> : new RefImpl(value)
221
+ }
222
+
223
+ export function shallowRef<T>(value: T): Ref<T> {
224
+ return new RefImpl(value, true)
225
+ }
226
+
227
+ export function triggerRef(r: Ref): void {
228
+ if (r instanceof RefImpl) {
229
+ trigger(r as any, refTarget)
230
+ ;(r as any)._subscribers.forEach((fn: () => void) => fn())
231
+ }
232
+ }
233
+
234
+ export function isRef<T = unknown>(r: unknown): r is Ref<T> {
235
+ return !!r && typeof r === 'object' && (r as any)[IS_REF] === true
236
+ }
237
+
238
+ export function unref<T>(r: T | Ref<T>): T {
239
+ return isRef(r) ? r.value : r
240
+ }
241
+
242
+ export function toRef<T extends object, K extends keyof T>(obj: T, key: K): Ref<T[K]> {
243
+ const r = new RefImpl<T[K]>(undefined as T[K], false)
244
+ Object.defineProperty(r, 'value', {
245
+ get() { track(r as any, refTarget); return obj[key] },
246
+ set(v: T[K]) { obj[key] = v; trigger(r as any, refTarget, v, obj[key]) }
247
+ })
248
+ return r
249
+ }
250
+
251
+ export function toRefs<T extends object>(obj: T): { [K in keyof T]: Ref<T[K]> } {
252
+ const result = {} as { [K in keyof T]: Ref<T[K]> }
253
+ for (const key in obj) result[key] = toRef(obj, key)
254
+ return result
255
+ }
256
+
257
+ // ══════════════════════════════════════════════════════════════════════════════
258
+ // § 6 Computed
259
+ // ══════════════════════════════════════════════════════════════════════════════
260
+
261
+ export interface WritableComputedRef<T> extends Ref<T> {
262
+ readonly effect: ReactiveEffect
263
+ }
264
+
265
+ export interface ComputedRef<T> extends WritableComputedRef<T> {
266
+ readonly value: T
267
+ }
268
+
269
+ class ComputedRefImpl<T> implements Ref<T> {
270
+ readonly [IS_REF] = true as const
271
+ readonly effect: ReactiveEffect
272
+ private _value!: T
273
+ private _dirty = true
274
+ private _subscribers = new Set<() => void>()
275
+
276
+ constructor(getter: () => T, private setter?: (v: T) => void) {
277
+ this.effect = new ReactiveEffect(getter, () => {
278
+ if (!this._dirty) {
279
+ this._dirty = true
280
+ trigger(this as any, refTarget)
281
+ this._subscribers.forEach(fn => fn())
282
+ }
283
+ }, activeScope)
284
+ this.effect.computed = true
285
+ }
286
+
287
+ get value(): T {
288
+ track(this as any, refTarget)
289
+ if (this._dirty) {
290
+ this._dirty = false
291
+ this._value = this.effect.run()
292
+ }
293
+ return this._value
294
+ }
295
+
296
+ set value(v: T) {
297
+ this.setter?.(v)
298
+ }
299
+
300
+ subscribe(fn: () => void): () => void {
301
+ this._subscribers.add(fn)
302
+ return () => this._subscribers.delete(fn)
303
+ }
304
+
305
+ peek(): T { return this._value }
306
+ }
307
+
308
+ export function computed<T>(getter: () => T): ComputedRef<T>
309
+ export function computed<T>(opts: { get: () => T; set: (v: T) => void }): WritableComputedRef<T>
310
+ export function computed<T>(arg: (() => T) | { get: () => T; set: (v: T) => void }): ComputedRef<T> {
311
+ if (typeof arg === 'function') {
312
+ return new ComputedRefImpl(arg) as ComputedRef<T>
313
+ }
314
+ return new ComputedRefImpl(arg.get, arg.set) as ComputedRef<T>
315
+ }
316
+
317
+ // ══════════════════════════════════════════════════════════════════════════════
318
+ // § 7 Reactive proxy
319
+ // ══════════════════════════════════════════════════════════════════════════════
320
+
321
+ const reactiveMap = new WeakMap<object, object>()
322
+ const readonlyMap = new WeakMap<object, object>()
323
+ const shallowReactiveMap = new WeakMap<object, object>()
324
+
325
+ function toReactive<T>(value: T): T {
326
+ return value !== null && typeof value === 'object' ? reactive(value as object) as T : value
327
+ }
328
+
329
+ const arrayInstrumentations: Record<string, Function> = {}
330
+ ;['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
331
+ arrayInstrumentations[method] = function (this: unknown[], ...args: unknown[]) {
332
+ const arr = toRaw(this) as unknown[]
333
+ for (let i = 0; i < this.length; i++) track(arr, i)
334
+ let res = (arr as any)[method](...args)
335
+ if (res === -1 || res === false) res = (arr as any)[method](...args.map(toRaw))
336
+ return res
337
+ }
338
+ })
339
+ ;['push', 'pop', 'shift', 'unshift', 'splice'].forEach(method => {
340
+ arrayInstrumentations[method] = function (this: unknown[], ...args: unknown[]) {
341
+ pauseTracking()
342
+ const res = (toRaw(this) as any)[method].apply(this, args)
343
+ resetTracking()
344
+ return res
345
+ }
346
+ })
347
+
348
+ function createHandler(shallow = false, readonly = false): ProxyHandler<object> {
349
+ return {
350
+ get(target, key, receiver) {
351
+ if (key === RAW) return target
352
+ if (key === IS_REACTIVE) return !readonly
353
+ if (key === IS_READONLY) return readonly
354
+ if (key === MARK_RAW) return (target as any)[MARK_RAW]
355
+
356
+ const isArray = Array.isArray(target)
357
+ if (!readonly && isArray && hasOwn(arrayInstrumentations, key as string)) {
358
+ return Reflect.get(arrayInstrumentations, key, receiver)
359
+ }
360
+
361
+ const res = Reflect.get(target, key, receiver)
362
+ if (typeof key === 'symbol' || key === '__proto__') return res
363
+
364
+ if (!readonly) track(target, key)
365
+
366
+ if (shallow) return res
367
+ if (isRef(res)) return isArray ? res : res.value
368
+ return res !== null && typeof res === 'object' && !res[MARK_RAW]
369
+ ? readonly ? readonlyProxy(res) : reactive(res)
370
+ : res
371
+ },
372
+ set(target, key, value, receiver) {
373
+ if (readonly) {
374
+ console.warn(`[fnetro] Cannot set "${String(key)}" on readonly object`)
375
+ return true
376
+ }
377
+ const oldVal = (target as any)[key]
378
+ const result = Reflect.set(target, key, value, receiver)
379
+ if (hasChanged(value, oldVal)) trigger(target, key, value, oldVal)
380
+ return result
381
+ },
382
+ deleteProperty(target, key) {
383
+ if (readonly) return true
384
+ const hadKey = hasOwn(target, key as string)
385
+ const result = Reflect.deleteProperty(target, key)
386
+ if (hadKey && result) trigger(target, key)
387
+ return result
388
+ },
389
+ has(target, key) {
390
+ const res = Reflect.has(target, key)
391
+ track(target, key)
392
+ return res
393
+ },
394
+ ownKeys(target) {
395
+ track(target, Array.isArray(target) ? 'length' : '__iterate__')
396
+ return Reflect.ownKeys(target)
397
+ }
398
+ }
399
+ }
400
+
401
+ export function reactive<T extends object>(target: T): T {
402
+ if (isReadonly(target)) return target
403
+ if ((target as any)[MARK_RAW]) return target
404
+ if (reactiveMap.has(target)) return reactiveMap.get(target) as T
405
+ const proxy = new Proxy(target, createHandler()) as T
406
+ reactiveMap.set(target, proxy)
407
+ return proxy
408
+ }
409
+
410
+ export function shallowReactive<T extends object>(target: T): T {
411
+ if (shallowReactiveMap.has(target)) return shallowReactiveMap.get(target) as T
412
+ const proxy = new Proxy(target, createHandler(true)) as T
413
+ shallowReactiveMap.set(target, proxy)
414
+ return proxy
415
+ }
416
+
417
+ function readonlyProxy<T extends object>(target: T): T {
418
+ if (readonlyMap.has(target)) return readonlyMap.get(target) as T
419
+ const proxy = new Proxy(target, createHandler(false, true)) as T
420
+ readonlyMap.set(target, proxy)
421
+ return proxy
422
+ }
423
+
424
+ export function readonly<T extends object>(target: T): Readonly<T> {
425
+ return readonlyProxy(target)
426
+ }
427
+
428
+ export function markRaw<T extends object>(value: T): T {
429
+ ;(value as any)[MARK_RAW] = true
430
+ return value
431
+ }
432
+
433
+ export function toRaw<T>(observed: T): T {
434
+ const raw = (observed as any)?.[RAW]
435
+ return raw ? toRaw(raw) : observed
436
+ }
437
+
438
+ export function isReactive(value: unknown): boolean {
439
+ if (isReadonly(value)) return isReactive((value as any)[RAW])
440
+ return !!(value && (value as any)[IS_REACTIVE])
441
+ }
442
+
443
+ export function isReadonly(value: unknown): boolean {
444
+ return !!(value && (value as any)[IS_READONLY])
445
+ }
446
+
447
+ // ══════════════════════════════════════════════════════════════════════════════
448
+ // § 8 watch
449
+ // ══════════════════════════════════════════════════════════════════════════════
450
+
451
+ export type WatchSource<T = unknown> = Ref<T> | ComputedRef<T> | (() => T)
452
+ export type MultiSource = WatchSource[] | readonly WatchSource[]
453
+ type MapSources<T, Immediate = false> = {
454
+ [K in keyof T]: T[K] extends WatchSource<infer V>
455
+ ? Immediate extends true ? V | undefined : V
456
+ : T[K] extends object ? T[K] : never
457
+ }
458
+
459
+ export interface WatchOptions<Immediate = boolean> {
460
+ immediate?: Immediate
461
+ deep?: boolean
462
+ once?: boolean
463
+ }
464
+
465
+ type StopHandle = () => void
466
+ type CleanupFn = (fn: () => void) => void
467
+
468
+ function traverse(value: unknown, seen = new Set()): unknown {
469
+ if (!value || typeof value !== 'object' || seen.has(value)) return value
470
+ seen.add(value)
471
+ if (isRef(value)) { traverse(value.value, seen); return value }
472
+ if (Array.isArray(value)) { value.forEach(v => traverse(v, seen)); return value }
473
+ for (const key in value as object) traverse((value as any)[key], seen)
474
+ return value
475
+ }
476
+
477
+ function normalizeSource<T>(src: WatchSource<T> | MultiSource): () => any {
478
+ if (Array.isArray(src)) return () => src.map(s => isRef(s) ? s.value : s())
479
+ if (isRef(src)) return () => src.value
480
+ return src as () => T
481
+ }
482
+
483
+ export function watch<T>(
484
+ source: WatchSource<T>,
485
+ cb: (val: T, old: T | undefined, cleanup: CleanupFn) => void,
486
+ opts?: WatchOptions
487
+ ): StopHandle
488
+ export function watch<T extends MultiSource>(
489
+ source: T,
490
+ cb: (val: MapSources<T>, old: MapSources<T, true>, cleanup: CleanupFn) => void,
491
+ opts?: WatchOptions
492
+ ): StopHandle
493
+ export function watch(source: any, cb: any, opts: WatchOptions = {}): StopHandle {
494
+ const getter = opts.deep
495
+ ? () => traverse(normalizeSource(source)())
496
+ : normalizeSource(source)
497
+
498
+ let oldVal: any = undefined
499
+ let cleanupFn: (() => void) | undefined
500
+
501
+ const cleanup: CleanupFn = (fn) => { cleanupFn = fn }
502
+
503
+ const job = () => {
504
+ if (!effect.active) return
505
+ cleanupFn?.(); cleanupFn = undefined
506
+ const newVal = effect.run()
507
+ if (opts.deep || hasChanged(newVal, oldVal)) {
508
+ cb(newVal, oldVal, cleanup)
509
+ oldVal = newVal
510
+ }
511
+ if (opts.once) effect.stop()
512
+ }
513
+
514
+ const effect = new ReactiveEffect(getter, job, activeScope)
515
+
516
+ if (opts.immediate) {
517
+ cleanupFn?.(); cleanupFn = undefined
518
+ const val = effect.run()
519
+ cb(val, oldVal, cleanup)
520
+ oldVal = val
521
+ } else {
522
+ oldVal = effect.run()
523
+ }
524
+
525
+ return () => effect.stop()
526
+ }
527
+
528
+ // ══════════════════════════════════════════════════════════════════════════════
529
+ // § 9 Component hooks (JSX-aware)
530
+ // Server: returns plain values. Client: patched by client.ts
531
+ // ══════════════════════════════════════════════════════════════════════════════
532
+
533
+ interface FNetroHooks {
534
+ useValue<T>(r: Ref<T> | (() => T)): T
535
+ useLocalRef<T>(init: T): Ref<T>
536
+ useLocalReactive<T extends object>(init: T): T
537
+ }
538
+
539
+ // SSR fallbacks — no re-renders needed on server
540
+ export const __hooks: FNetroHooks = {
541
+ useValue: (r) => isRef(r) ? r.value : r(),
542
+ useLocalRef: (init) => ref(init),
543
+ useLocalReactive: (init) => reactive(init),
544
+ }
545
+
546
+ /**
547
+ * Subscribe to a Ref or computed getter inside a JSX component.
548
+ * On the server, returns the current value (no reactivity needed).
549
+ * On the client, re-renders the component whenever the value changes.
550
+ *
551
+ * @example
552
+ * const count = ref(0)
553
+ * function Counter() {
554
+ * const n = use(count)
555
+ * return <button onClick={() => count.value++}>{n}</button>
556
+ * }
557
+ */
558
+ export function use<T>(source: Ref<T> | (() => T)): T {
559
+ return __hooks.useValue(source)
560
+ }
561
+
562
+ /**
563
+ * Create a component-local reactive Ref.
564
+ * Unlike module-level `ref()`, this is scoped to the component lifecycle.
565
+ *
566
+ * @example
567
+ * function Input() {
568
+ * const text = useLocalRef('')
569
+ * return <input value={use(text)} onInput={e => text.value = e.target.value} />
570
+ * }
571
+ */
572
+ export function useLocalRef<T>(init: T): Ref<T> {
573
+ return __hooks.useLocalRef(init)
574
+ }
575
+
576
+ /**
577
+ * Create a component-local reactive object.
578
+ * @example
579
+ * function Form() {
580
+ * const form = useLocalReactive({ name: '', email: '' })
581
+ * return <input value={form.name} onInput={e => form.name = e.target.value} />
582
+ * }
583
+ */
584
+ export function useLocalReactive<T extends object>(init: T): T {
585
+ return __hooks.useLocalReactive(init)
586
+ }
587
+
588
+ // ══════════════════════════════════════════════════════════════════════════════
589
+ // § 10 Route / App definitions
590
+ // ══════════════════════════════════════════════════════════════════════════════
591
+
592
+ export type LoaderCtx = Context
593
+ export type FNetroMiddleware = MiddlewareHandler
594
+ export type AnyJSX = any
595
+
596
+ export interface PageDef<TData extends object = {}> {
597
+ readonly __type: 'page'
598
+ path: string
599
+ /** Middleware applied only to this route */
600
+ middleware?: FNetroMiddleware[]
601
+ /** Server-side data loader. Return value becomes Page props. */
602
+ loader?: (c: LoaderCtx) => TData | Promise<TData>
603
+ /** Override the group/app layout for this page. Pass `false` to use no layout. */
604
+ layout?: LayoutDef | false
605
+ /** The JSX page component */
606
+ Page: (props: TData & { url: string; params: Record<string, string> }) => AnyJSX
607
+ }
608
+
609
+ export interface GroupDef {
610
+ readonly __type: 'group'
611
+ /** URL prefix — e.g. '/admin' */
612
+ prefix: string
613
+ /** Layout override for all pages in this group */
614
+ layout?: LayoutDef | false
615
+ /** Middleware applied to every route in the group */
616
+ middleware?: FNetroMiddleware[]
617
+ /** Pages and nested groups */
618
+ routes: (PageDef<any> | GroupDef | ApiRouteDef)[]
619
+ }
620
+
621
+ export interface LayoutDef {
622
+ readonly __type: 'layout'
623
+ Component: (props: { children: AnyJSX; url: string; params: Record<string, string> }) => AnyJSX
624
+ }
625
+
626
+ export interface ApiRouteDef {
627
+ readonly __type: 'api'
628
+ /** Mount path — e.g. '/api' or '/api/admin' */
629
+ path: string
630
+ /** Register raw Hono routes on the provided sub-app */
631
+ register: (app: Hono, middleware: FNetroMiddleware[]) => void
632
+ }
633
+
634
+ export interface MiddlewareDef {
635
+ readonly __type: 'middleware'
636
+ handler: FNetroMiddleware
637
+ }
638
+
639
+ export interface AppConfig {
640
+ /** Default layout for all pages */
641
+ layout?: LayoutDef
642
+ /** Global middleware applied before every route */
643
+ middleware?: FNetroMiddleware[]
644
+ /** Top-level routes, groups, and API routes */
645
+ routes: (PageDef<any> | GroupDef | ApiRouteDef)[]
646
+ /** 404 page */
647
+ notFound?: () => AnyJSX
648
+ }
649
+
650
+ // ── Builder functions ─────────────────────────────────────────────────────────
651
+
652
+ export function definePage<TData extends object = {}>(
653
+ def: Omit<PageDef<TData>, '__type'>
654
+ ): PageDef<TData> {
655
+ return { __type: 'page', ...def }
656
+ }
657
+
658
+ export function defineGroup(
659
+ def: Omit<GroupDef, '__type'>
660
+ ): GroupDef {
661
+ return { __type: 'group', ...def }
662
+ }
663
+
664
+ export function defineLayout(
665
+ Component: LayoutDef['Component']
666
+ ): LayoutDef {
667
+ return { __type: 'layout', Component }
668
+ }
669
+
670
+ export function defineMiddleware(handler: FNetroMiddleware): MiddlewareDef {
671
+ return { __type: 'middleware', handler }
672
+ }
673
+
674
+ export function defineApiRoute(
675
+ path: string,
676
+ register: ApiRouteDef['register']
677
+ ): ApiRouteDef {
678
+ return { __type: 'api', path, register }
679
+ }
680
+
681
+ // ── Internal route resolution ─────────────────────────────────────────────────
682
+
683
+ export interface ResolvedRoute {
684
+ fullPath: string
685
+ page: PageDef<any>
686
+ layout: LayoutDef | false | undefined
687
+ middleware: FNetroMiddleware[]
688
+ }
689
+
690
+ export function resolveRoutes(
691
+ routes: (PageDef<any> | GroupDef | ApiRouteDef)[],
692
+ options: {
693
+ prefix?: string
694
+ middleware?: FNetroMiddleware[]
695
+ layout?: LayoutDef | false
696
+ } = {}
697
+ ): { pages: ResolvedRoute[]; apis: ApiRouteDef[] } {
698
+ const pages: ResolvedRoute[] = []
699
+ const apis: ApiRouteDef[] = []
700
+
701
+ for (const route of routes) {
702
+ if (route.__type === 'api') {
703
+ apis.push({ ...route, path: (options.prefix ?? '') + route.path })
704
+ } else if (route.__type === 'group') {
705
+ const prefix = (options.prefix ?? '') + route.prefix
706
+ const mw = [...(options.middleware ?? []), ...(route.middleware ?? [])]
707
+ const layout = route.layout !== undefined ? route.layout : options.layout
708
+ const sub = resolveRoutes(route.routes, { prefix, middleware: mw, layout })
709
+ pages.push(...sub.pages)
710
+ apis.push(...sub.apis)
711
+ } else {
712
+ const fullPath = (options.prefix ?? '') + route.path
713
+ const layout = route.layout !== undefined ? route.layout : options.layout
714
+ const middleware = [...(options.middleware ?? []), ...(route.middleware ?? [])]
715
+ pages.push({ fullPath, page: route, layout, middleware })
716
+ }
717
+ }
718
+
719
+ return { pages, apis }
720
+ }
721
+
722
+ // ── Shared constants ──────────────────────────────────────────────────────────
723
+ export const SPA_HEADER = 'x-fnetro-spa'
724
+ export const STATE_KEY = '__FNETRO_STATE__'
725
+ export const PARAMS_KEY = '__FNETRO_PARAMS__'
726
+
727
+ // ── Utilities ─────────────────────────────────────────────────────────────────
728
+ function hasChanged(a: unknown, b: unknown): boolean {
729
+ return !Object.is(a, b)
730
+ }
731
+
732
+ function hasOwn(obj: object, key: string): boolean {
733
+ return Object.prototype.hasOwnProperty.call(obj, key)
734
+ }