preact-missing-hooks
Version:
A lightweight, extendable collection of missing React-like hooks for Preact — plus fresh, powerful new ones designed specifically for modern Preact apps.
1 lines • 156 kB
Source Map (JSON)
{"version":3,"file":"index.modern.mjs","sources":["../src/useTransition.ts","../src/useMutationObserver.ts","../src/useEventBus.ts","../src/useWrappedChildren.ts","../src/usePreferredTheme.ts","../src/useNetworkState.ts","../src/useClipboard.ts","../src/useRageClick.ts","../src/useThreadedWorker.ts","../src/indexedDB/openDB.ts","../src/indexedDB/requestToPromise.ts","../src/indexedDB/tableController.ts","../src/useIndexedDB.ts","../src/indexedDB/dbController.ts","../src/useWebRTCIP.ts","../src/useWasmCompute.ts","../src/useWorkerNotifications.ts","../src/useLLMMetadata.ts","../src/useRefPrint.ts","../src/useRBAC.ts","../src/usePrefetch.ts","../src/usePoll.ts","../src/useDeviceData.ts"],"sourcesContent":["import { useState, useCallback } from \"preact/hooks\";\n\n/**\n * Mimics React's useTransition hook in Preact.\n * @returns [startTransition, isPending]\n */\nexport function useTransition(): [\n startTransition: (callback: () => void) => void,\n isPending: boolean,\n] {\n const [isPending, setIsPending] = useState(false);\n\n const startTransition = useCallback((callback: () => void) => {\n setIsPending(true);\n Promise.resolve().then(() => {\n callback();\n setIsPending(false);\n });\n }, []);\n\n return [startTransition, isPending];\n}\n","import { RefObject } from \"preact\";\nimport { useEffect } from \"preact/hooks\";\n\nexport type UseMutationObserverOptions = MutationObserverInit;\n\n/**\n * A Preact hook to observe DOM mutations using MutationObserver.\n * @param target - The element to observe.\n * @param callback - Function to call on mutation.\n * @param options - MutationObserver options.\n */\nexport function useMutationObserver(\n targetRef: RefObject<HTMLElement | null>,\n callback: MutationCallback,\n options: MutationObserverInit\n) {\n useEffect(() => {\n const node = targetRef.current;\n if (!node) return;\n\n const observer = new MutationObserver(callback);\n observer.observe(node, options);\n\n return () => observer.disconnect();\n }, [targetRef, callback, options]);\n}\n","import { useCallback } from \"preact/hooks\";\n\ntype EventMap = Record<string, (...args: unknown[]) => void>;\n\nconst listeners = new Map<string, Set<(...args: unknown[]) => void>>();\n\n/**\n * A Preact hook to publish and subscribe to custom events across components.\n * @returns An object with `emit` and `on` methods.\n */\nexport function useEventBus<T extends EventMap>() {\n const emit = useCallback(\n <K extends keyof T>(event: K, ...args: Parameters<T[K]>) => {\n const handlers = listeners.get(event as string);\n if (handlers) {\n handlers.forEach((handler) => handler(...args));\n }\n },\n []\n );\n\n const on = useCallback(<K extends keyof T>(event: K, handler: T[K]) => {\n let handlers = listeners.get(event as string);\n if (!handlers) {\n handlers = new Set();\n listeners.set(event as string, handlers);\n }\n handlers.add(handler);\n\n return () => {\n handlers!.delete(handler);\n if (handlers!.size === 0) {\n listeners.delete(event as string);\n }\n };\n }, []);\n\n return { emit, on };\n}\n","import { ComponentChildren, cloneElement, isValidElement, VNode } from \"preact\";\nimport { useMemo } from \"preact/hooks\";\n\nexport type InjectableProps = Record<string, unknown>;\n\ninterface PropsWithStyle {\n style?: Record<string, string | number>;\n}\n\n/**\n * A Preact hook to wrap children components and inject additional props into them.\n * @param children - The children to wrap and enhance with props.\n * @param injectProps - The props to inject into each child component.\n * @param mergeStrategy - How to handle prop conflicts ('override' | 'preserve'). Defaults to 'preserve'.\n * @returns Enhanced children with injected props.\n */\nexport function useWrappedChildren(\n children: ComponentChildren,\n injectProps: InjectableProps,\n mergeStrategy: \"override\" | \"preserve\" = \"preserve\"\n): ComponentChildren {\n return useMemo(() => {\n if (!children) return children;\n\n const enhanceChild = (child: ComponentChildren): ComponentChildren => {\n if (!isValidElement(child)) return child;\n\n const existingProps = (child as VNode).props || {};\n\n let mergedProps: InjectableProps;\n\n if (mergeStrategy === \"override\") {\n // Injected props override existing ones\n mergedProps = { ...existingProps, ...injectProps };\n } else {\n // Existing props are preserved, injected props are added only if not present\n mergedProps = { ...injectProps, ...existingProps };\n }\n\n // Special handling for style prop to merge style objects properly\n const existingStyle = (existingProps as PropsWithStyle)?.style;\n const injectStyle = (injectProps as PropsWithStyle)?.style;\n\n if (\n existingStyle &&\n injectStyle &&\n typeof existingStyle === \"object\" &&\n typeof injectStyle === \"object\"\n ) {\n if (mergeStrategy === \"override\") {\n (mergedProps as PropsWithStyle).style = {\n ...existingStyle,\n ...injectStyle,\n };\n } else {\n (mergedProps as PropsWithStyle).style = {\n ...injectStyle,\n ...existingStyle,\n };\n }\n }\n\n return cloneElement(child, mergedProps);\n };\n\n if (Array.isArray(children)) {\n return children.map(enhanceChild);\n }\n\n return enhanceChild(children);\n }, [children, injectProps, mergeStrategy]);\n}\n","import { useEffect, useState } from \"preact/hooks\";\n\nexport type PreferredTheme = \"light\" | \"dark\" | \"no-preference\";\n\n/**\n * A Preact hook that returns the user's preferred color scheme based on the\n * `prefers-color-scheme` media query. Updates reactively when the user changes\n * their system or browser theme preference.\n *\n * @returns The preferred theme: 'light', 'dark', or 'no-preference'\n *\n * @example\n * ```tsx\n * function ThemeAwareComponent() {\n * const theme = usePreferredTheme();\n * return (\n * <div data-theme={theme}>\n * Current preference: {theme}\n * </div>\n * );\n * }\n * ```\n */\nexport function usePreferredTheme(): PreferredTheme {\n const [theme, setTheme] = useState<PreferredTheme>(() => {\n if (typeof window === \"undefined\") return \"no-preference\";\n\n const darkQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n const lightQuery = window.matchMedia(\"(prefers-color-scheme: light)\");\n\n if (darkQuery.matches) return \"dark\";\n if (lightQuery.matches) return \"light\";\n return \"no-preference\";\n });\n\n useEffect(() => {\n if (typeof window === \"undefined\") return;\n\n const mediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n\n const handleChange = (e: MediaQueryListEvent) => {\n setTheme(e.matches ? \"dark\" : \"light\");\n };\n\n // Re-check in case of no-preference (some browsers don't support light query)\n const updateTheme = () => {\n const darkQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n const lightQuery = window.matchMedia(\"(prefers-color-scheme: light)\");\n\n if (darkQuery.matches) setTheme(\"dark\");\n else if (lightQuery.matches) setTheme(\"light\");\n else setTheme(\"no-preference\");\n };\n\n mediaQuery.addEventListener(\"change\", handleChange);\n\n // Fallback: some environments may not fire change, so we also listen for light\n const lightQuery = window.matchMedia(\"(prefers-color-scheme: light)\");\n lightQuery.addEventListener(\"change\", updateTheme);\n\n return () => {\n mediaQuery.removeEventListener(\"change\", handleChange);\n lightQuery.removeEventListener(\"change\", updateTheme);\n };\n }, []);\n\n return theme;\n}\n","import { useEffect, useState } from \"preact/hooks\";\n\n/** Network Information API (not in all browsers) */\ninterface NetworkInformation extends EventTarget {\n effectiveType?: string;\n downlink?: number;\n rtt?: number;\n saveData?: boolean;\n type?: string;\n}\n\n/** Effective connection type from Network Information API */\nexport type EffectiveConnectionType = \"slow-2g\" | \"2g\" | \"3g\" | \"4g\";\n\n/** Network connection type (e.g., wifi, cellular) */\nexport type ConnectionType =\n | \"bluetooth\"\n | \"cellular\"\n | \"ethernet\"\n | \"mixed\"\n | \"none\"\n | \"other\"\n | \"unknown\"\n | \"wifi\";\n\nexport interface NetworkState {\n /** Whether the browser is online */\n online: boolean;\n /** Effective connection type (when supported) */\n effectiveType?: EffectiveConnectionType;\n /** Estimated downlink speed in Mbps (when supported) */\n downlink?: number;\n /** Estimated round-trip time in ms (when supported) */\n rtt?: number;\n /** Whether the user has requested reduced data usage (when supported) */\n saveData?: boolean;\n /** Connection type (when supported) */\n connectionType?: ConnectionType;\n}\n\nfunction getNetworkState(): NetworkState {\n if (typeof navigator === \"undefined\") {\n return { online: true };\n }\n\n const state: NetworkState = {\n online: navigator.onLine,\n };\n\n const connection = (\n navigator as Navigator & { connection?: NetworkInformation }\n ).connection;\n\n if (connection) {\n if (connection.effectiveType !== undefined) {\n state.effectiveType = connection.effectiveType as EffectiveConnectionType;\n }\n if (connection.downlink !== undefined) {\n state.downlink = connection.downlink;\n }\n if (connection.rtt !== undefined) {\n state.rtt = connection.rtt;\n }\n if (connection.saveData !== undefined) {\n state.saveData = connection.saveData;\n }\n if (connection.type !== undefined) {\n state.connectionType = connection.type as ConnectionType;\n }\n }\n\n return state;\n}\n\n/**\n * A Preact hook that returns the current network state, including online/offline\n * status and (when supported) connection type, downlink, RTT, and save-data preference.\n * Updates reactively when the network state changes.\n *\n * @returns The current network state object\n *\n * @example\n * ```tsx\n * function NetworkStatus() {\n * const { online, effectiveType, saveData } = useNetworkState();\n * return (\n * <div>\n * Status: {online ? 'Online' : 'Offline'}\n * {effectiveType && ` (${effectiveType})`}\n * {saveData && ' - Reduced data mode'}\n * </div>\n * );\n * }\n * ```\n */\nexport function useNetworkState(): NetworkState {\n const [state, setState] = useState<NetworkState>(getNetworkState);\n\n useEffect(() => {\n if (typeof window === \"undefined\") return;\n\n const updateState = () => setState(getNetworkState());\n\n window.addEventListener(\"online\", updateState);\n window.addEventListener(\"offline\", updateState);\n\n const connection = (\n navigator as Navigator & { connection?: NetworkInformation }\n ).connection;\n if (connection?.addEventListener) {\n connection.addEventListener(\"change\", updateState);\n }\n\n return () => {\n window.removeEventListener(\"online\", updateState);\n window.removeEventListener(\"offline\", updateState);\n if (connection?.removeEventListener) {\n connection.removeEventListener(\"change\", updateState);\n }\n };\n }, []);\n\n return state;\n}\n","import { useCallback, useState } from \"preact/hooks\";\n\nexport interface UseClipboardOptions {\n /** Duration in ms to keep `copied` true before resetting. Default: 2000 */\n resetDelay?: number;\n}\n\nexport interface UseClipboardReturn {\n /** Copy text to the clipboard. Returns true on success. */\n copy: (text: string) => Promise<boolean>;\n /** Read text from the clipboard. Returns empty string if denied or unavailable. */\n paste: () => Promise<string>;\n /** Whether the last copy operation succeeded (resets after resetDelay) */\n copied: boolean;\n /** Error from the last failed operation, or null */\n error: Error | null;\n /** Manually reset copied and error state */\n reset: () => void;\n}\n\n/**\n * A Preact hook for reading and writing the clipboard. Uses the async\n * Clipboard API when available (requires secure context and user gesture).\n *\n * @param options - Optional configuration (e.g., resetDelay for copied state)\n * @returns Object with copy, paste, copied, error, and reset\n *\n * @example\n * ```tsx\n * function CopyButton() {\n * const { copy, copied, error } = useClipboard();\n * return (\n * <button onClick={() => copy('Hello!')}>\n * {copied ? 'Copied!' : 'Copy'}\n * </button>\n * );\n * }\n * ```\n */\nexport function useClipboard(\n options: UseClipboardOptions = {}\n): UseClipboardReturn {\n const { resetDelay = 2000 } = options;\n\n const [copied, setCopied] = useState(false);\n const [error, setError] = useState<Error | null>(null);\n\n const reset = useCallback(() => {\n setCopied(false);\n setError(null);\n }, []);\n\n const copy = useCallback(\n async (text: string): Promise<boolean> => {\n setError(null);\n\n if (typeof navigator === \"undefined\" || !navigator.clipboard) {\n const err = new Error(\"Clipboard API is not available\");\n setError(err);\n return false;\n }\n\n try {\n await navigator.clipboard.writeText(text);\n setCopied(true);\n if (resetDelay > 0) {\n setTimeout(() => setCopied(false), resetDelay);\n }\n return true;\n } catch (e) {\n const err = e instanceof Error ? e : new Error(String(e));\n setError(err);\n return false;\n }\n },\n [resetDelay]\n );\n\n const paste = useCallback(async (): Promise<string> => {\n setError(null);\n\n if (typeof navigator === \"undefined\" || !navigator.clipboard) {\n const err = new Error(\"Clipboard API is not available\");\n setError(err);\n return \"\";\n }\n\n try {\n const text = await navigator.clipboard.readText();\n return text;\n } catch (e) {\n const err = e instanceof Error ? e : new Error(String(e));\n setError(err);\n return \"\";\n }\n }, []);\n\n return { copy, paste, copied, error, reset };\n}\n","import type { RefObject } from \"preact\";\nimport { useEffect, useRef } from \"preact/hooks\";\n\nexport interface RageClickPayload {\n /** Number of clicks that triggered the rage click */\n count: number;\n /** Last click event (e.g. for Sentry context) */\n event: MouseEvent;\n}\n\nexport interface UseRageClickOptions {\n /** Called when a rage click is detected. Use this to report to Sentry or your error tracker. */\n onRageClick: (payload: RageClickPayload) => void;\n /** Minimum number of clicks in the time window to count as rage click. Default: 5 (Sentry-style). */\n threshold?: number;\n /** Time window in ms. Default: 1000. */\n timeWindow?: number;\n /** Max distance in px between clicks to count as same spot. Default: 30. Set to Infinity to ignore distance. */\n distanceThreshold?: number;\n}\n\ninterface ClickRecord {\n time: number;\n x: number;\n y: number;\n}\n\nfunction distance(a: ClickRecord, b: ClickRecord): number {\n return Math.hypot(b.x - a.x, b.y - a.y);\n}\n\n/**\n * Detects \"rage clicks\" (repeated rapid clicks in the same area), e.g. when the UI\n * is unresponsive. Use the callback to report to Sentry or similar tools to surface\n * rage click issues and lower rage-click-related support.\n *\n * @param targetRef - Ref of the element to monitor (e.g. a button or card).\n * @param options - onRageClick callback and optional threshold, timeWindow, distanceThreshold.\n *\n * @example\n * ```tsx\n * const ref = useRef<HTMLButtonElement>(null)\n * useRageClick(ref, {\n * onRageClick: ({ count, event }) => {\n * Sentry.captureMessage('Rage click detected', { extra: { count, target: event.target } })\n * },\n * })\n * return <button ref={ref}>Submit</button>\n * ```\n */\nexport function useRageClick(\n targetRef: RefObject<HTMLElement | null>,\n options: UseRageClickOptions\n) {\n const {\n onRageClick,\n threshold = 5,\n timeWindow = 1000,\n distanceThreshold = 30,\n } = options;\n\n const onRageClickRef = useRef(onRageClick);\n onRageClickRef.current = onRageClick;\n\n const clicksRef = useRef<ClickRecord[]>([]);\n\n useEffect(() => {\n const node = targetRef.current;\n if (!node) return;\n\n const handleClick = (e: MouseEvent) => {\n const now = Date.now();\n const record: ClickRecord = { time: now, x: e.clientX, y: e.clientY };\n\n const clicks = clicksRef.current;\n const cutoff = now - timeWindow;\n const recent = clicks.filter((c) => c.time >= cutoff);\n recent.push(record);\n\n if (distanceThreshold !== Infinity) {\n const inRange = recent.filter(\n (c) => distance(c, record) <= distanceThreshold\n );\n if (inRange.length >= threshold) {\n onRageClickRef.current({ count: inRange.length, event: e });\n clicksRef.current = [];\n return;\n }\n } else {\n if (recent.length >= threshold) {\n onRageClickRef.current({ count: recent.length, event: e });\n clicksRef.current = [];\n return;\n }\n }\n\n clicksRef.current = recent;\n };\n\n node.addEventListener(\"click\", handleClick);\n return () => node.removeEventListener(\"click\", handleClick);\n }, [targetRef, threshold, timeWindow, distanceThreshold]);\n}\n","import { useState, useCallback, useRef, useEffect } from \"preact/hooks\";\n\n/** Lower number = higher priority. Default priority when not specified. */\nconst DEFAULT_PRIORITY = 1;\n\nexport type ThreadedWorkerMode = \"sequential\" | \"parallel\";\n\nexport interface UseThreadedWorkerOptions {\n /** Sequential: single worker, priority-ordered. Parallel: worker pool. */\n mode: ThreadedWorkerMode;\n /** Max concurrent workers. Only used when mode is \"parallel\". Default 4. */\n concurrency?: number;\n}\n\nexport interface RunOptions {\n /** 1 = highest priority. Lower number runs first. FIFO within same priority. */\n priority?: number;\n}\n\ninterface QueuedTask<TData, TResult> {\n data: TData;\n priority: number;\n sequence: number;\n resolve: (value: TResult) => void;\n reject: (reason: unknown) => void;\n}\n\nexport interface UseThreadedWorkerReturn<TData, TResult> {\n /** Enqueue work. Returns a Promise that resolves with the worker result. */\n run: (data: TData, options?: RunOptions) => Promise<TResult>;\n /** True while any task is queued or running. */\n loading: boolean;\n /** Result of the most recently completed successful task. */\n result: TResult | undefined;\n /** Error from the most recently failed task. */\n error: unknown;\n /** Number of tasks currently queued + running. */\n queueSize: number;\n /** Clear all pending (not yet started) tasks. Running tasks continue. */\n clearQueue: () => void;\n /** Stop accepting new work and clear pending queue. Running tasks finish. */\n terminate: () => void;\n}\n\n/**\n * Production-grade hook to run async work in a queue with optional priority\n * and either sequential or parallel execution.\n *\n * @param workerFn - Async function to run for each task (e.g. API call, heavy compute).\n * @param options - mode: \"sequential\" | \"parallel\", concurrency (parallel only).\n * @returns run, loading, result, error, queueSize, clearQueue, terminate.\n */\nexport function useThreadedWorker<TData, TResult>(\n workerFn: (data: TData) => Promise<TResult>,\n options: UseThreadedWorkerOptions\n): UseThreadedWorkerReturn<TData, TResult> {\n const { mode, concurrency = 4 } = options;\n const maxConcurrent = mode === \"sequential\" ? 1 : Math.max(1, concurrency);\n\n const [loading, setLoading] = useState(false);\n const [result, setResult] = useState<TResult | undefined>(undefined);\n const [error, setError] = useState<unknown>(undefined);\n const [queueSize, setQueueSize] = useState(0);\n\n const queueRef = useRef<QueuedTask<TData, TResult>[]>([]);\n const sequenceRef = useRef(0);\n const activeCountRef = useRef(0);\n const terminatedRef = useRef(false);\n const workerFnRef = useRef(workerFn);\n workerFnRef.current = workerFn;\n\n const updateQueueSize = useCallback(() => {\n setQueueSize(queueRef.current.length + activeCountRef.current);\n }, []);\n\n const processNext = useCallback(() => {\n if (terminatedRef.current) return;\n if (activeCountRef.current >= maxConcurrent) return;\n if (queueRef.current.length === 0) {\n if (activeCountRef.current === 0) setLoading(false);\n updateQueueSize();\n return;\n }\n\n // Sort by priority (asc), then by sequence (FIFO within same priority).\n queueRef.current.sort((a, b) => {\n if (a.priority !== b.priority) return a.priority - b.priority;\n return a.sequence - b.sequence;\n });\n const task = queueRef.current.shift()!;\n activeCountRef.current += 1;\n setLoading(true);\n updateQueueSize();\n\n const fn = workerFnRef.current;\n fn(task.data)\n .then((value) => {\n setResult(value);\n setError(undefined);\n task.resolve(value);\n })\n .catch((err) => {\n setError(err);\n task.reject(err);\n })\n .finally(() => {\n activeCountRef.current -= 1;\n updateQueueSize();\n processNext();\n });\n\n // Fill remaining slots (parallel mode).\n if (queueRef.current.length > 0 && activeCountRef.current < maxConcurrent) {\n processNext();\n }\n }, [maxConcurrent, updateQueueSize]);\n\n const run = useCallback(\n (data: TData, runOptions?: RunOptions): Promise<TResult> => {\n if (terminatedRef.current) {\n return Promise.reject(new Error(\"Worker is terminated\"));\n }\n const priority = runOptions?.priority ?? DEFAULT_PRIORITY;\n const sequence = ++sequenceRef.current;\n const promise = new Promise<TResult>((resolve, reject) => {\n queueRef.current.push({ data, priority, sequence, resolve, reject });\n });\n updateQueueSize();\n setLoading(true);\n queueMicrotask(processNext);\n return promise;\n },\n [processNext, updateQueueSize]\n );\n\n const clearQueue = useCallback(() => {\n const pending = queueRef.current;\n queueRef.current = [];\n pending.forEach((t) => t.reject(new Error(\"Task cleared from queue\")));\n updateQueueSize();\n if (activeCountRef.current === 0) setLoading(false);\n }, [updateQueueSize]);\n\n const terminate = useCallback(() => {\n terminatedRef.current = true;\n clearQueue();\n }, [clearQueue]);\n\n // Reset terminated on unmount so the same hook instance can't be \"revived\" without options change.\n useEffect(() => {\n return () => {\n terminatedRef.current = true;\n };\n }, []);\n\n return {\n run,\n loading,\n result,\n error,\n queueSize,\n clearQueue,\n terminate,\n };\n}\n","/**\n * Opens IndexedDB and runs onupgradeneeded to create stores and indexes.\n * Singleton per (name, version).\n * @module indexedDB/openDB\n */\n\nimport type { IndexedDBConfig, TableSchema } from \"./types\";\n\nconst connectionCache = new Map<string, Promise<IDBDatabase>>();\n\n/**\n * Opens the database and creates/upgrades object stores and indexes from config.\n * Uses a singleton cache per (name, version); repeated calls with the same config reuse the same connection.\n */\nexport function openDB(config: IndexedDBConfig): Promise<IDBDatabase> {\n const key = `${config.name}_v${config.version}`;\n let promise = connectionCache.get(key);\n if (promise) return promise;\n promise = _openDB(config);\n connectionCache.set(key, promise);\n return promise;\n}\n\nfunction _openDB(config: IndexedDBConfig): Promise<IDBDatabase> {\n return new Promise<IDBDatabase>((resolve, reject) => {\n const request = indexedDB.open(config.name, config.version);\n request.onerror = () =>\n reject(request.error ?? new DOMException(\"Failed to open database\"));\n request.onsuccess = () => resolve(request.result);\n request.onupgradeneeded = (event: IDBVersionChangeEvent) => {\n const db = (event.target as IDBOpenDBRequest).result;\n const tables = config.tables;\n for (const tableName of Object.keys(tables)) {\n const schema = tables[tableName] as TableSchema;\n if (!db.objectStoreNames.contains(tableName)) {\n const store = db.createObjectStore(tableName, {\n keyPath: schema.keyPath,\n autoIncrement: schema.autoIncrement ?? false,\n });\n if (schema.indexes) {\n for (const indexName of schema.indexes) {\n store.createIndex(indexName, indexName, { unique: false });\n }\n }\n }\n }\n };\n });\n}\n","/**\n * Wraps an IDBRequest in a Promise.\n * @module indexedDB/requestToPromise\n */\n\n/**\n * Converts an IDBRequest to a Promise. Rejects with the request's error on failure.\n * @param request - Native IndexedDB request.\n * @returns Promise that resolves with the request result or rejects with DOMException.\n */\nexport function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n request.onsuccess = () => resolve(request.result);\n request.onerror = () =>\n reject(request.error ?? new DOMException(\"Unknown IndexedDB error\"));\n });\n}\n","/**\n * Table controller: insert, update, delete, exists, query, upsert, bulkInsert, clear, count.\n * Works in standalone mode (opens its own transaction per op) or bound to a transaction.\n * @module indexedDB/tableController\n */\n\nimport type { OperationCallbacks } from \"./types\";\nimport { requestToPromise } from \"./requestToPromise\";\n\n/** Runs optional callbacks and returns the result. */\nfunction withCallbacks<T>(\n promise: Promise<T>,\n options?: OperationCallbacks<T>\n): Promise<T> {\n if (!options) return promise;\n return promise\n .then((result) => {\n options.onSuccess?.(result);\n return result;\n })\n .catch((err: DOMException) => {\n options.onError?.(err);\n throw err;\n });\n}\n\n/**\n * Standalone table controller: opens a new transaction for each operation.\n */\nfunction createStandaloneController(\n db: IDBDatabase,\n tableName: string\n): ITableController {\n function getStore(mode: IDBTransactionMode): IDBObjectStore {\n const tx = db.transaction([tableName], mode);\n return tx.objectStore(tableName);\n }\n\n return {\n insert<T>(\n data: T,\n options?: OperationCallbacks<IDBValidKey>\n ): Promise<IDBValidKey> {\n const store = getStore(\"readwrite\");\n return withCallbacks(requestToPromise(store.add(data)), options);\n },\n\n update<T>(\n key: IDBValidKey,\n updates: Partial<T>,\n options?: OperationCallbacks<void>\n ): Promise<void> {\n const store = getStore(\"readwrite\");\n const getReq = store.get(key);\n return withCallbacks(\n requestToPromise(getReq)\n .then((existing) => {\n if (existing === undefined) {\n throw new DOMException(\"Key not found\", \"NotFoundError\");\n }\n const merged = { ...existing, ...updates } as T;\n return requestToPromise(store.put(merged));\n })\n .then(() => undefined),\n options\n );\n },\n\n delete(\n key: IDBValidKey,\n options?: OperationCallbacks<void>\n ): Promise<void> {\n const store = getStore(\"readwrite\");\n return withCallbacks(\n requestToPromise(store.delete(key)).then(() => undefined),\n options\n );\n },\n\n exists(key: IDBValidKey): Promise<boolean> {\n const store = getStore(\"readonly\");\n return requestToPromise(store.getKey(key)).then((k) => k !== undefined);\n },\n\n query<T>(\n filterFn: (item: T) => boolean,\n options?: OperationCallbacks<T[]>\n ): Promise<T[]> {\n const store = getStore(\"readonly\");\n const request = store.openCursor();\n const results: T[] = [];\n return withCallbacks(\n new Promise<T[]>((resolve, reject) => {\n request.onsuccess = () => {\n const cursor = request.result;\n if (cursor) {\n if (filterFn(cursor.value as T)) results.push(cursor.value as T);\n cursor.continue();\n } else {\n resolve(results);\n }\n };\n request.onerror = () =>\n reject(request.error ?? new DOMException(\"Unknown error\"));\n }),\n options\n );\n },\n\n upsert<T>(\n data: T,\n options?: OperationCallbacks<IDBValidKey>\n ): Promise<IDBValidKey> {\n const store = getStore(\"readwrite\");\n return withCallbacks(requestToPromise(store.put(data)), options);\n },\n\n bulkInsert<T>(\n items: T[],\n options?: OperationCallbacks<IDBValidKey[]>\n ): Promise<IDBValidKey[]> {\n const store = getStore(\"readwrite\");\n const keys: IDBValidKey[] = [];\n if (items.length === 0) {\n return withCallbacks(Promise.resolve(keys), options);\n }\n let completed = 0;\n const promise = new Promise<IDBValidKey[]>((resolve, reject) => {\n const onDone = () => {\n completed++;\n if (completed === items.length) resolve(keys);\n };\n items.forEach((item, i) => {\n const req = store.add(item);\n req.onsuccess = () => {\n keys[i] = req.result;\n onDone();\n };\n req.onerror = () =>\n reject(req.error ?? new DOMException(\"Unknown error\"));\n });\n });\n return withCallbacks(promise, options);\n },\n\n clear(options?: OperationCallbacks<void>): Promise<void> {\n const store = getStore(\"readwrite\");\n return withCallbacks(\n requestToPromise(store.clear()).then(() => undefined),\n options\n );\n },\n\n count(options?: OperationCallbacks<number>): Promise<number> {\n const store = getStore(\"readonly\");\n return withCallbacks(requestToPromise(store.count()), options ?? {});\n },\n };\n}\n\n/**\n * Transaction-scoped table controller: uses the given transaction (no new transaction).\n */\nfunction createTransactionController(\n tx: IDBTransaction,\n tableName: string\n): ITableController {\n function getStore(): IDBObjectStore {\n return tx.objectStore(tableName);\n }\n\n return {\n insert<T>(\n data: T,\n options?: OperationCallbacks<IDBValidKey>\n ): Promise<IDBValidKey> {\n const store = getStore();\n return withCallbacks(requestToPromise(store.add(data)), options);\n },\n\n update<T>(\n key: IDBValidKey,\n updates: Partial<T>,\n options?: OperationCallbacks<void>\n ): Promise<void> {\n const store = getStore();\n return withCallbacks(\n requestToPromise(store.get(key))\n .then((existing) => {\n if (existing === undefined) {\n throw new DOMException(\"Key not found\", \"NotFoundError\");\n }\n const merged = { ...existing, ...updates } as T;\n return requestToPromise(store.put(merged));\n })\n .then(() => undefined),\n options\n );\n },\n\n delete(\n key: IDBValidKey,\n options?: OperationCallbacks<void>\n ): Promise<void> {\n const store = getStore();\n return withCallbacks(\n requestToPromise(store.delete(key)).then(() => undefined),\n options\n );\n },\n\n exists(key: IDBValidKey): Promise<boolean> {\n const store = getStore();\n return requestToPromise(store.getKey(key)).then((k) => k !== undefined);\n },\n\n query<T>(\n filterFn: (item: T) => boolean,\n options?: OperationCallbacks<T[]>\n ): Promise<T[]> {\n const store = getStore();\n const request = store.openCursor();\n const results: T[] = [];\n return withCallbacks(\n new Promise<T[]>((resolve, reject) => {\n request.onsuccess = () => {\n const cursor = request.result;\n if (cursor) {\n if (filterFn(cursor.value as T)) results.push(cursor.value as T);\n cursor.continue();\n } else {\n resolve(results);\n }\n };\n request.onerror = () =>\n reject(request.error ?? new DOMException(\"Unknown error\"));\n }),\n options\n );\n },\n\n upsert<T>(\n data: T,\n options?: OperationCallbacks<IDBValidKey>\n ): Promise<IDBValidKey> {\n const store = getStore();\n return withCallbacks(requestToPromise(store.put(data)), options);\n },\n\n bulkInsert<T>(\n items: T[],\n options?: OperationCallbacks<IDBValidKey[]>\n ): Promise<IDBValidKey[]> {\n const store = getStore();\n const keys: IDBValidKey[] = [];\n if (items.length === 0) {\n return withCallbacks(Promise.resolve(keys), options);\n }\n let completed = 0;\n const promise = new Promise<IDBValidKey[]>((resolve, reject) => {\n items.forEach((item, i) => {\n const req = store.add(item);\n req.onsuccess = () => {\n keys[i] = req.result;\n completed++;\n if (completed === items.length) resolve(keys);\n };\n req.onerror = () =>\n reject(req.error ?? new DOMException(\"Unknown error\"));\n });\n });\n return withCallbacks(promise, options);\n },\n\n clear(options?: OperationCallbacks<void>): Promise<void> {\n const store = getStore();\n return withCallbacks(\n requestToPromise(store.clear()).then(() => undefined),\n options\n );\n },\n\n count(options?: OperationCallbacks<number>): Promise<number> {\n const store = getStore();\n return withCallbacks(requestToPromise(store.count()), options ?? {});\n },\n };\n}\n\n/** Public interface for a table controller (standalone or transaction-scoped). */\nexport interface ITableController {\n insert<T>(\n data: T,\n options?: OperationCallbacks<IDBValidKey>\n ): Promise<IDBValidKey>;\n update<T>(\n key: IDBValidKey,\n updates: Partial<T>,\n options?: OperationCallbacks<void>\n ): Promise<void>;\n delete(key: IDBValidKey, options?: OperationCallbacks<void>): Promise<void>;\n exists(key: IDBValidKey): Promise<boolean>;\n query<T>(\n filterFn: (item: T) => boolean,\n options?: OperationCallbacks<T[]>\n ): Promise<T[]>;\n upsert<T>(\n data: T,\n options?: OperationCallbacks<IDBValidKey>\n ): Promise<IDBValidKey>;\n bulkInsert<T>(\n items: T[],\n options?: OperationCallbacks<IDBValidKey[]>\n ): Promise<IDBValidKey[]>;\n clear(options?: OperationCallbacks<void>): Promise<void>;\n count(options?: OperationCallbacks<number>): Promise<number>;\n}\n\nexport function createTableController(\n db: IDBDatabase,\n tableName: string\n): ITableController {\n return createStandaloneController(db, tableName);\n}\n\nexport function createTransactionTableController(\n tx: IDBTransaction,\n tableName: string\n): ITableController {\n return createTransactionController(tx, tableName);\n}\n","/**\n * Preact hook for IndexedDB: open database, create stores/indexes, return a database controller.\n * Uses a singleton connection per (name, version).\n * @module useIndexedDB\n */\n\nimport { useState, useEffect, useRef } from \"preact/hooks\";\nimport type { IndexedDBConfig } from \"./indexedDB/types\";\nimport { openDB } from \"./indexedDB/openDB\";\nimport { createDBController } from \"./indexedDB/dbController\";\nimport type { IDBController } from \"./indexedDB/dbController\";\n\nexport type { IndexedDBConfig, IDBController } from \"./indexedDB\";\n\nexport interface UseIndexedDBReturn {\n /** Database controller (table, transaction). Null until the database is open. */\n db: IDBController | null;\n /** True once the database is open and ready. */\n isReady: boolean;\n /** Error from opening the database, if any. */\n error: DOMException | null;\n}\n\n/**\n * Opens an IndexedDB database and returns a controller for tables and transactions.\n * Handles onupgradeneeded: creates object stores and indexes from config.\n * Connection is a singleton per (config.name, config.version).\n *\n * @param config - Database name, version, and table schemas (keyPath, autoIncrement, indexes).\n * @returns { db, isReady, error }. Use db.table(name) and db.transaction(...) when isReady is true.\n *\n * @example\n * const { db, isReady, error } = useIndexedDB({\n * name: 'my-db',\n * version: 1,\n * tables: {\n * users: { keyPath: 'id', autoIncrement: true, indexes: ['email'] },\n * },\n * })\n * if (isReady && db) {\n * const users = db.table('users')\n * await users.insert({ email: 'a@b.com' })\n * await db.transaction(['users'], 'readwrite', (tx) => tx.table('users').insert({ email: 'b@b.com' }))\n * }\n */\nexport function useIndexedDB(config: IndexedDBConfig): UseIndexedDBReturn {\n const [db, setDb] = useState<IDBController | null>(null);\n const [error, setError] = useState<DOMException | null>(null);\n const [isReady, setIsReady] = useState(false);\n const configRef = useRef(config);\n configRef.current = config;\n\n useEffect(() => {\n let cancelled = false;\n setError(null);\n setIsReady(false);\n setDb(null);\n\n const { name, version, tables } = configRef.current;\n openDB({ name, version, tables })\n .then((database) => {\n if (cancelled) {\n database.close();\n return;\n }\n const controller = createDBController(database, configRef.current);\n setDb(controller);\n setIsReady(true);\n })\n .catch((err: DOMException) => {\n if (!cancelled) setError(err);\n });\n\n return () => {\n cancelled = true;\n };\n }, [config.name, config.version]);\n\n return { db, isReady, error };\n}\n\n/*\n * Usage example:\n *\n * const { db, isReady, error } = useIndexedDB({\n * name: 'my-app-db',\n * version: 1,\n * tables: {\n * users: { keyPath: 'id', autoIncrement: true, indexes: ['email'] },\n * settings: { keyPath: 'key' },\n * },\n * })\n *\n * if (error) return <div>Failed to open database</div>\n * if (!isReady || !db) return <div>Loading...</div>\n *\n * const users = db.table('users')\n * await users.insert({ email: 'a@b.com', name: 'Alice' })\n * await users.update(1, { name: 'Alice Smith' })\n * const found = await users.query((u) => u.email.startsWith('a@'))\n * const n = await users.count()\n * await users.delete(1)\n * await users.upsert({ id: 2, email: 'b@b.com' })\n * await users.bulkInsert([{ email: 'c@b.com' }, { email: 'd@b.com' }])\n * await users.clear({ onSuccess: () => console.log('cleared') })\n *\n * await db.transaction(['users', 'settings'], 'readwrite', async (tx) => {\n * await tx.table('users').insert({ email: 'e@b.com' })\n * await tx.table('settings').upsert({ key: 'theme', value: 'dark' })\n * }, { onSuccess: () => console.log('transaction done') })\n */\n","/**\n * Database controller: table(name), transaction(storeNames, mode, callback, options).\n * @module indexedDB/dbController\n */\n\nimport type { IndexedDBConfig, TransactionOptions } from \"./types\";\nimport type { ITableController } from \"./tableController\";\nimport {\n createTableController,\n createTransactionTableController,\n} from \"./tableController\";\n\n/** Transaction context passed to the callback: provides table(name) bound to this transaction. */\nexport interface TransactionContext {\n /** Returns a table controller bound to this transaction. Use for all ops inside the callback. */\n table: (name: string) => ITableController;\n}\n\n/**\n * Database controller built from an open IDBDatabase.\n * Exposes table(name) and transaction(...).\n */\nexport interface IDBController {\n /** Underlying IDBDatabase (read-only). */\n readonly db: IDBDatabase;\n /** Returns true if an object store with the given name exists. */\n hasTable: (name: string) => boolean;\n /** Returns a table controller for the given store (each op opens its own transaction). */\n table: (name: string) => ITableController;\n /**\n * Runs a callback inside a single transaction. All operations in the callback use the same transaction.\n * @param storeNames - Object store names to include in the transaction.\n * @param mode - 'readonly' | 'readwrite'.\n * @param callback - Async or sync function receiving { table(name) }. Return value is ignored; await all ops inside.\n * @param options - Optional onSuccess/onError callbacks.\n * @returns Promise that resolves when the transaction completes (after all requests and the callback).\n */\n transaction: <T = void>(\n storeNames: string[],\n mode: IDBTransactionMode,\n callback: (tx: TransactionContext) => T | Promise<T>,\n options?: TransactionOptions\n ) => Promise<void>;\n}\n\nfunction withTransactionCallbacks(\n promise: Promise<void>,\n options?: TransactionOptions\n): Promise<void> {\n if (!options) return promise;\n return promise\n .then(() => options.onSuccess?.())\n .catch((err: DOMException) => {\n options.onError?.(err);\n throw err;\n });\n}\n\n/**\n * Creates a database controller from an open IDBDatabase instance.\n */\nexport function createDBController(\n db: IDBDatabase,\n _config: IndexedDBConfig\n): IDBController {\n void _config; // Reserved for future config options\n return {\n get db(): IDBDatabase {\n return db;\n },\n\n hasTable(name: string): boolean {\n return db.objectStoreNames.contains(name);\n },\n\n table(name: string): ITableController {\n return createTableController(db, name);\n },\n\n transaction<T = void>(\n storeNames: string[],\n mode: IDBTransactionMode,\n callback: (tx: TransactionContext) => T | Promise<T>,\n options?: TransactionOptions\n ): Promise<void> {\n const tx = db.transaction(storeNames, mode);\n const txContext: TransactionContext = {\n table: (tableName: string) =>\n createTransactionTableController(tx, tableName),\n };\n const txPromise = new Promise<void>((resolve, reject) => {\n tx.oncomplete = () => resolve();\n tx.onerror = () =>\n reject(tx.error ?? new DOMException(\"Transaction failed\"));\n });\n const callbackResult = callback(txContext);\n const promise = Promise.resolve(callbackResult).then(() => txPromise);\n return withTransactionCallbacks(promise, options);\n },\n };\n}\n","/**\n * useWebRTCIP – detect local/public IPs via WebRTC ICE candidates and STUN.\n * Not highly reliable; use as first-priority hint and fall back to a public IP API (e.g. ipapi.co) if needed.\n * @module useWebRTCIP\n */\n\nimport { useEffect, useState, useRef } from \"preact/hooks\";\n\n/** IPv4 regex for ICE candidate strings (captures dotted-decimal). */\nconst IPV4_REGEX =\n /\\b(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})(?:\\.(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})){3}\\b/g;\n\nconst DEFAULT_STUN_SERVERS: string[] = [\"stun:stun.l.google.com:19302\"];\nconst DEFAULT_TIMEOUT_MS = 3000;\n\nexport interface UseWebRTCIPOptions {\n /** STUN server URLs (default: Google STUN). */\n stunServers?: string[];\n /** Stop gathering after this many ms (default: 3000). */\n timeout?: number;\n /** Called once per newly detected IP (no duplicates). */\n onDetect?: (ip: string) => void;\n}\n\nexport interface UseWebRTCIPReturn {\n /** Unique IPv4 addresses found from ICE candidates. */\n ips: string[];\n /** True while ICE gathering is in progress. */\n loading: boolean;\n /** Error message if WebRTC is unavailable or detection fails. */\n error: string | null;\n}\n\nfunction isSSR(): boolean {\n return typeof window === \"undefined\";\n}\n\nfunction isWebRTCAvailable(): boolean {\n return typeof RTCPeerConnection !== \"undefined\";\n}\n\n/**\n * Extracts IPv4 addresses from an ICE candidate string.\n * Filters out common non-public/local patterns (e.g. 0.0.0.0) if desired; currently returns all matches.\n */\nfunction extractIPv4FromCandidate(candidate: string): string[] {\n const matches = candidate.match(IPV4_REGEX);\n return matches ? [...matches] : [];\n}\n\n/**\n * Attempts to detect client IP addresses using WebRTC ICE candidates and a STUN server.\n * Works frontend-only (no backend). Not guaranteed to return a public IP; use as a hint and\n * fall back to a public IP API (e.g. ipapi.co, ip-api.com) if you need reliability.\n *\n * @param options - Optional: stunServers, timeout (ms), onDetect(ip) callback.\n * @returns { ips, loading, error } – unique IPv4s, loading flag, and error message.\n *\n * @example\n * const { ips, loading, error } = useWebRTCIP({\n * timeout: 5000,\n * onDetect: (ip) => console.log('Detected:', ip),\n * })\n * // If ips is empty and error is set, fall back to: fetch('https://api.ipify.org?format=json')\n */\nexport function useWebRTCIP(\n options: UseWebRTCIPOptions = {}\n): UseWebRTCIPReturn {\n const {\n stunServers = DEFAULT_STUN_SERVERS,\n timeout: timeoutMs = DEFAULT_TIMEOUT_MS,\n onDetect,\n } = options;\n\n const [ips, setIps] = useState<string[]>([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n\n const pcRef = useRef<RTCPeerConnection | null>(null);\n const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const reportedRef = useRef<Set<string>>(new Set());\n const onDetectRef = useRef(onDetect);\n onDetectRef.current = onDetect;\n\n useEffect(() => {\n if (isSSR()) {\n setLoading(false);\n setError(\"WebRTC IP detection is not available during SSR\");\n return;\n }\n\n if (!isWebRTCAvailable()) {\n setLoading(false);\n setError(\"RTCPeerConnection is not available\");\n return;\n }\n\n const reported = new Set<string>();\n reportedRef.current = reported;\n\n const finish = () => {\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current);\n timeoutRef.current = null;\n }\n if (pcRef.current) {\n pcRef.current.close();\n pcRef.current = null;\n }\n setLoading(false);\n };\n\n const addIP = (ip: string) => {\n if (reported.has(ip)) return;\n reported.add(ip);\n setIps((prev) => {\n const next = [...prev, ip];\n return next;\n });\n onDetectRef.current?.(ip);\n };\n\n try {\n const pc = new RTCPeerConnection({\n iceServers: [{ urls: stunServers }],\n });\n pcRef.current = pc;\n\n pc.onicecandidate = (event) => {\n const c = event.candidate;\n if (!c || !c.candidate) return;\n const found = extractIPv4FromCandidate(c.candidate);\n found.forEach(addIP);\n };\n\n pc.createDataChannel(\"\");\n\n pc.createOffer()\n .then((offer) => pc.setLocalDescription(offer))\n .catch((err) => {\n setError(\n err instanceof Error ? err.message : \"Failed to create offer\"\n );\n finish();\n });\n\n timeoutRef.current = setTimeout(() => finish(), timeoutMs);\n } catch (err) {\n setError(err instanceof Error ? err.message : \"WebRTC setup failed\");\n finish();\n }\n\n return () => {\n finish();\n };\n }, [stunServers.join(\",\"), timeoutMs]);\n\n return { ips, loading, error };\n}\n\n/*\n * Example usage (Preact component):\n *\n * function MyIPDisplay() {\n * const { ips, loading, error } = useWebRTCIP({\n * timeout: 4000,\n * onDetect: (ip) => { / * optional: e.g. send to analytics * / },\n * })\n *\n * if (loading) return <p>Detecting IP…</p>\n * if (error) return <p>WebRTC failed: {error}. Try fallback API.</p>\n * return <p>IPs (WebRTC): {ips.join(', ') || 'None'}</p>\n * }\n *\n * Fallback to public IP API when WebRTC fails or returns empty:\n * const [apiIP, setApiIP] = useState<string | null>(null)\n * useEffect(() => {\n * if (!loading && ips.length === 0 && error)\n * fetch('https://api.ipify.org?format=json').then(r => r.json()).then(d => setApiIP(d.ip))\n * }, [loading, ips.length, error])\n */\n","/**\n * useWasmCompute – run WebAssembly computation off the main thread via a Web Worker.\n * Flow: Preact Component → useWasmCompute() → Web Worker → WASM Module → Return result.\n * @module useWasmCompute\n */\n\nimport { useState, useCallback, useRef, useEffect } from \"preact/hooks\";\n\nconst WASM_WORKER_SCRIPT = `\nself.onmessage = async (e) => {\n const d = e.data;\n if (d.type === 'init') {\n try {\n const res = await fetch(d.wasmUrl);\n const buf = await res.arrayBuffer();\n const mod = await WebAssembly.instantiate(buf, d.importObject || {});\n self.wasmInstance = mod.instance;\n self.exportName = d.exportName || 'compute';\n self.postMessage({ type: 'ready' });\n } catch (err) {\n self.postMessage({ type: 'error', error: (err && err.message) || String(err) });\n }\n return;\n }\n if (d.type === 'compute') {\n try {\n const fn = self.wasmInstance.exports[self.exportName];\n if (typeof fn !== 'function') {\n self.postMessage({ type: 'error', error: 'Export \"' + self.exportName + '\" is not a function' });\n return;\n }\n const result = fn(d.input);\n sel