UNPKG

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,311 lines (1,287 loc) 95.6 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var react = require('react'); /** * Mimics React's useTransition hook in Preact. * @returns [startTransition, isPending] */ function useTransition() { const [isPending, setIsPending] = react.useState(false); const startTransition = react.useCallback((callback) => { setIsPending(true); Promise.resolve().then(() => { callback(); setIsPending(false); }); }, []); return [startTransition, isPending]; } /** * A Preact hook to observe DOM mutations using MutationObserver. * @param target - The element to observe. * @param callback - Function to call on mutation. * @param options - MutationObserver options. */ function useMutationObserver(targetRef, callback, options) { react.useEffect(() => { const node = targetRef.current; if (!node) return; const observer = new MutationObserver(callback); observer.observe(node, options); return () => observer.disconnect(); }, [targetRef, callback, options]); } const listeners = new Map(); /** * A Preact hook to publish and subscribe to custom events across components. * @returns An object with `emit` and `on` methods. */ function useEventBus() { const emit = react.useCallback((event, ...args) => { const handlers = listeners.get(event); if (handlers) { handlers.forEach((handler) => handler(...args)); } }, []); const on = react.useCallback((event, handler) => { let handlers = listeners.get(event); if (!handlers) { handlers = new Set(); listeners.set(event, handlers); } handlers.add(handler); return () => { handlers.delete(handler); if (handlers.size === 0) { listeners.delete(event); } }; }, []); return { emit, on }; } /** * A Preact hook to wrap children components and inject additional props into them. * @param children - The children to wrap and enhance with props. * @param injectProps - The props to inject into each child component. * @param mergeStrategy - How to handle prop conflicts ('override' | 'preserve'). Defaults to 'preserve'. * @returns Enhanced children with injected props. */ function useWrappedChildren(children, injectProps, mergeStrategy = "preserve") { return react.useMemo(() => { if (!children) return children; const enhanceChild = (child) => { if (!react.isValidElement(child)) return child; const existingProps = child.props || {}; let mergedProps; if (mergeStrategy === "override") { // Injected props override existing ones mergedProps = Object.assign(Object.assign({}, existingProps), injectProps); } else { // Existing props are preserved, injected props are added only if not present mergedProps = Object.assign(Object.assign({}, injectProps), existingProps); } // Special handling for style prop to merge style objects properly const existingStyle = existingProps === null || existingProps === void 0 ? void 0 : existingProps.style; const injectStyle = injectProps === null || injectProps === void 0 ? void 0 : injectProps.style; if (existingStyle && injectStyle && typeof existingStyle === "object" && typeof injectStyle === "object") { if (mergeStrategy === "override") { mergedProps.style = Object.assign(Object.assign({}, existingStyle), injectStyle); } else { mergedProps.style = Object.assign(Object.assign({}, injectStyle), existingStyle); } } return react.cloneElement(child, mergedProps); }; if (Array.isArray(children)) { return children.map(enhanceChild); } return enhanceChild(children); }, [children, injectProps, mergeStrategy]); } /** * A Preact hook that returns the user's preferred color scheme based on the * `prefers-color-scheme` media query. Updates reactively when the user changes * their system or browser theme preference. * * @returns The preferred theme: 'light', 'dark', or 'no-preference' * * @example * ```tsx * function ThemeAwareComponent() { * const theme = usePreferredTheme(); * return ( * <div data-theme={theme}> * Current preference: {theme} * </div> * ); * } * ``` */ function usePreferredTheme() { const [theme, setTheme] = react.useState(() => { if (typeof window === "undefined") return "no-preference"; const darkQuery = window.matchMedia("(prefers-color-scheme: dark)"); const lightQuery = window.matchMedia("(prefers-color-scheme: light)"); if (darkQuery.matches) return "dark"; if (lightQuery.matches) return "light"; return "no-preference"; }); react.useEffect(() => { if (typeof window === "undefined") return; const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleChange = (e) => { setTheme(e.matches ? "dark" : "light"); }; // Re-check in case of no-preference (some browsers don't support light query) const updateTheme = () => { const darkQuery = window.matchMedia("(prefers-color-scheme: dark)"); const lightQuery = window.matchMedia("(prefers-color-scheme: light)"); if (darkQuery.matches) setTheme("dark"); else if (lightQuery.matches) setTheme("light"); else setTheme("no-preference"); }; mediaQuery.addEventListener("change", handleChange); // Fallback: some environments may not fire change, so we also listen for light const lightQuery = window.matchMedia("(prefers-color-scheme: light)"); lightQuery.addEventListener("change", updateTheme); return () => { mediaQuery.removeEventListener("change", handleChange); lightQuery.removeEventListener("change", updateTheme); }; }, []); return theme; } function getNetworkState() { if (typeof navigator === "undefined") { return { online: true }; } const state = { online: navigator.onLine, }; const connection = navigator.connection; if (connection) { if (connection.effectiveType !== undefined) { state.effectiveType = connection.effectiveType; } if (connection.downlink !== undefined) { state.downlink = connection.downlink; } if (connection.rtt !== undefined) { state.rtt = connection.rtt; } if (connection.saveData !== undefined) { state.saveData = connection.saveData; } if (connection.type !== undefined) { state.connectionType = connection.type; } } return state; } /** * A Preact hook that returns the current network state, including online/offline * status and (when supported) connection type, downlink, RTT, and save-data preference. * Updates reactively when the network state changes. * * @returns The current network state object * * @example * ```tsx * function NetworkStatus() { * const { online, effectiveType, saveData } = useNetworkState(); * return ( * <div> * Status: {online ? 'Online' : 'Offline'} * {effectiveType && ` (${effectiveType})`} * {saveData && ' - Reduced data mode'} * </div> * ); * } * ``` */ function useNetworkState() { const [state, setState] = react.useState(getNetworkState); react.useEffect(() => { if (typeof window === "undefined") return; const updateState = () => setState(getNetworkState()); window.addEventListener("online", updateState); window.addEventListener("offline", updateState); const connection = navigator.connection; if (connection === null || connection === void 0 ? void 0 : connection.addEventListener) { connection.addEventListener("change", updateState); } return () => { window.removeEventListener("online", updateState); window.removeEventListener("offline", updateState); if (connection === null || connection === void 0 ? void 0 : connection.removeEventListener) { connection.removeEventListener("change", updateState); } }; }, []); return state; } /****************************************************************************** Copyright (c) Microsoft Corporation. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ***************************************************************************** */ function __awaiter(thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); } typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }; /** * A Preact hook for reading and writing the clipboard. Uses the async * Clipboard API when available (requires secure context and user gesture). * * @param options - Optional configuration (e.g., resetDelay for copied state) * @returns Object with copy, paste, copied, error, and reset * * @example * ```tsx * function CopyButton() { * const { copy, copied, error } = useClipboard(); * return ( * <button onClick={() => copy('Hello!')}> * {copied ? 'Copied!' : 'Copy'} * </button> * ); * } * ``` */ function useClipboard(options = {}) { const { resetDelay = 2000 } = options; const [copied, setCopied] = react.useState(false); const [error, setError] = react.useState(null); const reset = react.useCallback(() => { setCopied(false); setError(null); }, []); const copy = react.useCallback((text) => __awaiter(this, void 0, void 0, function* () { setError(null); if (typeof navigator === "undefined" || !navigator.clipboard) { const err = new Error("Clipboard API is not available"); setError(err); return false; } try { yield navigator.clipboard.writeText(text); setCopied(true); if (resetDelay > 0) { setTimeout(() => setCopied(false), resetDelay); } return true; } catch (e) { const err = e instanceof Error ? e : new Error(String(e)); setError(err); return false; } }), [resetDelay]); const paste = react.useCallback(() => __awaiter(this, void 0, void 0, function* () { setError(null); if (typeof navigator === "undefined" || !navigator.clipboard) { const err = new Error("Clipboard API is not available"); setError(err); return ""; } try { const text = yield navigator.clipboard.readText(); return text; } catch (e) { const err = e instanceof Error ? e : new Error(String(e)); setError(err); return ""; } }), []); return { copy, paste, copied, error, reset }; } function distance(a, b) { return Math.hypot(b.x - a.x, b.y - a.y); } /** * Detects "rage clicks" (repeated rapid clicks in the same area), e.g. when the UI * is unresponsive. Use the callback to report to Sentry or similar tools to surface * rage click issues and lower rage-click-related support. * * @param targetRef - Ref of the element to monitor (e.g. a button or card). * @param options - onRageClick callback and optional threshold, timeWindow, distanceThreshold. * * @example * ```tsx * const ref = useRef<HTMLButtonElement>(null) * useRageClick(ref, { * onRageClick: ({ count, event }) => { * Sentry.captureMessage('Rage click detected', { extra: { count, target: event.target } }) * }, * }) * return <button ref={ref}>Submit</button> * ``` */ function useRageClick(targetRef, options) { const { onRageClick, threshold = 5, timeWindow = 1000, distanceThreshold = 30, } = options; const onRageClickRef = react.useRef(onRageClick); onRageClickRef.current = onRageClick; const clicksRef = react.useRef([]); react.useEffect(() => { const node = targetRef.current; if (!node) return; const handleClick = (e) => { const now = Date.now(); const record = { time: now, x: e.clientX, y: e.clientY }; const clicks = clicksRef.current; const cutoff = now - timeWindow; const recent = clicks.filter((c) => c.time >= cutoff); recent.push(record); if (distanceThreshold !== Infinity) { const inRange = recent.filter((c) => distance(c, record) <= distanceThreshold); if (inRange.length >= threshold) { onRageClickRef.current({ count: inRange.length, event: e }); clicksRef.current = []; return; } } else { if (recent.length >= threshold) { onRageClickRef.current({ count: recent.length, event: e }); clicksRef.current = []; return; } } clicksRef.current = recent; }; node.addEventListener("click", handleClick); return () => node.removeEventListener("click", handleClick); }, [targetRef, threshold, timeWindow, distanceThreshold]); } /** Lower number = higher priority. Default priority when not specified. */ const DEFAULT_PRIORITY = 1; /** * Production-grade hook to run async work in a queue with optional priority * and either sequential or parallel execution. * * @param workerFn - Async function to run for each task (e.g. API call, heavy compute). * @param options - mode: "sequential" | "parallel", concurrency (parallel only). * @returns run, loading, result, error, queueSize, clearQueue, terminate. */ function useThreadedWorker(workerFn, options) { const { mode, concurrency = 4 } = options; const maxConcurrent = mode === "sequential" ? 1 : Math.max(1, concurrency); const [loading, setLoading] = react.useState(false); const [result, setResult] = react.useState(undefined); const [error, setError] = react.useState(undefined); const [queueSize, setQueueSize] = react.useState(0); const queueRef = react.useRef([]); const sequenceRef = react.useRef(0); const activeCountRef = react.useRef(0); const terminatedRef = react.useRef(false); const workerFnRef = react.useRef(workerFn); workerFnRef.current = workerFn; const updateQueueSize = react.useCallback(() => { setQueueSize(queueRef.current.length + activeCountRef.current); }, []); const processNext = react.useCallback(() => { if (terminatedRef.current) return; if (activeCountRef.current >= maxConcurrent) return; if (queueRef.current.length === 0) { if (activeCountRef.current === 0) setLoading(false); updateQueueSize(); return; } // Sort by priority (asc), then by sequence (FIFO within same priority). queueRef.current.sort((a, b) => { if (a.priority !== b.priority) return a.priority - b.priority; return a.sequence - b.sequence; }); const task = queueRef.current.shift(); activeCountRef.current += 1; setLoading(true); updateQueueSize(); const fn = workerFnRef.current; fn(task.data) .then((value) => { setResult(value); setError(undefined); task.resolve(value); }) .catch((err) => { setError(err); task.reject(err); }) .finally(() => { activeCountRef.current -= 1; updateQueueSize(); processNext(); }); // Fill remaining slots (parallel mode). if (queueRef.current.length > 0 && activeCountRef.current < maxConcurrent) { processNext(); } }, [maxConcurrent, updateQueueSize]); const run = react.useCallback((data, runOptions) => { var _a; if (terminatedRef.current) { return Promise.reject(new Error("Worker is terminated")); } const priority = (_a = runOptions === null || runOptions === void 0 ? void 0 : runOptions.priority) !== null && _a !== void 0 ? _a : DEFAULT_PRIORITY; const sequence = ++sequenceRef.current; const promise = new Promise((resolve, reject) => { queueRef.current.push({ data, priority, sequence, resolve, reject }); }); updateQueueSize(); setLoading(true); queueMicrotask(processNext); return promise; }, [processNext, updateQueueSize]); const clearQueue = react.useCallback(() => { const pending = queueRef.current; queueRef.current = []; pending.forEach((t) => t.reject(new Error("Task cleared from queue"))); updateQueueSize(); if (activeCountRef.current === 0) setLoading(false); }, [updateQueueSize]); const terminate = react.useCallback(() => { terminatedRef.current = true; clearQueue(); }, [clearQueue]); // Reset terminated on unmount so the same hook instance can't be "revived" without options change. react.useEffect(() => { return () => { terminatedRef.current = true; }; }, []); return { run, loading, result, error, queueSize, clearQueue, terminate, }; } /** * Opens IndexedDB and runs onupgradeneeded to create stores and indexes. * Singleton per (name, version). * @module indexedDB/openDB */ const connectionCache = new Map(); /** * Opens the database and creates/upgrades object stores and indexes from config. * Uses a singleton cache per (name, version); repeated calls with the same config reuse the same connection. */ function openDB(config) { const key = `${config.name}_v${config.version}`; let promise = connectionCache.get(key); if (promise) return promise; promise = _openDB(config); connectionCache.set(key, promise); return promise; } function _openDB(config) { return new Promise((resolve, reject) => { const request = indexedDB.open(config.name, config.version); request.onerror = () => { var _a; return reject((_a = request.error) !== null && _a !== void 0 ? _a : new DOMException("Failed to open database")); }; request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { var _a; const db = event.target.result; const tables = config.tables; for (const tableName of Object.keys(tables)) { const schema = tables[tableName]; if (!db.objectStoreNames.contains(tableName)) { const store = db.createObjectStore(tableName, { keyPath: schema.keyPath, autoIncrement: (_a = schema.autoIncrement) !== null && _a !== void 0 ? _a : false, }); if (schema.indexes) { for (const indexName of schema.indexes) { store.createIndex(indexName, indexName, { unique: false }); } } } } }; }); } /** * Wraps an IDBRequest in a Promise. * @module indexedDB/requestToPromise */ /** * Converts an IDBRequest to a Promise. Rejects with the request's error on failure. * @param request - Native IndexedDB request. * @returns Promise that resolves with the request result or rejects with DOMException. */ function requestToPromise(request) { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => { var _a; return reject((_a = request.error) !== null && _a !== void 0 ? _a : new DOMException("Unknown IndexedDB error")); }; }); } /** * Table controller: insert, update, delete, exists, query, upsert, bulkInsert, clear, count. * Works in standalone mode (opens its own transaction per op) or bound to a transaction. * @module indexedDB/tableController */ /** Runs optional callbacks and returns the result. */ function withCallbacks(promise, options) { if (!options) return promise; return promise .then((result) => { var _a; (_a = options.onSuccess) === null || _a === void 0 ? void 0 : _a.call(options, result); return result; }) .catch((err) => { var _a; (_a = options.onError) === null || _a === void 0 ? void 0 : _a.call(options, err); throw err; }); } /** * Standalone table controller: opens a new transaction for each operation. */ function createStandaloneController(db, tableName) { function getStore(mode) { const tx = db.transaction([tableName], mode); return tx.objectStore(tableName); } return { insert(data, options) { const store = getStore("readwrite"); return withCallbacks(requestToPromise(store.add(data)), options); }, update(key, updates, options) { const store = getStore("readwrite"); const getReq = store.get(key); return withCallbacks(requestToPromise(getReq) .then((existing) => { if (existing === undefined) { throw new DOMException("Key not found", "NotFoundError"); } const merged = Object.assign(Object.assign({}, existing), updates); return requestToPromise(store.put(merged)); }) .then(() => undefined), options); }, delete(key, options) { const store = getStore("readwrite"); return withCallbacks(requestToPromise(store.delete(key)).then(() => undefined), options); }, exists(key) { const store = getStore("readonly"); return requestToPromise(store.getKey(key)).then((k) => k !== undefined); }, query(filterFn, options) { const store = getStore("readonly"); const request = store.openCursor(); const results = []; return withCallbacks(new Promise((resolve, reject) => { request.onsuccess = () => { const cursor = request.result; if (cursor) { if (filterFn(cursor.value)) results.push(cursor.value); cursor.continue(); } else { resolve(results); } }; request.onerror = () => { var _a; return reject((_a = request.error) !== null && _a !== void 0 ? _a : new DOMException("Unknown error")); }; }), options); }, upsert(data, options) { const store = getStore("readwrite"); return withCallbacks(requestToPromise(store.put(data)), options); }, bulkInsert(items, options) { const store = getStore("readwrite"); const keys = []; if (items.length === 0) { return withCallbacks(Promise.resolve(keys), options); } let completed = 0; const promise = new Promise((resolve, reject) => { const onDone = () => { completed++; if (completed === items.length) resolve(keys); }; items.forEach((item, i) => { const req = store.add(item); req.onsuccess = () => { keys[i] = req.result; onDone(); }; req.onerror = () => { var _a; return reject((_a = req.error) !== null && _a !== void 0 ? _a : new DOMException("Unknown error")); }; }); }); return withCallbacks(promise, options); }, clear(options) { const store = getStore("readwrite"); return withCallbacks(requestToPromise(store.clear()).then(() => undefined), options); }, count(options) { const store = getStore("readonly"); return withCallbacks(requestToPromise(store.count()), options !== null && options !== void 0 ? options : {}); }, }; } /** * Transaction-scoped table controller: uses the given transaction (no new transaction). */ function createTransactionController(tx, tableName) { function getStore() { return tx.objectStore(tableName); } return { insert(data, options) { const store = getStore(); return withCallbacks(requestToPromise(store.add(data)), options); }, update(key, updates, options) { const store = getStore(); return withCallbacks(requestToPromise(store.get(key)) .then((existing) => { if (existing === undefined) { throw new DOMException("Key not found", "NotFoundError"); } const merged = Object.assign(Object.assign({}, existing), updates); return requestToPromise(store.put(merged)); }) .then(() => undefined), options); }, delete(key, options) { const store = getStore(); return withCallbacks(requestToPromise(store.delete(key)).then(() => undefined), options); }, exists(key) { const store = getStore(); return requestToPromise(store.getKey(key)).then((k) => k !== undefined); }, query(filterFn, options) { const store = getStore(); const request = store.openCursor(); const results = []; return withCallbacks(new Promise((resolve, reject) => { request.onsuccess = () => { const cursor = request.result; if (cursor) { if (filterFn(cursor.value)) results.push(cursor.value); cursor.continue(); } else { resolve(results); } }; request.onerror = () => { var _a; return reject((_a = request.error) !== null && _a !== void 0 ? _a : new DOMException("Unknown error")); }; }), options); }, upsert(data, options) { const store = getStore(); return withCallbacks(requestToPromise(store.put(data)), options); }, bulkInsert(items, options) { const store = getStore(); const keys = []; if (items.length === 0) { return withCallbacks(Promise.resolve(keys), options); } let completed = 0; const promise = new Promise((resolve, reject) => { items.forEach((item, i) => { const req = store.add(item); req.onsuccess = () => { keys[i] = req.result; completed++; if (completed === items.length) resolve(keys); }; req.onerror = () => { var _a; return reject((_a = req.error) !== null && _a !== void 0 ? _a : new DOMException("Unknown error")); }; }); }); return withCallbacks(promise, options); }, clear(options) { const store = getStore(); return withCallbacks(requestToPromise(store.clear()).then(() => undefined), options); }, count(options) { const store = getStore(); return withCallbacks(requestToPromise(store.count()), options !== null && options !== void 0 ? options : {}); }, }; } function createTableController(db, tableName) { return createStandaloneController(db, tableName); } function createTransactionTableController(tx, tableName) { return createTransactionController(tx, tableName); } /** * Database controller: table(name), transaction(storeNames, mode, callback, options). * @module indexedDB/dbController */ function withTransactionCallbacks(promise, options) { if (!options) return promise; return promise .then(() => { var _a; return (_a = options.onSuccess) === null || _a === void 0 ? void 0 : _a.call(options); }) .catch((err) => { var _a; (_a = options.onError) === null || _a === void 0 ? void 0 : _a.call(options, err); throw err; }); } /** * Creates a database controller from an open IDBDatabase instance. */ function createDBController(db, _config) { return { get db() { return db; }, hasTable(name) { return db.objectStoreNames.contains(name); }, table(name) { return createTableController(db, name); }, transaction(storeNames, mode, callback, options) { const tx = db.transaction(storeNames, mode); const txContext = { table: (tableName) => createTransactionTableController(tx, tableName), }; const txPromise = new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => { var _a; return reject((_a = tx.error) !== null && _a !== void 0 ? _a : new DOMException("Transaction failed")); }; }); const callbackResult = callback(txContext); const promise = Promise.resolve(callbackResult).then(() => txPromise); return withTransactionCallbacks(promise, options); }, }; } /** * Preact hook for IndexedDB: open database, create stores/indexes, return a database controller. * Uses a singleton connection per (name, version). * @module useIndexedDB */ /** * Opens an IndexedDB database and returns a controller for tables and transactions. * Handles onupgradeneeded: creates object stores and indexes from config. * Connection is a singleton per (config.name, config.version). * * @param config - Database name, version, and table schemas (keyPath, autoIncrement, indexes). * @returns { db, isReady, error }. Use db.table(name) and db.transaction(...) when isReady is true. * * @example * const { db, isReady, error } = useIndexedDB({ * name: 'my-db', * version: 1, * tables: { * users: { keyPath: 'id', autoIncrement: true, indexes: ['email'] }, * }, * }) * if (isReady && db) { * const users = db.table('users') * await users.insert({ email: 'a@b.com' }) * await db.transaction(['users'], 'readwrite', (tx) => tx.table('users').insert({ email: 'b@b.com' })) * } */ function useIndexedDB(config) { const [db, setDb] = react.useState(null); const [error, setError] = react.useState(null); const [isReady, setIsReady] = react.useState(false); const configRef = react.useRef(config); configRef.current = config; react.useEffect(() => { let cancelled = false; setError(null); setIsReady(false); setDb(null); const { name, version, tables } = configRef.current; openDB({ name, version, tables }) .then((database) => { if (cancelled) { database.close(); return; } const controller = createDBController(database, configRef.current); setDb(controller); setIsReady(true); }) .catch((err) => { if (!cancelled) setError(err); }); return () => { cancelled = true; }; }, [config.name, config.version]); return { db, isReady, error }; } /* * Usage example: * * const { db, isReady, error } = useIndexedDB({ * name: 'my-app-db', * version: 1, * tables: { * users: { keyPath: 'id', autoIncrement: true, indexes: ['email'] }, * settings: { keyPath: 'key' }, * }, * }) * * if (error) return <div>Failed to open database</div> * if (!isReady || !db) return <div>Loading...</div> * * const users = db.table('users') * await users.insert({ email: 'a@b.com', name: 'Alice' }) * await users.update(1, { name: 'Alice Smith' }) * const found = await users.query((u) => u.email.startsWith('a@')) * const n = await users.count() * await users.delete(1) * await users.upsert({ id: 2, email: 'b@b.com' }) * await users.bulkInsert([{ email: 'c@b.com' }, { email: 'd@b.com' }]) * await users.clear({ onSuccess: () => console.log('cleared') }) * * await db.transaction(['users', 'settings'], 'readwrite', async (tx) => { * await tx.table('users').insert({ email: 'e@b.com' }) * await tx.table('settings').upsert({ key: 'theme', value: 'dark' }) * }, { onSuccess: () => console.log('transaction done') }) */ /** * useWebRTCIP – detect local/public IPs via WebRTC ICE candidates and STUN. * Not highly reliable; use as first-priority hint and fall back to a public IP API (e.g. ipapi.co) if needed. * @module useWebRTCIP */ /** IPv4 regex for ICE candidate strings (captures dotted-decimal). */ const IPV4_REGEX = /\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; const DEFAULT_STUN_SERVERS = ["stun:stun.l.google.com:19302"]; const DEFAULT_TIMEOUT_MS = 3000; function isSSR$1() { return typeof window === "undefined"; } function isWebRTCAvailable() { return typeof RTCPeerConnection !== "undefined"; } /** * Extracts IPv4 addresses from an ICE candidate string. * Filters out common non-public/local patterns (e.g. 0.0.0.0) if desired; currently returns all matches. */ function extractIPv4FromCandidate(candidate) { const matches = candidate.match(IPV4_REGEX); return matches ? [...matches] : []; } /** * Attempts to detect client IP addresses using WebRTC ICE candidates and a STUN server. * Works frontend-only (no backend). Not guaranteed to return a public IP; use as a hint and * fall back to a public IP API (e.g. ipapi.co, ip-api.com) if you need reliability. * * @param options - Optional: stunServers, timeout (ms), onDetect(ip) callback. * @returns { ips, loading, error } – unique IPv4s, loading flag, and error message. * * @example * const { ips, loading, error } = useWebRTCIP({ * timeout: 5000, * onDetect: (ip) => console.log('Detected:', ip), * }) * // If ips is empty and error is set, fall back to: fetch('https://api.ipify.org?format=json') */ function useWebRTCIP(options = {}) { const { stunServers = DEFAULT_STUN_SERVERS, timeout: timeoutMs = DEFAULT_TIMEOUT_MS, onDetect, } = options; const [ips, setIps] = react.useState([]); const [loading, setLoading] = react.useState(true); const [error, setError] = react.useState(null); const pcRef = react.useRef(null); const timeoutRef = react.useRef(null); const reportedRef = react.useRef(new Set()); const onDetectRef = react.useRef(onDetect); onDetectRef.current = onDetect; react.useEffect(() => { if (isSSR$1()) { setLoading(false); setError("WebRTC IP detection is not available during SSR"); return; } if (!isWebRTCAvailable()) { setLoading(false); setError("RTCPeerConnection is not available"); return; } const reported = new Set(); reportedRef.current = reported; const finish = () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } if (pcRef.current) { pcRef.current.close(); pcRef.current = null; } setLoading(false); }; const addIP = (ip) => { var _a; if (reported.has(ip)) return; reported.add(ip); setIps((prev) => { const next = [...prev, ip]; return next; }); (_a = onDetectRef.current) === null || _a === void 0 ? void 0 : _a.call(onDetectRef, ip); }; try { const pc = new RTCPeerConnection({ iceServers: [{ urls: stunServers }], }); pcRef.current = pc; pc.onicecandidate = (event) => { const c = event.candidate; if (!c || !c.candidate) return; const found = extractIPv4FromCandidate(c.candidate); found.forEach(addIP); }; pc.createDataChannel(""); pc.createOffer() .then((offer) => pc.setLocalDescription(offer)) .catch((err) => { setError(err instanceof Error ? err.message : "Failed to create offer"); finish(); }); timeoutRef.current = setTimeout(() => finish(), timeoutMs); } catch (err) { setError(err instanceof Error ? err.message : "WebRTC setup failed"); finish(); } return () => { finish(); }; }, [stunServers.join(","), timeoutMs]); return { ips, loading, error }; } /* * Example usage (Preact component): * * function MyIPDisplay() { * const { ips, loading, error } = useWebRTCIP({ * timeout: 4000, * onDetect: (ip) => { / * optional: e.g. send to analytics * / }, * }) * * if (loading) return <p>Detecting IP…</p> * if (error) return <p>WebRTC failed: {error}. Try fallback API.</p> * return <p>IPs (WebRTC): {ips.join(', ') || 'None'}</p> * } * * Fallback to public IP API when WebRTC fails or returns empty: * const [apiIP, setApiIP] = useState<string | null>(null) * useEffect(() => { * if (!loading && ips.length === 0 && error) * fetch('https://api.ipify.org?format=json').then(r => r.json()).then(d => setApiIP(d.ip)) * }, [loading, ips.length, error]) */ /** * useWasmCompute – run WebAssembly computation off the main thread via a Web Worker. * Flow: Preact Component → useWasmCompute() → Web Worker → WASM Module → Return result. * @module useWasmCompute */ const WASM_WORKER_SCRIPT = ` self.onmessage = async (e) => { const d = e.data; if (d.type === 'init') { try { const res = await fetch(d.wasmUrl); const buf = await res.arrayBuffer(); const mod = await WebAssembly.instantiate(buf, d.importObject || {}); self.wasmInstance = mod.instance; self.exportName = d.exportName || 'compute'; self.postMessage({ type: 'ready' }); } catch (err) { self.postMessage({ type: 'error', error: (err && err.message) || String(err) }); } return; } if (d.type === 'compute') { try { const fn = self.wasmInstance.exports[self.exportName]; if (typeof fn !== 'function') { self.postMessage({ type: 'error', error: 'Export "' + self.exportName + '" is not a function' }); return; } const result = fn(d.input); self.postMessage({ type: 'result', result: result }); } catch (err) { self.postMessage({ type: 'error', error: (err && err.message) || String(err) }); } } }; `; function isSSR() { return typeof window === "undefined"; } function isWorkerSupported() { return typeof Worker !== "undefined"; } function isWebAssemblySupported() { return (typeof WebAssembly !== "undefined" && typeof WebAssembly.instantiate === "function"); } function createWorker(workerUrl) { if (workerUrl) { return new Worker(workerUrl); } const blob = new Blob([WASM_WORKER_SCRIPT], { type: "application/javascript", }); const url = URL.createObjectURL(blob); const w = new Worker(url); URL.revokeObjectURL(url); return w; } /** * Runs WebAssembly computation in a Web Worker. Validates environment (browser, Worker, WebAssembly) * and returns a stable compute function plus result/loading/error/ready state. * * @param options - wasmUrl, optional exportName, optional workerUrl, optional importObject. * @returns { compute, result, loading, error, ready }. * * @example * const { compute, result, loading, error, ready } = useWasmCompute({ wasmUrl: '/add.wasm', exportName: 'add' }); * // When ready: compute(2).then(sum => ...); result will update with the last return value. */ function useWasmCompute(options) { const { wasmUrl, exportName = "compute", workerUrl, importObject } = options; const [result, setResult] = react.useState(undefined); const [loading, setLoading] = react.useState(true); const [error, setError] = react.useState(null); const [ready, setReady] = react.useState(false); const workerRef = react.useRef(null); const pendingResolveRef = react.useRef(null); const pendingRejectRef = react.useRef(null); react.useEffect(() => { if (isSSR()) { setError("useWasmCompute is not available during SSR"); setLoading(false); return; } if (!isWorkerSupported()) { setError("Worker is not supported in this environment"); setLoading(false); return; } if (!isWebAssemblySupported()) { setError("WebAssembly is not supported in this environment"); setLoading(false); return; } setError(null); setReady(false); const worker = createWorker(workerUrl); workerRef.current = worker; const onMessage = (e) => { var _a; const { type, result: msgResult, error: msgError } = (_a = e.data) !== null && _a !== void 0 ? _a : {}; if (type === "ready") { setReady(true); setLoading(false); return; } if (type === "error") { setError(msgError !== null && msgError !== void 0 ? msgError : "Unknown error"); setLoading(false); if (pendingRejectRef.current) { pendingRejectRef.current(new Error(msgError)); pendingResolveRef.current = null; pendingRejectRef.current = null; } return; } if (type === "result") { setResult(msgResult); setLoading(false); if (pendingResolveRef.current) { pendingResolveRef.current(msgResult); pendingResolveRef.current = null; pendingRejectRef.current = null; } } }; worker.addEventListener("message", onMessage); worker.postMessage({ type: "init", wasmUrl, exportName, importObject: importObject !== null && importObject !== void 0 ? importObject : {}, }); return () => { worker.removeEventListener("message", onMessage); worker.terminate(); workerRef.current = null; if (pendingRejectRef.current) { pendingRejectRef.current(new Error("Worker terminated")); pendingResolveRef.current = null; pendingRejectRef.current = null; } }; }, [wasmUrl, exportName, workerUrl, importObject]); const compute = react.useCallback((input) => { return new Promise((resolve, reject) => { if (!workerRef.current || !ready) { reject(new Error("WASM not ready")); return; } if (error) { reject(new Error(error)); return; } pendingResolveRef.current = resolve; pendingRejectRef.current = reject; setLoading(true); workerRef.current.postMessage({ type: "compute", input }); }); }, [ready, error]); return { compute, result, loading, error, ready }; } /** * useWorkerNotifications – listen to worker messages and maintain running state, counts, history, and derived stats. * @module useWorkerNotifications */ function parseMessage(data) { if (data == null || typeof data !== "object") return null; const d = data; const type = d.type; if (type !== "task_start" && type !== "task_end" && type !== "task_fail" && type !== "queue_size") { return null; } const taskId = typeof d.taskId === "string" ? d.taskId : undefined; const duration = typeof d.duration === "number" ? d.duration : undefined; const error = typeof d.error === "string" ? d.error : undefined; const size = typeof d.size === "number" ? d.size : undefined; return { type: type, taskId, duration, error, size, timestamp: Date.now(), }; } /** * Listens to a Worker's messages and maintains state: running tasks, completed/failed counts, * event history, execution time per task, average duration, throughput per second, and queue size. * Worker should postMessage with: { type: 'task_start'|'task_end'|'task_fail'|'queue_size', taskId?, duration?, error?, size? }. * * @param worker - The Worker instance to listen to, or null/undefined to listen to nothing. * @param options - Optional maxHistory and throughputWindowMs. * @returns State and derived stats plus a default progress object. */ function useWorkerNotifications(worker, options = {}) { const { maxHistory = 100, throughputWindowMs = 1000 } = options; const [runningTasks, setRunningTasks] = react.useState([]); const [completedCount, setCompletedCount] = react.useState(0); const [failedCount, setFailedCount] = react.useState(0); const [eventHistory, setEventHistory] = react.useState([]); const [currentQueueSize, setCurrentQueueSize] = react.useState(0); const completedTimestampsRef = react.useRef([]); const durationSumRef = react.useRef(0); const durationCountRef = react.useRef(0); react.useEffect(() => { if (!worker) return; const onMessage = (e) => { const ev = parseMessage(e.data); if (!ev) return; setEventHistory((prev) => { const next = [...prev, ev].slice(-maxHistory); return next; }); if (ev.type === "task_start" && ev.taskId) { setRunningTasks((prev) => prev.includes(ev.taskId) ? prev : [...prev, ev.taskId]); } else if (ev.type === "task_end") { if (ev.taskId) { setRunningTasks((prev) => prev.filter((id) => id !== ev.taskId)); } setCompletedCount((c) => c + 1); const cutoff = Date.now() - throughputWindowMs; completedTimestampsRef.current = [ ...completedTimestampsRef.current.filter((t) => t >= cutoff), ev.timestamp, ]; if (typeof ev.duration === "number") { durationSumRef.current += ev.duration; durationCountRef.current += 1; } } else if (ev.type === "task_fail") { if (ev.taskId) { setRunningTasks((prev) => prev.filter((id) => id !== ev.taskId)); } setFailedCount((c) => c + 1); } else if (ev.type === "queue_size" && typeof ev.size === "number") { setCurrentQueueSize(ev.size); } }; worker.addEventListener("message", onMessage); return () => worker.removeEventListener("message", onMessage); }, [worker, maxHistory]); const averageDurationMs = react.useMemo(() => { const count = durationCountRef.current; const sum = durationSumRef.current; return count