@jabraf/app-config
v0.2.3
Published
Application configuration and feature flags management for web projects
Maintainers
Readme
@jabraf/app-config
Application configuration and feature flags management for web projects.
Installation
npm install @jabraf/app-configreact is an optional peer dependency. Install it alongside @jabraf/app-config only if you intend to use the useFeature hook.
Overview
@jabraf/app-config is a lightweight configuration and feature-flag layer for Node.js and React applications. It provides:
- A universal runtime API (
setConfig/getConfig) that works identically in browser and Node environments — the consumer statically imports the generatedconfig/app-config.jsonand hands it to the library at bootstrap. - A Node-only
buildConfigentrypoint (under the@jabraf/app-config/buildsubpath) that delegates to a project-ownedconfig/make-config.tsso apps can produce their own configuration artifacts. - A React hook (
useFeature) plus cookie helpers (setFeatureCookie,removeFeatureCookie) for toggling features at runtime with cookie-based overrides. - A
jabraf-configCLI with abuildsubcommand (with an optional--watchmode) for generatingconfig/app-config.jsonfrom a project-ownedconfig/make-config.ts.
Entry points
| Import | Environment | Exports |
| ----------------------------------------- | --------------- | ------------------------------------------------------- |
| @jabraf/app-config | browser + Node | setConfig, getConfig, resetConfig, types |
| @jabraf/app-config/hooks/use-feature.js | browser (+ SSR) | useFeature, setFeatureCookie, removeFeatureCookie |
| @jabraf/app-config/build | Node only | buildConfig, appDirectory, fromRoot |
The root entry point and the React hooks subpath never import node:* modules and are safe to bundle for the browser.
Project Layout
The package resolves paths relative to the nearest package.json of the consuming project and expects the following files under a config/ directory:
your-app/
└── config/
├── make-config.ts # Authored: build-time script invoked by buildConfig
└── app-config.json # Generated: application configuration consumed by getConfigmake-config.ts must export an async buildAppConfig function that receives the resolved environment and returns the AppConfig for that environment (or null if no config applies). buildConfig imports this module, invokes the function, validates the result, and writes config/app-config.json itself — your code does not need to touch the filesystem.
// config/make-config.ts
import type { AppConfig, BuildAppConfig, EnvironmentConfig } from '@jabraf/app-config';
const configByEnv: EnvironmentConfig<AppConfig> = {
dev: { env: 'dev', hostname: 'localhost:3000', features: { beta: true } },
test: { env: 'test', hostname: 'test.example.com', features: { beta: true } },
staging: { env: 'staging', hostname: 'staging.example.com', features: { beta: true } },
production: { env: 'production', hostname: 'example.com', features: { beta: false } },
};
export const buildAppConfig: BuildAppConfig = async (env) => configByEnv[env] ?? null;Because config/app-config.json is a build artifact rather than source, add it to your .gitignore:
# config build output from @jabraf/app-config
config/app-config.jsonFeature Type
Features are tracked through a global FeatureMap interface. The Feature string-literal type is derived as keyof FeatureMap. Register your app's features by augmenting FeatureMap from a declaration file in your project — do not redeclare Feature directly, as that would clash with the type shipped by this package and produce a Duplicate identifier 'Feature' error.
// app/types/features.d.ts
declare global {
interface FeatureMap {
beta: true;
'experimental-search': true;
'new-checkout': true;
}
}
export {};After augmenting, Feature resolves to 'beta' | 'experimental-search' | 'new-checkout' everywhere the package is consumed. If you don't augment FeatureMap, Feature is never and calls like isFeatureEnabled(...) won't type-check.
Usage
Bootstrap: register the config once
Statically import the generated config/app-config.json and hand it to setConfig at app startup. The same snippet works in a browser entry, a Node script, or an SSR server — there is no node:fs involved.
// app/setup.ts
import appConfig from '../config/app-config.json' with { type: 'json' };
import { setConfig } from '@jabraf/app-config';
setConfig(appConfig);TypeScript: make sure
"resolveJsonModule": trueis set in yourtsconfig.json(it is by default in@jabraf/dev/ Vite / Next.js).CommonJS bundlers / older Node: drop the
with { type: 'json' }assertion —import appConfig from '../config/app-config.json'is enough.First-time checkouts: since
config/app-config.jsonis git-ignored and generated bybuildConfig, the import may not resolve on a fresh clone. Either commit a placeholder{}, runbuildConfigbefore the type-checker, or annotate the import with// @ts-expect-error generated by buildConfig.
Read configuration
import { getConfig } from '@jabraf/app-config';
const config = getConfig();
console.log(config.env);
console.log(config.hostname);
console.log(config.features);getConfig returns whatever object was last registered with setConfig. It throws Error: app-config not set. Call setConfig(config) with your imported config/app-config.json at app startup. when called before bootstrap.
resetConfig() clears the registered config — primarily useful in tests.
Build configuration (Node only)
import { buildConfig } from '@jabraf/app-config/build';
await buildConfig();buildConfig:
- Registers the
tsxESM loader so the project-ownedconfig/make-config.tscan be imported at runtime without a separate compile step. - Dynamically imports
config/make-config.tsand reads itsbuildAppConfigexport. If the export is missing, it throwsError: export buildAppConfig method from config/make-config.ts. - Resolves the environment from
process.env.APP_ENV, falling back toprocess.env.NODE_ENV, then defaulting to'production'. - Invokes
buildAppConfig(env)and validates the result. If it returnsnull/undefined, it throwsApp Config could not be found. Make sure to return env specific config from buildAppConfig method in config/make-config.ts. - Writes the returned config to
config/app-config.jsonviafs.writeFileSyncusingJSON.stringify.
You typically don't call buildConfig directly — invoke it via the jabraf-config build CLI command instead (see CLI).
React: useFeature
Once setConfig has run, the useFeature hook reads features directly from the registered config:
// app/components/beta-banner.tsx
import { useFeature } from '@jabraf/app-config/hooks/use-feature.js';
function BetaBanner() {
const { isFeatureEnabled } = useFeature();
if (!isFeatureEnabled('beta')) return null;
return <div>Welcome to the beta!</div>;
}Cookies named feature__{feature} take precedence over the registered features, which makes runtime overrides (e.g., for QA or beta testers) easy.
Toggling features via cookies
import { setFeatureCookie, removeFeatureCookie } from '@jabraf/app-config';
setFeatureCookie('beta', true);
setFeatureCookie('beta', false);
removeFeatureCookie('beta');Both setFeatureCookie and removeFeatureCookie are no-ops in SSR environments where document is undefined.
API Reference
setConfig(config: AppConfig): void
Registers the application configuration. Call once at app bootstrap with the statically-imported config/app-config.json. Subsequent calls replace the registered config.
getConfig(): AppConfig
Returns the registered AppConfig reference. Throws Error: app-config not set. Call setConfig(config) with your imported config/app-config.json at app startup. when called before setConfig.
resetConfig(): void
Clears the registered config so that the next getConfig call throws again. Intended for tests.
buildConfig(importer?): Promise<void> — @jabraf/app-config/build
Node only. Registers the tsx ESM loader, dynamically imports config/make-config.ts from the project root, invokes its buildAppConfig(env) export, and writes the resolved config to config/app-config.json. The env is resolved from APP_ENV || NODE_ENV || 'production'. Throws when the export is missing or when buildAppConfig returns a nullish value. Accepts an optional Importer override — (specifier: string) => Promise<{ buildAppConfig: BuildAppConfig }> — primarily useful in tests.
useFeature() — @jabraf/app-config/hooks/use-feature.js
React hook. Returns { isFeatureEnabled: (feature: Feature) => boolean }. Resolves features from getConfig().features. The returned function checks a feature__{name} cookie first and falls back to the registered features. The function is memoized against the registered features reference.
setFeatureCookie(feature: Feature, enabled: boolean): void — @jabraf/app-config/hooks/use-feature.js
Writes feature__{feature}={enabled} with a one-year expiry and path=/. No-op when document is undefined.
removeFeatureCookie(feature: Feature): void — @jabraf/app-config/hooks/use-feature.js
Clears the cookie for feature by setting it with an expiry in the past. No-op when document is undefined.
Utilities — @jabraf/app-config/build (Node only)
appDirectory(): string— Resolves the directory of the nearestpackage.jsonto the current working directory. Memoized after the first call.fromRoot(...segments: string[]): string— Joins path segments againstappDirectory().
Types
type Environment = 'dev' | 'test' | 'staging' | 'production';
type EnvironmentConfig<T> = {
[key in Environment]: T;
};
type FeatureConfig<T extends string = Feature> = {
[key in T]: boolean;
};
type AppConfig = {
env: Environment;
hostname: string;
features: FeatureConfig;
};
type BuildAppConfig = (env: Environment) => Promise<AppConfig | null>;CLI
The package ships a jabraf-config binary backed by commander.
# Print the usage hint
npx jabraf-config
# Build config/app-config.json from config/make-config.ts
npx jabraf-config build
# Build with an explicit environment
APP_ENV=staging npx jabraf-config build
# Build once, then watch config/ and rebuild on changes
npx jabraf-config build --watch
npx jabraf-config build -wjabraf-config build is a thin wrapper around buildConfig() from @jabraf/app-config/build. The default action (no subcommand) prints Use "jabraf-config build" to build the app config..
--watch / -w
Performs an initial build, then watches the project's config/ directory recursively and rebuilds whenever a source file changes. File-change bursts are debounced (≈100 ms) and writes to the generated config/app-config.json are ignored so the watcher does not retrigger itself. Overlapping change events are coalesced into a single follow-up rebuild while one is in flight. A failed rebuild logs the error and keeps the watcher alive — fix the underlying file and save again to recover.
Stop the watcher with Ctrl+C.
$ npx jabraf-config build --watch
Watching config/ for changes. Press Ctrl+C to exit.
Rebuilt app config.
Rebuilt app config.Consumer recipes
Standalone watch (no dev server)
Useful while iterating on config/make-config.ts itself or generating fixtures before running tests.
// package.json
{
"scripts": {
"config:build": "jabraf-config build",
"config:watch": "jabraf-config build --watch",
},
}npm run config:watchAlongside a Vite dev server
Run the watcher and the dev server in parallel so edits to config/make-config.ts regenerate config/app-config.json while Vite picks up the change via its own HMR. The watcher is debounced and skips its own output file, so it will not loop with the dev server's reloads.
Install concurrently (or npm-run-all) as a dev dependency:
npm install --save-dev concurrently// package.json
{
"scripts": {
"config:build": "jabraf-config build",
"config:watch": "jabraf-config build --watch",
"dev:vite": "vite",
"dev": "concurrently -k -n config,vite -c blue,green \"npm:config:watch\" \"npm:dev:vite\"",
},
}npm run devNotes for any dev server integration (Vite, Next.js, Remix, etc.):
- Run
jabraf-config buildonce before the dev server starts (e.g. via apredevscript or as the first step of a compositedevtask) so the statically importedconfig/app-config.jsonalways exists. - Treat
config/app-config.jsonas a build artifact: keep it in.gitignoreand let the watcher own it. The watcher ignores writes to that file specifically, so it will not rebuild in response to its own output. - Run the watcher in the foreground next to your dev server so its logs (
Rebuilt app config./Failed to rebuild app config:) are visible — failures keep the watcher running but the previous JSON output remains in place until the next successful rebuild. - Set
APP_ENV(or rely onNODE_ENV) the same way for both the one-shot build and the watcher to keep the generated config consistent:APP_ENV=dev concurrently ....
CI / one-shot builds
In CI or production builds, omit --watch so the command exits after a single build:
// package.json
{
"scripts": {
"prebuild": "jabraf-config build",
"build": "vite build",
},
}License
MIT © Jabran Rafique
