@ryadavwebdev/core
v0.2.0
Published
Shared services, interceptors, guards, theming and models for the portfolio ecosystem.
Readme
@ryadavwebdev/core
Cross-app Angular services shared across the portfolio ecosystem (portfolio,
folio, design-system): auth client, HTTP interceptors, route guards, theming
and the shared models.
Cross-app only — anything that knows about a specific domain ("forms", "boards")
belongs in the app, not here. Everything public is exported from
src/public-api.ts; nothing is deep-imported.
Angular 22, standalone, signal-first, zoneless-safe.
1. Install
npm i @ryadavwebdev/corePeer dependencies (you almost certainly have them already): @angular/common,
@angular/core, @angular/router, rxjs.
2. Wire it up
One call in app.config.ts supplies the API base URL, and the interceptors are
registered on provideHttpClient:
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideCore, authInterceptor, errorInterceptor } from '@ryadavwebdev/core';
import { environment } from '../environments/environment';
export const appConfig: ApplicationConfig = {
providers: [
provideCore({ apiBaseUrl: environment.apiBaseUrl }),
provideHttpClient(withInterceptors([errorInterceptor, authInterceptor])),
],
};The interceptor order is not cosmetic.
errorInterceptormust come first, so it sits outermost and is the last to see a failure. Reverse them andauthInterceptornever sees the rawHttpErrorResponsebehind a 401 — it gets an already-normalisedApiErrorand the silent token refresh stops working.
provideCore options:
| Option | Required | Default | Meaning |
| --------------- | -------- | --------- | --------------------------------------------------------------------- |
| apiBaseUrl | yes | — | API root, e.g. http://localhost:3000/api. Trailing / stripped. |
| storagePrefix | no | 'folio' | Namespaces the localStorage keys so apps on one origin don't clash. |
3. Auth
AuthService holds the session as signals and returns observables from the HTTP
calls, because callers compose them (switchMap, retry, cancel).
const auth = inject(AuthService);
auth.currentUser(); // Signal<User | null>
auth.isAuthenticated(); // Signal<boolean>
auth.roles(); // Signal<readonly string[]>
auth.hasRole('admin');
auth.login({ email, password }).subscribe(/* … */);
auth.register({ name, email, password });
auth.refresh();
auth.logout();Two behaviours worth knowing:
- Concurrent 401s trigger exactly one refresh. The in-flight request is
shared via
shareReplay, so ten parallel calls failing at once produce one/auth/refresh, not ten. - A rejected refresh token clears the session locally, so the app can't sit in a half-authenticated state.
TokenStorage persists the session in localStorage behind a service, so tests
can swap it and a non-browser context degrades to an in-memory session rather
than throwing. localStorage is also wrapped in try/catch — it throws in some
privacy modes and sandboxed iframes.
Guards
{ path: 'dashboard', canActivate: [authGuard], /* … */ }
{ path: 'admin', canActivate: [authGuard, roleGuard('admin')], /* … */ }authGuard redirects to /login with a returnUrl query param so the app can
send the user back. roleGuard(...roles) is a factory and redirects to
/forbidden.
4. Errors
errorInterceptor normalises every failure into an ApiError, so components and
stores never branch on HttpErrorResponse internals:
class ApiError extends Error {
readonly code: string; // 'network_error' | 'unexpected_error' | whatever the API sent
readonly status: number; // 0 for network/CORS/offline
readonly details?: unknown;
get isNetworkError(): boolean; // status === 0
}It reads the ecosystem's standard failure shape, { error: { code, message } },
and falls back to unexpected_error for anything that doesn't match.
5. Theming
The visual layer lives in @ryadavwebdev/ui/styles/tokens.css; this package owns
the state. Every themed token is declared with CSS light-dark(), so flipping
data-theme on <html> re-resolves all of them at once.
const theme = inject(ThemeService);
theme.theme(); // Signal<'light' | 'dark' | 'system'> — what the user chose
theme.resolved(); // Signal<'light' | 'dark'> — what will actually paint
theme.isDark(); // Signal<boolean>
theme.toggle();
theme.set('dark');
theme.useSystem();ThemeService tracks the OS preference live via matchMedia, and tears the
listener down through DestroyRef.
For use before Angular boots, the same logic is available as plain framework-free
functions: setTheme, getStoredTheme, applyStoredTheme, resolveTheme,
THEME_STORAGE_KEY.
Preventing a flash of the wrong theme.
setThemesetscolor-schemeas an inline style as well as thedata-themeattribute, and both are required. Angular's critical-CSS inliner drops:root[data-theme="dark"]rules from the inlined block — they match nothing in the served HTML, because Angular hasn't run yet — so they arrive with the deferred stylesheet, after first paint. The attribute alone therefore still flashes. See the@ryadavwebdev/uiREADME for theindex.htmlsnippet.
6. Public API
| Export | Kind |
| ---------------------------------------------------------------------------------------------- | ------------------------ |
| provideCore, CORE_CONFIG, CoreConfig | Setup |
| AuthService, TokenStorage | Auth |
| authInterceptor, errorInterceptor | HTTP |
| authGuard, roleGuard | Routing |
| ThemeService | Theming |
| setTheme, getStoredTheme, applyStoredTheme, resolveTheme, THEME_STORAGE_KEY, Theme | Theming (framework-free) |
| ApiError, isApiErrorBody, ApiErrorBody | Models |
| AuthSession, AuthTokens, LoginRequest, RegisterRequest, User | Models |
Deep imports are not supported. The package exports map exposes the root entry
point only, so @ryadavwebdev/core/lib/... fails at build time by design.
7. Versioning
The public API is a semver contract:
| Change | Bump | | ----------------------------------------------------------------------------------------------- | --------- | | Adding an export, an optional input, a new optional config field | minor | | Removing or renaming an export; changing a type incompatibly; making an optional thing required | major | | Bug fix, docs, internal refactor with no API change | patch |
While the package is 0.x, a breaking change bumps the minor — that is the
standard 0.x convention, and it is why the API is not yet declared stable.
Bump projects/core/package.json before every publish. npm versions are
immutable: a published version can never be re-uploaded, only deprecated.
8. Build, test, publish
ng build core # → dist/core
ng test core --watch=false
cd dist/core && npm publish --access publicPublish from dist/core, never from projects/core — the built folder is the
real package (it has the FESM bundle, the flattened .d.ts and the generated
exports map).
