@presencelearning/remote-config
Signal-based remote configuration and feature flags. Wraps ConfigCat (or localStorage, or an in-memory map) behind one provider, and gives you a Zod-typed accessor so flags are checked at compile time.
Current version: 0.2.2. Single entry point — everything is imported from @presencelearning/remote-config.
Features
Section titled “Features”- Signal-based — every flag is an Angular signal, so templates react without subscriptions.
- Typed via Zod — declare a schema once, get typed signals per key.
- Swappable strategies — ConfigCat in production, localStorage for local dev, in-memory for tests and Storybook.
- Loading and error state —
isLoadinganderrorsignals surface strategy and parse failures. - User targeting —
identifyUser()feeds identity to ConfigCat’s targeting rules. - SSR-safe — all browser access is guarded by
isPlatformBrowser.
Installation
Section titled “Installation”npm install @presencelearning/remote-configBoth heavyweight dependencies are optional peers — install only what you use:
npm install @configcat/sdk # required for withConfigCatnpm install zod # required for zodRemoteConfigPeer dependencies
Section titled “Peer dependencies”@angular/core>=18.0.0 <22.0.0@angular/common>=18.0.0 <22.0.0@configcat/sdk>=1.0.2 <2(optional)zod>=4.0.0(optional)
Quick start
Section titled “Quick start”1. Register a strategy
Section titled “1. Register a strategy”import { ApplicationConfig } from '@angular/core';import { provideRemoteConfig, withConfigCat } from '@presencelearning/remote-config';
export const appConfig: ApplicationConfig = { providers: [provideRemoteConfig(withConfigCat({ sdkKey: environment.configCatKey }))],};2. Declare a schema
Section titled “2. Declare a schema”import { zodRemoteConfig } from '@presencelearning/remote-config';import { z } from 'zod';
const AppSchema = z.object({ showNewDashboard: z.boolean().default(false), maxUploadMb: z.number().default(10), maintenanceBanner: z.string().default(''),});
export const injectAppConfig = zodRemoteConfig(AppSchema);All schema fields must have
.default()or be.optional(). If a required field has no default, DI throws at injection time with a descriptive error naming the offending fields.
Call zodRemoteConfig() once at module level; call the returned accessor inside an injection context.
3. Read flags in components
Section titled “3. Read flags in components”@Component({ template: ` @if (rc.showNewDashboard()) { <app-new-dashboard /> } <p>Max upload: {{ rc.maxUploadMb() }} MB</p> `,})export class DashboardComponent { protected readonly rc = injectAppConfig();}Strategies
Section titled “Strategies”withConfigCat
Section titled “withConfigCat”provideRemoteConfig( withConfigCat({ sdkKey: 'your-sdk-key', pollIntervalSeconds: 60, // default logLevel: 'error', // 'warn' | 'error' (default) }));Auto-polls, refreshes on ConfigCat’s configChanged hook, and disposes the client on destroy. Strategy failures land in the error signal as { kind: 'strategy', cause }.
User identification timing
Section titled “User identification timing”ConfigCat emits anonymous/default values immediately after SDK init, then a second set of values once identifyUser() has run. Call identifyUser as early as possible (right after the OIDC token resolves), and gate on isLoading wherever a flag must never briefly show its anonymous value.
withLocalStorage
Section titled “withLocalStorage”provideRemoteConfig(withLocalStorage());Reads a JSON object from the presence.remoteConfig key (exported as REMOTE_CONFIG_STORAGE_KEY). isLoading is always false and error always null. It listens for cross-tab storage events and installs a devtools global:
window.__remoteConfig.getConfig();window.__remoteConfig.setConfig((current) => ({ ...current, showNewDashboard: true }));setConfig takes an updater function, not an object.
withInMemory
Section titled “withInMemory”// Plain objectprovideRemoteConfig(withInMemory({ showNewDashboard: true }));
// Writable signal — useful in Storybook or feature harnessesconst flags = signal<Record<string, unknown>>({ showNewDashboard: false });provideRemoteConfig(withInMemory(flags));// Later:flags.set({ showNewDashboard: true });Untyped access
Section titled “Untyped access”When you don’t want a schema, inject the raw service directly:
import { UntypedRemoteConfig } from '@presencelearning/remote-config';
const rc = inject(UntypedRemoteConfig);
rc.config; // Signal<Record<string, unknown>>rc.config(); // Record<string, unknown> — all raw valuesrc.get('someFlag'); // Signal<unknown>rc.get('someFlag')(); // unknown — current valuerc.get<boolean>('flag')(); // boolean — cast via genericrc.isLoading(); // booleanrc.error(); // RemoteConfigError | nullrc.identifyUser(user);Reserved key names
Section titled “Reserved key names”get, config, isLoading, error, and identifyUser do not get a direct signal property on the typed accessor, because they collide with the accessor’s own members. Reach them through the method form instead:
rc.get('config')(); // ✓rc.config; // ✗ — this is the accessor's own config signalRenaming the flag remotely is the better fix.
Testing
Section titled “Testing”TestBed.configureTestingModule({ providers: [provideRemoteConfig(withInMemory({ showNewDashboard: true }))],});
const rc = TestBed.runInInjectionContext(() => injectAppConfig());expect(rc.showNewDashboard()).toBe(true);API reference
Section titled “API reference”| Export | Kind | Description |
| ------------------------------ | -------- | --------------------------------------------------------------------------------------------------------- |
| provideRemoteConfig(feature) | function | EnvironmentProviders — registers the service and wires the chosen strategy |
| withConfigCat(options) | function | ConfigCat strategy. Options: sdkKey, pollIntervalSeconds (default 60), logLevel (default 'error') |
| withLocalStorage() | function | localStorage strategy with cross-tab sync and devtools global |
| withInMemory(overrides?) | function | In-memory strategy. Accepts a plain object or a Signal |
| REMOTE_CONFIG_STORAGE_KEY | const | 'presence.remoteConfig' |
| UntypedRemoteConfig | service | Raw signal access — config, isLoading, error, get(key), identifyUser(user) |
| zodRemoteConfig(schema) | function | Returns an injection helper for a typed accessor |
| RemoteConfigUser | type | { id, email?, custom? } |
| ConfigCatOptions | type | Options for withConfigCat |
| RemoteConfigError | type | { kind: 'strategy' \| 'parse', cause } |
| RemoteConfigFeature | type | Return type of the with* helpers |
| TypedRemoteConfig<T> | type | Shape of the object zodRemoteConfig’s accessor returns |
TypedRemoteConfig members
Section titled “TypedRemoteConfig members”| Member | Type | Description |
| ----------------------- | ----------------------------------- | -------------------------------------------------- |
| rc.someKey | Signal<T[key]> | Direct signal for each schema key |
| rc.get('key') | Signal<T[key]> | Method form |
| rc.config | Signal<z.infer<T>> | All parsed values as one object |
| rc.isLoading | Signal<boolean> | Delegates to the active strategy |
| rc.error | Signal<RemoteConfigError \| null> | Strategy error merged with Zod parse error |
| rc.identifyUser(user) | void | Passes user to the strategy for personalised flags |
On a Zod parse failure the accessor falls back to the schema defaults and exposes { kind: 'parse', cause } — flags degrade rather than throw.
Example app
Section titled “Example app”A standalone Angular app under packages/remote-config/example exercises the full public API — env-based strategy selection, the typed accessor, UntypedRemoteConfig, loading and error states, and feature-gating patterns. It runs on withLocalStorage() out of the box.
cd packages/remote-config/examplenpm run setup # builds + packs the package, then installs itnpm run devSource: packages/remote-config