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.
182 lines (157 loc) • 5.48 kB
text/typescript
/**
* 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
*/
import { useEffect, useState, useRef } from "preact/hooks";
/** 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: string[] = ["stun:stun.l.google.com:19302"];
const DEFAULT_TIMEOUT_MS = 3000;
export interface UseWebRTCIPOptions {
/** STUN server URLs (default: Google STUN). */
stunServers?: string[];
/** Stop gathering after this many ms (default: 3000). */
timeout?: number;
/** Called once per newly detected IP (no duplicates). */
onDetect?: (ip: string) => void;
}
export interface UseWebRTCIPReturn {
/** Unique IPv4 addresses found from ICE candidates. */
ips: string[];
/** True while ICE gathering is in progress. */
loading: boolean;
/** Error message if WebRTC is unavailable or detection fails. */
error: string | null;
}
function isSSR(): boolean {
return typeof window === "undefined";
}
function isWebRTCAvailable(): boolean {
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: string): string[] {
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')
*/
export function useWebRTCIP(
options: UseWebRTCIPOptions = {}
): UseWebRTCIPReturn {
const {
stunServers = DEFAULT_STUN_SERVERS,
timeout: timeoutMs = DEFAULT_TIMEOUT_MS,
onDetect,
} = options;
const [ips, setIps] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reportedRef = useRef<Set<string>>(new Set());
const onDetectRef = useRef(onDetect);
onDetectRef.current = onDetect;
useEffect(() => {
if (isSSR()) {
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<string>();
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: string) => {
if (reported.has(ip)) return;
reported.add(ip);
setIps((prev) => {
const next = [...prev, ip];
return next;
});
onDetectRef.current?.(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])
*/