diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e07bd6d0..2983ddf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,4 +15,4 @@ jobs: cache: 'npm' - run: npm ci - run: npm run build - - run: npm run lint \ No newline at end of file + - run: npm run lint diff --git a/next.config.ts b/next.config.ts index 502a79a4..17680aa3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,6 +7,46 @@ const nextConfig: NextConfig = { // Many legacy files do not match Prettier; keep type checking without blocking production builds. ignoreDuringBuilds: true, }, + + // ── Asset Versioning & Cache Headers (#326) ────────────────────────────── + // Next.js already content-hashes JS/CSS chunks in /_next/static/. + // We add long-lived cache headers for those immutable assets and a + // short revalidation window for HTML pages so users never see stale UI. + async headers() { + return [ + { + // Immutable hashed static assets – cache for 1 year + source: '/_next/static/:path*', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=31536000, immutable', + }, + ], + }, + { + // Public folder assets (images, fonts, etc.) – cache for 7 days + source: '/static/:path*', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=604800, stale-while-revalidate=86400', + }, + ], + }, + { + // HTML pages – always revalidate so deployments are picked up quickly + source: '/:path*', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=0, must-revalidate', + }, + ], + }, + ]; + }, + images: { formats: ['image/avif', 'image/webp'], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], diff --git a/src/hooks/useApi.ts b/src/hooks/useApi.ts new file mode 100644 index 00000000..5373c4d5 --- /dev/null +++ b/src/hooks/useApi.ts @@ -0,0 +1,86 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { buildDedupeKey, dedupe, cancelDedupe } from '@/lib/api/dedupe'; + +export type ApiState = { + data: T | null; + loading: boolean; + error: Error | null; +}; + +export type UseApiOptions = { + /** Skip the initial fetch (manual trigger only). */ + skip?: boolean; + /** Extra data included in the dedupe key (e.g. request body). */ + body?: unknown; +}; + +/** + * Hook for data fetching with automatic request deduplication. + * + * Concurrent calls with the same method + url + body share a single + * in-flight request instead of firing duplicate network calls. + */ +export function useApi( + url: string, + options: RequestInit & UseApiOptions = {}, +): ApiState & { refetch: () => void } { + const { skip = false, body, ...fetchOptions } = options; + const method = fetchOptions.method ?? 'GET'; + + const [state, setState] = useState>({ + data: null, + loading: !skip, + error: null, + }); + + // Track whether the component is still mounted to avoid state updates after unmount. + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const fetchData = useCallback(async () => { + const key = buildDedupeKey(method, url, body); + + if (mountedRef.current) { + setState((prev) => ({ ...prev, loading: true, error: null })); + } + + try { + const data = await dedupe(key, () => + fetch(url, { + ...fetchOptions, + method, + ...(body ? { body: JSON.stringify(body) } : {}), + }).then((res) => { + if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`); + return res.json() as Promise; + }), + ); + + if (mountedRef.current) { + setState({ data, loading: false, error: null }); + } + } catch (err) { + if (mountedRef.current) { + setState({ data: null, loading: false, error: err instanceof Error ? err : new Error(String(err)) }); + } + } + }, [url, method, body]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!skip) { + fetchData(); + } + return () => { + cancelDedupe(buildDedupeKey(method, url, body)); + }; + }, [skip, fetchData]); // eslint-disable-line react-hooks/exhaustive-deps + + return { ...state, refetch: fetchData }; +} diff --git a/src/lib/api/dedupe.ts b/src/lib/api/dedupe.ts new file mode 100644 index 00000000..4d750caa --- /dev/null +++ b/src/lib/api/dedupe.ts @@ -0,0 +1,74 @@ +/** + * Request Deduplication Cache (#323) + * + * Merges concurrent identical requests so only one network call is made. + * Subsequent callers receive the same promise as the in-flight request. + */ + +type Resolver = { + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +}; + +interface InFlight { + promise: Promise; + resolvers: Resolver[]; +} + +const cache = new Map>(); + +/** + * Build a stable cache key from method + url + optional body. + */ +export function buildDedupeKey(method: string, url: string, body?: unknown): string { + const base = `${method.toUpperCase()}:${url}`; + return body ? `${base}:${JSON.stringify(body)}` : base; +} + +/** + * Deduplicate an async request factory. + * + * If a request with the same key is already in-flight, the caller receives + * the same promise instead of triggering a new network call. + * + * @param key - Unique identifier for this request (use buildDedupeKey) + * @param fn - Factory that performs the actual request + */ +export async function dedupe(key: string, fn: () => Promise): Promise { + const existing = cache.get(key) as InFlight | undefined; + if (existing) { + return existing.promise; + } + + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + const entry: InFlight = { promise, resolvers: [{ resolve, reject }] }; + cache.set(key, entry as InFlight); + + try { + const result = await fn(); + resolve(result); + return result; + } catch (err) { + reject(err); + throw err; + } finally { + cache.delete(key); + } +} + +/** Remove a specific key from the cache (e.g. on cancellation). */ +export function cancelDedupe(key: string): void { + cache.delete(key); +} + +/** Clear the entire deduplication cache. */ +export function clearDedupeCache(): void { + cache.clear(); +} diff --git a/src/lib/errors/index.ts b/src/lib/errors/index.ts new file mode 100644 index 00000000..1f8a1995 --- /dev/null +++ b/src/lib/errors/index.ts @@ -0,0 +1,121 @@ +/** + * Error Tracking Integration (#327) + * + * Wires a Sentry-compatible interface over the existing errorReportingService. + * Drop-in: swap the stub init() for a real Sentry.init() call when the SDK + * is installed, without changing any call-sites. + */ + +import { errorReportingService, BreadcrumbEntry } from '@/services/errorReporting'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface ErrorContext { + userId?: string; + tags?: Record; + extra?: Record; +} + +export interface Breadcrumb { + category: string; + message?: string; + data?: Record; + level?: 'debug' | 'info' | 'warning' | 'error'; +} + +// ── Sentry-compatible stub ──────────────────────────────────────────────────── +// Replace the body of each function with the real Sentry SDK call once +// `@sentry/nextjs` is installed. + +let _initialized = false; + +/** + * Initialise the error tracking SDK. + * Call once at application startup (e.g. in instrumentation.ts). + */ +export function init(dsn?: string): void { + if (_initialized) return; + _initialized = true; + + // TODO: replace with Sentry.init({ dsn, ... }) when SDK is installed. + if (dsn) { + console.info('[ErrorTracking] Initialised with DSN:', dsn); + } + + // Forward global unhandled errors to the reporting service automatically. + if (typeof window !== 'undefined') { + window.addEventListener('unhandledrejection', (event) => { + captureException(event.reason, { extra: { type: 'unhandledRejection' } }); + }); + window.addEventListener('error', (event) => { + captureException(event.error ?? new Error(event.message), { + extra: { filename: event.filename, lineno: event.lineno }, + }); + }); + } +} + +/** + * Capture an exception with optional context. + * Mirrors Sentry.captureException(). + */ +export function captureException(error: unknown, context?: ErrorContext): void { + const err = error instanceof Error ? error : new Error(String(error)); + + if (context?.userId) { + errorReportingService.setUserId(context.userId); + } + + addBreadcrumb({ + category: 'exception', + message: err.message, + data: { stack: err.stack, ...context?.extra }, + level: 'error', + }); + + errorReportingService.reportError(err, { tags: context?.tags, extra: context?.extra }); +} + +/** + * Capture a plain message (non-exception). + * Mirrors Sentry.captureMessage(). + */ +export function captureMessage(message: string, context?: ErrorContext): void { + addBreadcrumb({ category: 'message', message, level: 'info', data: context?.extra }); + errorReportingService.reportError(new Error(message), context); +} + +/** + * Add a breadcrumb for richer error context. + * Mirrors Sentry.addBreadcrumb(). + */ +export function addBreadcrumb(breadcrumb: Breadcrumb): void { + errorReportingService.addBreadcrumb(breadcrumb.category, { + message: breadcrumb.message, + level: breadcrumb.level, + ...breadcrumb.data, + }); +} + +/** + * Attach user identity to subsequent error reports. + * Mirrors Sentry.setUser(). + */ +export function setUser(user: { id: string; [key: string]: unknown } | null): void { + if (user) { + errorReportingService.setUserId(user.id); + addBreadcrumb({ category: 'user', message: 'User identified', data: { userId: user.id } }); + } else { + errorReportingService.clearUserId(); + } +} + +/** + * Retrieve current breadcrumbs (useful for diagnostics / tests). + */ +export function getBreadcrumbs(): BreadcrumbEntry[] { + return errorReportingService.getBreadcrumbs(); +} + +// Re-export the underlying service for advanced use-cases. +export { errorReportingService }; diff --git a/src/lib/queue/index.ts b/src/lib/queue/index.ts new file mode 100644 index 00000000..f623a6ff --- /dev/null +++ b/src/lib/queue/index.ts @@ -0,0 +1,161 @@ +/** + * Lightweight Task Queue (#325) + * + * Runs async jobs in the background with configurable concurrency, + * exponential-backoff retry, and a dead-letter queue for failed jobs. + */ + +export type JobStatus = 'pending' | 'running' | 'done' | 'failed'; + +export interface Job { + id: string; + name: string; + payload: T; + status: JobStatus; + attempts: number; + maxAttempts: number; + lastError?: string; + createdAt: number; + updatedAt: number; +} + +export type JobHandler = (job: Job) => Promise; + +export interface QueueOptions { + /** Max concurrent jobs (default: 3). */ + concurrency?: number; + /** Max retry attempts per job (default: 3). */ + maxAttempts?: number; + /** Base delay in ms for exponential backoff (default: 500). */ + retryDelay?: number; +} + +let _idCounter = 0; +function nextId(): string { + return `job-${Date.now()}-${++_idCounter}`; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export class TaskQueue { + private queue: Job[] = []; + private deadLetter: Job[] = []; + private handlers = new Map>(); + private running = 0; + private opts: Required; + + constructor(options: QueueOptions = {}) { + this.opts = { + concurrency: options.concurrency ?? 3, + maxAttempts: options.maxAttempts ?? 3, + retryDelay: options.retryDelay ?? 500, + }; + } + + /** Register a handler for a named job type. */ + register(name: string, handler: JobHandler): void { + this.handlers.set(name, handler as JobHandler); + } + + /** Enqueue a new job and start processing. */ + enqueue(name: string, payload: T, maxAttempts?: number): Job { + const job: Job = { + id: nextId(), + name, + payload, + status: 'pending', + attempts: 0, + maxAttempts: maxAttempts ?? this.opts.maxAttempts, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + this.queue.push(job as Job); + this.tick(); + return job; + } + + /** Jobs currently waiting or running. */ + get pending(): Job[] { + return this.queue.filter((j) => j.status === 'pending' || j.status === 'running'); + } + + /** Jobs that exhausted all retry attempts. */ + get deadLetterQueue(): Job[] { + return [...this.deadLetter]; + } + + /** Re-enqueue a dead-letter job for another attempt. */ + retry(jobId: string): boolean { + const idx = this.deadLetter.findIndex((j) => j.id === jobId); + if (idx === -1) return false; + const [job] = this.deadLetter.splice(idx, 1); + job.status = 'pending'; + job.attempts = 0; + job.updatedAt = Date.now(); + this.queue.push(job); + this.tick(); + return true; + } + + private tick(): void { + while (this.running < this.opts.concurrency) { + const job = this.queue.find((j) => j.status === 'pending'); + if (!job) break; + job.status = 'running'; + job.updatedAt = Date.now(); + this.running++; + this.run(job).finally(() => { + this.running--; + this.tick(); + }); + } + } + + private async run(job: Job): Promise { + const handler = this.handlers.get(job.name); + if (!handler) { + job.status = 'failed'; + job.lastError = `No handler registered for job type "${job.name}"`; + job.updatedAt = Date.now(); + this.moveToDeadLetter(job); + return; + } + + for (let attempt = 1; attempt <= job.maxAttempts; attempt++) { + job.attempts = attempt; + job.updatedAt = Date.now(); + try { + await handler(job); + job.status = 'done'; + job.updatedAt = Date.now(); + this.removeFromQueue(job.id); + return; + } catch (err) { + job.lastError = err instanceof Error ? err.message : String(err); + if (attempt < job.maxAttempts) { + const backoff = this.opts.retryDelay * Math.pow(2, attempt - 1); + await delay(backoff); + } + } + } + + job.status = 'failed'; + job.updatedAt = Date.now(); + this.moveToDeadLetter(job); + } + + private removeFromQueue(id: string): void { + const idx = this.queue.findIndex((j) => j.id === id); + if (idx !== -1) this.queue.splice(idx, 1); + } + + private moveToDeadLetter(job: Job): void { + this.removeFromQueue(job.id); + this.deadLetter.push(job); + } +} + +/** Shared application-level queue instance. */ +export const taskQueue = new TaskQueue();