@periodic/tungsten-client
v1.0.0
Published
Framework-agnostic browser authentication engine with deterministic state management and TypeScript support
Maintainers
Readme
🔐 Periodic Tungsten Client
Framework-agnostic browser authentication engine with deterministic state management and TypeScript support
Part of the Periodic series of Node.js packages by Uday Thakur.
💡 Why Tungsten Client?
@periodic/tungsten-client is the browser-side companion to @periodic/tungsten. While @periodic/tungsten handles the server-side cryptographic primitives — signing tokens, hashing passwords, verifying HMAC signatures — this library handles the client-side lifecycle: storing tokens, refreshing them before they expire, injecting them into requests, and synchronizing auth state across browser tabs.
Most frontend authentication implementations are hand-rolled, inconsistent, and fragile. A token refresh races with a concurrent request. A tab logs out but the other tab doesn't notice. An expired token reaches the server because nobody was watching the clock. Tungsten Client eliminates all of it with a deterministic state machine that makes illegal auth states impossible to represent.
The name represents:
- Determinism: Every auth state transition is explicit and inspectable — no undefined behaviour
- Resilience: Token refresh races, concurrent requests, and tab synchronization are handled automatically
- Transparency: Every state change fires an event — full observability without polling
- Minimalism: Zero dependencies, storage-agnostic, framework-agnostic
Just as @periodic/tungsten handles server-side authentication without shortcuts, @periodic/tungsten-client handles client-side authentication without magic — explicit, auditable, and correct.
🎯 Why Choose Tungsten Client?
Browser authentication is surprisingly easy to get wrong — and most implementations have at least one of these bugs:
- Token refresh races — two concurrent requests both try to refresh, one succeeds, one gets a 401
- No tab synchronization — logout in one tab leaves other tabs authenticated
- Expired tokens reaching the server — nobody was watching the clock before sending requests
- Inconsistent loading states —
isLoadingis set in three different places with three different timings - No typed errors — auth failures are caught as
unknownwithinstanceofguessing - No storage abstraction — switching from
localStoragetosessionStoragerequires rewriting the integration
Periodic Tungsten Client provides the perfect solution:
✅ Zero dependencies — pure TypeScript, no runtime dependencies
✅ Framework-agnostic — works with React, Vue, Svelte, Angular, or no framework
✅ Deterministic State Machine — every auth state transition is explicit and typed
✅ Automatic Token Refresh — proactive refresh before expiry, with race protection
✅ Fetch Wrapper — auth headers injected automatically on every request
✅ Multi-Tab Synchronization — logout in one tab propagates to all tabs instantly
✅ Storage Abstraction — swap localStorage, sessionStorage, or custom storage
✅ Event System — subscribe to every state transition without polling
✅ Type-safe — strict TypeScript, zero any, throughout
✅ No global state — multiple instances in the same app are fully isolated
✅ Production-ready — non-blocking, never crashes your app
📦 Installation
npm install @periodic/tungsten-clientOr with yarn:
yarn add @periodic/tungsten-client🚀 Quick Start
import { TungstenClient } from '@periodic/tungsten-client';
// 1. Create a client
const client = new TungstenClient({
refreshEndpoint: '/api/auth/refresh',
onStateChange: (state) => {
console.log('Auth state:', state.status);
},
});
// 2. Login — store tokens and transition to authenticated
client.login({
accessToken: 'eyJhbGciOiJIUzI1NiJ9...',
refreshToken: 'rt_01HQ4K2N...',
});
// 3. Make authenticated requests — token injected automatically
const response = await client.fetch('/api/me');
const user = await response.json();
// 4. Logout — clear tokens, notify other tabs
client.logout();Auth state after login:
{
"status": "authenticated",
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"expiresAt": 1708000900000,
"authenticatedAt": 1708000000000
}🧠 Core Concepts
The TungstenClient Class
TungstenClientis the single entry point — one instance manages the entire auth lifecycle- Holds the current auth state, manages token storage, drives the refresh cycle, and wraps
fetch - No global instance — create one per app context, safe for SSR and multi-tenant apps
- Accepts a storage adapter — defaults to
localStorage, swap to anything
const client = new TungstenClient({
refreshEndpoint: '/api/auth/refresh',
storage: localStorage, // or sessionStorage, or your own adapter
refreshThresholdMs: 60_000, // refresh 60s before expiry
onStateChange: (state) => updateUIState(state),
});The Auth State Machine
- Every auth state is a named, typed value — no boolean soup (
isLoading && !isError && !!user) - Illegal transitions are impossible — you cannot go from
unauthenticatedtorefreshing - The state machine drives the UI — your components react to state, they don't manage it
Design principle:
The client holds exactly one auth state at any moment. Every action either transitions to a new state or is rejected. Nothing happens outside the machine.
unauthenticated → authenticating → authenticated ⟶ refreshing → authenticated
↘ expired
↘ error
authenticated → unauthenticated (logout)Auth States
| State | Meaning |
|-------|---------|
| unauthenticated | No tokens — user is logged out |
| authenticating | Login in progress |
| authenticated | Valid access token present |
| refreshing | Access token expired — refresh in progress |
| expired | Refresh failed or refresh token expired |
| error | Unrecoverable auth error |
✨ Features
🔄 Automatic Token Refresh
The client watches the access token expiry and refreshes proactively — before the token actually expires, not after the next request fails:
const client = new TungstenClient({
refreshEndpoint: '/api/auth/refresh',
refreshThresholdMs: 60_000, // refresh when < 60s remaining (default)
});
// Refresh happens automatically in the background
// client.fetch() waits for refresh to complete before sending🌐 Authenticated Fetch Wrapper
Every request through client.fetch() gets the current access token injected — no manual header management:
// Before: manual, inconsistent, error-prone
const res = await fetch('/api/orders', {
headers: { Authorization: `Bearer ${getTokenSomehow()}` },
});
// After: automatic, consistent, always fresh
const res = await client.fetch('/api/orders');If a refresh is in progress when client.fetch() is called, it waits for the refresh to complete before sending — eliminating token refresh races.
🗂️ Multi-Tab Synchronization
Logout in one tab propagates to all open tabs immediately via the storage event:
// Tab A
client.logout(); // clears tokens, fires storage event
// Tab B — automatically receives the event
// onStateChange fires with { status: 'unauthenticated' }
// No polling, no extra setup💾 Storage Abstraction
Swap the storage backend without changing any other code:
// localStorage (default)
new TungstenClient({ refreshEndpoint: '...', storage: localStorage });
// sessionStorage — tokens cleared on tab close
new TungstenClient({ refreshEndpoint: '...', storage: sessionStorage });
// Custom adapter — any object with getItem/setItem/removeItem
new TungstenClient({
refreshEndpoint: '...',
storage: {
getItem: (key) => mySecureStorage.get(key),
setItem: (key, value) => mySecureStorage.set(key, value),
removeItem: (key) => mySecureStorage.delete(key),
},
});📡 Event System
Subscribe to every state transition — drive your UI reactively without polling:
client.on('stateChange', (state) => {
switch (state.status) {
case 'authenticated': showApp(); break;
case 'unauthenticated': showLoginPage(); break;
case 'refreshing': showLoadingIndicator(); break;
case 'expired': showSessionExpiredModal(); break;
case 'error': showErrorBanner(state.error); break;
}
});📚 Common Patterns
1. Vanilla TypeScript Setup
import { TungstenClient } from '@periodic/tungsten-client';
export const authClient = new TungstenClient({
refreshEndpoint: '/api/auth/refresh',
refreshThresholdMs: 60_000,
onStateChange: (state) => {
document.dispatchEvent(new CustomEvent('auth:stateChange', { detail: state }));
},
});
// Login after form submit
async function handleLogin(email: string, password: string) {
const res = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
headers: { 'Content-Type': 'application/json' },
});
const { accessToken, refreshToken } = await res.json();
authClient.login({ accessToken, refreshToken });
}2. React Integration
import { createContext, useContext, useEffect, useState } from 'react';
import { TungstenClient, AuthStateData } from '@periodic/tungsten-client';
const client = new TungstenClient({ refreshEndpoint: '/api/auth/refresh' });
const AuthContext = createContext<AuthStateData | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [authState, setAuthState] = useState<AuthStateData>(client.getState());
useEffect(() => {
return client.on('stateChange', setAuthState);
}, []);
return <AuthContext.Provider value={authState}>{children}</AuthContext.Provider>;
}
export const useAuth = () => useContext(AuthContext)!;3. Making Authenticated API Calls
// All requests through client.fetch() are automatically authenticated
const orders = await authClient.fetch('/api/orders').then(r => r.json());
// POST with body
const order = await authClient.fetch('/api/orders', {
method: 'POST',
body: JSON.stringify({ productId: 'prod_123', quantity: 2 }),
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json());4. Handling Session Expiry
authClient.on('stateChange', (state) => {
if (state.status === 'expired') {
// Clear any app state that depends on the user
clearUserCache();
// Redirect to login with return URL
router.push(`/login?returnTo=${encodeURIComponent(location.pathname)}`);
}
});5. Custom Storage Adapter
import { TungstenClient } from '@periodic/tungsten-client';
// Cookie-based storage (for SSR-compatible auth)
const cookieStorage = {
getItem: (key: string) => getCookie(key) ?? null,
setItem: (key: string, value: string) => setCookie(key, value, { secure: true, sameSite: 'strict' }),
removeItem: (key: string) => deleteCookie(key),
};
const client = new TungstenClient({
refreshEndpoint: '/api/auth/refresh',
storage: cookieStorage,
});6. With @periodic/tungsten Server
// Server: @periodic/tungsten signs the tokens
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findOne({ email });
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) return res.status(401).json({ error: 'Invalid credentials' });
const accessToken = await signAccessToken({ sub: user.id }, { expiresIn: '15m', keyProvider });
const refreshToken = await signAccessToken({ sub: user.id, type: 'refresh' }, { expiresIn: '7d', keyProvider });
res.json({ accessToken, refreshToken });
});
// Client: @periodic/tungsten-client manages the lifecycle
const client = new TungstenClient({ refreshEndpoint: '/api/auth/refresh' });
const { accessToken, refreshToken } = await loginRequest(email, password);
client.login({ accessToken, refreshToken });7. Structured Logging Integration
import { createLogger, ConsoleTransport, JsonFormatter } from '@periodic/iridium';
import { TungstenClient } from '@periodic/tungsten-client';
const logger = createLogger({
transports: [new ConsoleTransport({ formatter: new JsonFormatter() })],
});
const client = new TungstenClient({
refreshEndpoint: '/api/auth/refresh',
onStateChange: (state) => {
logger.info('tungsten.auth.state_change', { status: state.status });
},
});8. Production Configuration
import { TungstenClient } from '@periodic/tungsten-client';
const isDevelopment = process.env.NODE_ENV === 'development';
export const authClient = new TungstenClient({
refreshEndpoint: '/api/auth/refresh',
refreshThresholdMs: isDevelopment ? 5_000 : 60_000,
storage: isDevelopment ? sessionStorage : localStorage,
onStateChange: (state) => {
if (state.status === 'error') {
Sentry.captureException(state.error);
}
if (state.status === 'expired') {
logger.warn('tungsten.session_expired');
}
},
});
export default authClient;🎛️ Configuration Options
TungstenClient Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| refreshEndpoint | string | required | URL to call for token refresh |
| storage | StorageAdapter | localStorage | Storage backend for tokens |
| refreshThresholdMs | number | 60_000 | Refresh when this many ms remain before expiry |
| onStateChange | (state: AuthStateData) => void | — | Shorthand state change callback |
StorageAdapter Interface
interface StorageAdapter {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}AuthTokens
interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresAt?: number; // Unix ms — inferred from JWT exp if not provided
}📋 API Reference
TungstenClient
new TungstenClient(options: TungstenClientOptions): TungstenClient
client.login(tokens: AuthTokens): void
client.logout(): void
client.fetch(url: string, init?: RequestInit): Promise<Response>
client.getState(): AuthStateData
client.on(event: 'stateChange', handler: (state: AuthStateData) => void): () => voidTypes
import type {
AuthState,
AuthTokens,
AuthStateData,
StorageAdapter,
TungstenClientOptions,
} from '@periodic/tungsten-client';🧩 Architecture
@periodic/tungsten-client/
├── src/
│ ├── client.ts # TungstenClient class — main entry point
│ ├── stateMachine.ts # Deterministic auth state transitions
│ ├── tokenManager.ts # Token storage, parsing, expiry tracking
│ ├── refreshEngine.ts # Proactive refresh + race protection
│ ├── fetchWrapper.ts # Authenticated fetch with refresh wait
│ ├── tabSync.ts # Multi-tab synchronization via storage events
│ ├── eventEmitter.ts # Typed event system
│ ├── types.ts # All shared TypeScript interfaces
│ └── index.ts # Public APIDesign Philosophy:
- State machine is the source of truth — the UI reacts to state, it never sets state directly
- Refresh engine is proactive — tokens are refreshed before they expire, not after a 401
- Fetch wrapper queues requests — concurrent requests during a refresh wait, they don't race
- Tab sync is passive — uses
storageevents, no polling, no WebSockets - No global state — every instance is isolated, safe for SSR and testing
📈 Performance
- Proactive refresh — no failed requests due to expired tokens in normal operation
- Race protection — concurrent refresh attempts are coalesced into a single request
- Passive tab sync — storage events are native browser push, zero polling overhead
- Zero dependencies — no additional bundle weight beyond the library itself
- Tree-shakeable — unused exports are excluded from your bundle
🚫 Explicit Non-Goals
This package intentionally does not include:
❌ Server-side token signing or verification (use @periodic/tungsten)
❌ React hooks or components (adapt the event system to your framework)
❌ OAuth / OpenID Connect flows — bring your own login UI
❌ Token introspection or JWK fetching
❌ Automatic retry on 401 beyond the refresh flow
❌ Magic or implicit behavior on import
❌ Configuration files (configure in code)
Focus on doing one thing well: deterministic, race-safe, framework-agnostic browser authentication state management.
🎨 TypeScript Support
Full TypeScript support with complete type safety:
import type {
AuthState,
AuthTokens,
AuthStateData,
StorageAdapter,
TungstenClientOptions,
} from '@periodic/tungsten-client';
// AuthStateData is a discriminated union — narrow by status
const state = client.getState();
if (state.status === 'authenticated') {
state.accessToken; // string — only present in this branch
state.expiresAt; // number — only present in this branch
}
// on() returns an unsubscribe function — fully typed
const unsubscribe = client.on('stateChange', (state: AuthStateData) => {
console.log(state.status);
});
unsubscribe(); // clean up🧪 Testing
# Run tests
npm test
# Run tests with coverage
npm run test:coverage
# Run tests in watch mode
npm run test:watchNote: All tests achieve >80% code coverage.
🤝 Related Packages
Part of the Periodic series by Uday Thakur:
- @periodic/tungsten - Server-side authentication primitives (JWT, Argon2, TOTP)
- @periodic/iridium - Structured logging
- @periodic/arsenic - Semantic runtime monitoring
- @periodic/zirconium - Environment configuration
- @periodic/vanadium - Idempotency and distributed locks
- @periodic/strontium - Resilient HTTP client
- @periodic/obsidian - HTTP error handling
- @periodic/titanium - Rate limiting
- @periodic/osmium - Redis caching
Build complete, production-ready APIs with the Periodic series!
📖 Documentation
🛠️ Production Recommendations
Environment Variables
NODE_ENV=production
NEXT_PUBLIC_API_URL=https://api.example.comLog Aggregation
Pair with @periodic/iridium for structured JSON output:
import { createLogger, ConsoleTransport, JsonFormatter } from '@periodic/iridium';
const logger = createLogger({
transports: [new ConsoleTransport({ formatter: new JsonFormatter() })],
});
const client = new TungstenClient({
refreshEndpoint: '/api/auth/refresh',
onStateChange: (state) => {
logger.info('tungsten.auth.state_change', { status: state.status });
if (state.status === 'error') {
logger.error('tungsten.auth.error', { error: state.error?.message });
}
},
});
// Pipe to Elasticsearch, Datadog, CloudWatch, etc.Error Monitoring
const client = new TungstenClient({
refreshEndpoint: '/api/auth/refresh',
onStateChange: (state) => {
if (state.status === 'error') {
Sentry.captureException(state.error, { extra: { authStatus: state.status } });
}
},
});📝 License
MIT © Uday Thakur
🙏 Contributing
Contributions are welcome! Please read CONTRIBUTING.md for details on:
- Code of conduct
- Development setup
- Pull request process
- Coding standards
- Architecture principles
📞 Support
- 📧 Email: [email protected]
- 🐛 Issues: GitHub Issues
- 💬 Discussions: GitHub Discussions
🌟 Show Your Support
Give a ⭐️ if this project helped you build better applications!
Built with ❤️ by Uday Thakur for production-grade Node.js applications
