Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions public/service-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* Access Layer service worker — background sync for creator profile and
* portfolio endpoints (Issue #754).
*
* Intercepts failed GET requests during offline periods, queues them in
* IndexedDB, then replays them when connectivity is restored via the
* Background Sync API.
*/

const SYNC_TAG = 'api-retry';
const DB_NAME = 'accesslayer-query-cache';
const DB_VERSION = 1;
const QUERIES_STORE = 'queries';
const SYNC_STORE = 'sync-queue';
const MAX_QUEUE_AGE_MS = 60 * 60 * 1000; // 1 hour

const INTERCEPTED_PATHS = ['/api/creators/', '/api/wallet/'];

// ---------------------------------------------------------------------------
// IndexedDB helpers (duplicated from the main-thread adapter so the SW is
// self-contained and does not import ES modules).
// ---------------------------------------------------------------------------

function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = event => {
const db = event.target.result;
if (!db.objectStoreNames.contains(QUERIES_STORE)) {
db.createObjectStore(QUERIES_STORE, { keyPath: 'queryHash' });
}
if (!db.objectStoreNames.contains(SYNC_STORE)) {
db.createObjectStore(SYNC_STORE, { keyPath: 'id', autoIncrement: true });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}

async function enqueueRequest(url, method, body) {
try {
const db = await openDB();
await new Promise((resolve, reject) => {
const tx = db.transaction(SYNC_STORE, 'readwrite');
tx.objectStore(SYNC_STORE).add({ url, method, body: body ?? null, queuedAt: Date.now() });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {
// silently ignore — offline queuing is best-effort
}
}

async function drainQueue() {
try {
const db = await openDB();

const items = await new Promise((resolve, reject) => {
const req = db.transaction(SYNC_STORE, 'readonly').objectStore(SYNC_STORE).getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});

const now = Date.now();

for (const item of items) {
// Discard items older than 1 hour without replaying.
if (now - item.queuedAt > MAX_QUEUE_AGE_MS) {
await deleteQueueItem(db, item.id);
continue;
}

try {
const init = { method: item.method };
if (item.body) init.body = item.body;

const response = await fetch(item.url, init);
if (response.ok) {
const data = await response.json();
await updateQueryCache(db, item.url, data);
await deleteQueueItem(db, item.id);
}
} catch {
// Network still unavailable for this item — leave it queued.
}
}
} catch {
// silently ignore
}
}

async function deleteQueueItem(db, id) {
return new Promise(resolve => {
const tx = db.transaction(SYNC_STORE, 'readwrite');
tx.objectStore(SYNC_STORE).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
}

async function updateQueryCache(db, url, data) {
try {
const queryHash = url;
const entry = {
queryKey: [url],
data,
dataUpdatedAt: Date.now(),
queryHash,
};
await new Promise((resolve, reject) => {
const tx = db.transaction(QUERIES_STORE, 'readwrite');
tx.objectStore(QUERIES_STORE).put(entry);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {
// silently ignore
}
}

// ---------------------------------------------------------------------------
// Service Worker lifecycle
// ---------------------------------------------------------------------------

self.addEventListener('install', () => {
self.skipWaiting();
});

self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
});

// ---------------------------------------------------------------------------
// Fetch interception — queue failed requests to the tracked endpoints.
// ---------------------------------------------------------------------------

self.addEventListener('fetch', event => {
const { request } = event;
if (request.method !== 'GET') return;

const url = new URL(request.url);
const isTracked = INTERCEPTED_PATHS.some(p => url.pathname.startsWith(p));
if (!isTracked) return;

event.respondWith(
fetch(request).catch(async err => {
await enqueueRequest(request.url, request.method, null);

// Register background sync so the queue is drained once online.
if ('sync' in self.registration) {
try {
await self.registration.sync.register(SYNC_TAG);
} catch {
// Background Sync API not available — queue will drain on
// the next successful fetch.
}
}

throw err;
})
);
});

// ---------------------------------------------------------------------------
// Background Sync — drain the queue when connectivity is restored.
// ---------------------------------------------------------------------------

self.addEventListener('sync', event => {
if (event.tag === SYNC_TAG) {
event.waitUntil(drainQueue());
}
});
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useEffect } from 'react';
import { Toaster } from 'react-hot-toast';
import { createBrowserRouter, RouterProvider } from 'react-router';
import AppErrorBoundary from './components/common/AppErrorBoundary';
import OfflineBanner from './components/common/OfflineBanner';
import { routes } from './routes';
import { useRouteChangeLogging } from './hooks/useRouteChangeLogging';

Expand All @@ -26,6 +27,7 @@ function App() {

return (
<AppErrorBoundary>
<OfflineBanner />
<Toaster
toastOptions={{
ariaProps: {
Expand Down
17 changes: 17 additions & 0 deletions src/components/common/OfflineBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useOfflineStatus } from '@/hooks/useOfflineStatus';

export default function OfflineBanner() {
const isOffline = useOfflineStatus();

if (!isOffline) return null;

return (
<div
role="alert"
aria-live="polite"
className="fixed inset-x-0 top-0 z-[100] flex items-center justify-center bg-amber-500 px-4 py-2 text-center text-sm font-medium text-white"
>
You are offline &mdash; showing cached data
</div>
);
}
21 changes: 21 additions & 0 deletions src/components/common/StaleBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export const STALE_THRESHOLD_MS = 5 * 60 * 1000;

interface StaleBadgeProps {
dataUpdatedAt: number;
}

export default function StaleBadge({ dataUpdatedAt }: StaleBadgeProps) {
const isStale = Date.now() - dataUpdatedAt > STALE_THRESHOLD_MS;

if (!isStale) return null;

return (
<span
role="status"
aria-label="Data may be outdated"
className="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800"
>
Data may be outdated
</span>
);
}
20 changes: 20 additions & 0 deletions src/hooks/useOfflineStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';

export function useOfflineStatus(): boolean {
const [isOffline, setIsOffline] = useState(!navigator.onLine);

useEffect(() => {
const onOnline = () => setIsOffline(false);
const onOffline = () => setIsOffline(true);

window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);

return () => {
window.removeEventListener('online', onOnline);
window.removeEventListener('offline', onOffline);
};
}, []);

return isOffline;
}
132 changes: 132 additions & 0 deletions src/lib/__tests__/conflictResolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest';
import { resolveConflict } from '@/lib/conflictResolution';
import type { ConflictEntry } from '@/lib/conflictResolution';

function entry(data: Record<string, unknown>, updatedAt: number): ConflictEntry {
return { data, dataUpdatedAt: updatedAt };
}

describe('resolveConflict', () => {
describe('when server data is newer', () => {
it('returns server data when there are no optimistic fields', () => {
const local = entry({ name: 'Alice', score: 10 }, 1000);
const server = entry({ name: 'Alice', score: 20 }, 2000);

const result = resolveConflict(local, server);

expect(result).toEqual(server);
});

it('preserves optimistic fields from local entry', () => {
const local = entry({ name: 'Alice', positions: [{ id: 1 }] }, 1000);
const server = entry({ name: 'Alice Updated', positions: [] }, 2000);

const result = resolveConflict(local, server, {
optimisticFields: ['positions'],
});

expect(result.data.name).toBe('Alice Updated');
expect(result.data.positions).toEqual([{ id: 1 }]);
expect(result.dataUpdatedAt).toBe(2000);
});

it('preserves multiple optimistic fields', () => {
const local = entry(
{ title: 'Old', positions: [1, 2], balance: 99 },
500
);
const server = entry(
{ title: 'New', positions: [], balance: 50 },
1500
);

const result = resolveConflict(local, server, {
optimisticFields: ['positions', 'balance'],
});

expect(result.data.title).toBe('New');
expect(result.data.positions).toEqual([1, 2]);
expect(result.data.balance).toBe(99);
});

it('ignores an optimistic field that does not exist in local data', () => {
const local = entry({ name: 'Alice' }, 1000);
const server = entry({ name: 'Bob', score: 5 }, 2000);

const result = resolveConflict(local, server, {
optimisticFields: ['score'],
});

expect(result.data.score).toBe(5);
expect(result.data.name).toBe('Bob');
});

it('stamps the result with the server dataUpdatedAt', () => {
const local = entry({ a: 1 }, 1000);
const server = entry({ a: 2 }, 3000);

const result = resolveConflict(local, server, {
optimisticFields: ['a'],
});

expect(result.dataUpdatedAt).toBe(3000);
});
});

describe('when local data is newer or equal', () => {
it('returns the local entry when local is newer', () => {
const local = entry({ name: 'Alice', score: 50 }, 3000);
const server = entry({ name: 'Alice', score: 10 }, 1000);

const result = resolveConflict(local, server);

expect(result).toEqual(local);
});

it('returns the local entry when timestamps are equal', () => {
const local = entry({ value: 'local' }, 1000);
const server = entry({ value: 'server' }, 1000);

const result = resolveConflict(local, server);

expect(result).toEqual(local);
});

it('ignores optimistic fields when local is newer', () => {
const local = entry({ positions: [1, 2, 3] }, 5000);
const server = entry({ positions: [] }, 2000);

const result = resolveConflict(local, server, {
optimisticFields: ['positions'],
});

expect(result.data.positions).toEqual([1, 2, 3]);
expect(result.dataUpdatedAt).toBe(5000);
});
});

describe('edge cases', () => {
it('handles empty optimistic fields array the same as no options', () => {
const local = entry({ x: 1 }, 1000);
const server = entry({ x: 2 }, 2000);

const withEmpty = resolveConflict(local, server, { optimisticFields: [] });
const withDefault = resolveConflict(local, server);

expect(withEmpty).toEqual(withDefault);
});

it('does not mutate the input entries', () => {
const local = entry({ a: 1, b: 2 }, 1000);
const server = entry({ a: 10, b: 20 }, 2000);

const localCopy = JSON.parse(JSON.stringify(local)) as ConflictEntry;
const serverCopy = JSON.parse(JSON.stringify(server)) as ConflictEntry;

resolveConflict(local, server, { optimisticFields: ['a'] });

expect(local).toEqual(localCopy);
expect(server).toEqual(serverCopy);
});
});
});
Loading
Loading