@meetkai/flags
v0.2.1
Published
Evaluation client for mka1-feature-flags: per-identity cached flag lookups with isOn/stringValue helpers.
Readme
@meetkai/flags
Evaluation client for mka1-feature-flags. Your service asks "what do the flags say for this user?" once, the answer is cached per user for a configurable TTL, and isOn / stringValue read from it. No admin operations: creating flags and editing rollouts stay with the API (or the @meetkai/mka1 SDK once its flags group is published).
Server-side only. Runs on Bun and Node 18+, has no dependencies, and uses the global fetch.
Install
Install from the public npm registry; no package-download token is required:
bun add @meetkai/flags # or: npm install @meetkai/flagsWhen migrating from GitHub Packages, remove the old @meetkai registry override
from your project and user .npmrc or bunfig.toml, or point it to
https://registry.npmjs.org. Regenerate the consumer lockfile so it resolves
@meetkai/flags from npm rather than retaining a GitHub Packages tarball URL.
Other private packages in the same scope may still need their existing registry
configuration.
Quick start
import { FlagsClient } from "@meetkai/flags";
const flags = new FlagsClient({
baseUrl: "https://apigw.mka1.com",
environment: "production",
apiKey: process.env.FLAGS_EVALUATION_KEY!, // the evaluation key generated for this environment
ttlMs: 30_000,
onError: (err) => console.warn("flags:", err.message),
});
// One request per user, then everything is read from the cached answer.
const ev = await flags.evaluate(userId);
if (ev.isOn("new-endpoint")) {
// 50% rollout, with overrides pinning specific users on or off
}
const provider = ev.stringValue("auth-provider", "google"); // "google" | "facebook"
console.log(ev.reason("auth-provider")); // "rule:0" | "rule:1" | "default" — which variant this user landed inapiKey is the environment-scoped bearer returned by the feature flags API's evaluation-key generation endpoint. It is not a platform or cluster-admin credential. The client requires it for every evaluation, including calls to a local server running in insecure-local mode. Keep it server-side and rotate or revoke it through the environment's admin API.
One-liners when you only need a single flag (they never throw; on failure they return the fallback):
await flags.isOn(userId, "new-endpoint"); // false on failure
await flags.stringValue(userId, "auth-provider", "google");
await flags.numberValue(userId, "search-limit", 50);
await flags.booleanValue(userId, "beta", false);
await flags.jsonValue(userId, "retry-policy", { retries: 3 });Rules that look at traits (plan, country, ...) need the traits sent along; each distinct trait set is cached separately:
await flags.evaluate(userId, { plan: "pro", country: "BR" });Always send a stable identity. An empty identity is evaluated as anonymous: no overrides, and every rollout below 100% answers false.
What the helpers return
| Helper | Returns the value when | Otherwise |
|---|---|---|
| isOn(key) | enabled is true | false |
| stringValue(key, fallback) | enabled and the value is a string | fallback |
| numberValue(key, fallback) | enabled and the value is a finite number | fallback |
| booleanValue(key, fallback) | enabled and the value is a boolean | fallback |
| jsonValue(key, fallback) | enabled and a value is present (no shape check) | fallback |
| reason(key) | always | undefined for an unknown flag |
A disabled flag returns the fallback even though the server still sends its value, so switching a flag off really switches the feature off.
Options
| Option | Default | Meaning |
|---|---|---|
| baseUrl | required | https://apigw.mka1.com, or http://localhost:8080 locally |
| environment | required | Which environment's flags to evaluate |
| apiKey | required | Environment-scoped evaluation key sent as Authorization: Bearer; required in every mode and kept server-side |
| ttlMs | 30000 | How long a cached evaluation is fresh. 0 refetches every call |
| maxStaleMs | 86400000 (24 h) | How long an expired entry may still be served when a refresh fails. 0 disables |
| timeoutMs | 2000 | Request timeout |
| cache | new MemoryCache() | Any FlagsCache (see below) |
| onError | none | Called once per failed request with a FlagsError (status, code, requestId, cause) |
| headers | none | Extra headers on every request; Authorization always comes from apiKey |
| fetch | globalThis.fetch | Replacement transport, mainly for tests |
MemoryCache takes { maxEntries } (default 10 000; the oldest entry is evicted first).
When the server is unreachable
evaluate serves the cache while it is fresh. When it is not, one request is made (concurrent callers for the same user share it). If that request fails — network error, timeout, non-2xx, malformed body — onError is called, then:
- if an expired entry exists and is younger than
ttlMs + maxStaleMs, it is returned withstale: true; - otherwise
evaluatethrows aFlagsError, and the one-liners return their fallback.
So a short outage keeps every user on their last known answer instead of flipping everyone to the fallback at once.
Rotating or revoking an evaluation key makes future requests with the old key fail, but it does not remove answers already stored in this client's cache. Fresh entries remain available until ttlMs; after a failed refresh, expired entries may remain available until ttlMs + maxStaleMs under the stale-on-error policy above. Clear or replace the cache if the application must discard those answers immediately.
Custom cache
Implement FlagsCache to share evaluations across processes. Every method may be sync or async. The client decides freshness from entry.storedAt and its own ttlMs; the store only needs to keep the entry around for ttlMs + maxStaleMs so stale-on-error still works.
import { cacheKey, type CacheEntry, type CacheSetOptions, type FlagsCache } from "@meetkai/flags";
class RedisCache implements FlagsCache {
constructor(private readonly redis: { get(k: string): Promise<string | null>; set(k: string, v: string, mode: "PX", ms: number): Promise<unknown>; del(k: string): Promise<unknown> }) {}
async get(key: string): Promise<CacheEntry | undefined> {
const raw = await this.redis.get("flags:" + key);
return raw === null ? undefined : (JSON.parse(raw) as CacheEntry);
}
async set(key: string, entry: CacheEntry, options: CacheSetOptions): Promise<void> {
await this.redis.set("flags:" + key, JSON.stringify(entry), "PX", options.ttlMs + options.maxStaleMs);
}
async delete(key: string): Promise<void> {
await this.redis.del("flags:" + key);
}
async clear(): Promise<void> {
// Scan and delete "flags:*", or use a key prefix per deployment.
}
}cacheKey(environment, identity, traits?) is exported so a custom store can compute the same keys the client uses (production:abc_123, or with traits production:abc_123:{"plan":"pro"}).
Releasing
Version 0.2.1 starts publication to public npm. Existing GitHub Packages releases
are retained, but new releases go to https://registry.npmjs.org.
Publishing uses GitHub OIDC with Node 24 and npm trusted publishing. No
NPM_TOKEN Actions secret is needed. In the npm package's trusted-publisher
settings, authorize these exact values:
| Field | Value |
| --- | --- |
| Provider | GitHub Actions |
| Organization | MeetKai |
| Repository | mka1-feature-flags |
| Workflow filename | publish-typescript-client.yml |
| Environment | Leave empty (the job does not use a GitHub environment) |
| Allowed actions | Enable direct publishing with npm publish |
Configure this before the workflow's first npm release. If the package does not exist on npm yet, an npm maintainer must first bootstrap it with an authenticated manual publish, then configure the trusted publisher. If that bootstrap uses 0.2.1, bump the package version before the next workflow release; the workflow will not overwrite it. The GitHub Packages publisher configuration does not transfer to npm. See npm trusted publishing for setup details. A local dry run checks packaging, but cannot validate the GitHub OIDC exchange or the npm-side trust configuration.
Versions on npm are immutable. To ship a change, bump version in package.json
and merge to main. The Publish TypeScript client workflow tests, builds, and
publishes with public access; it refuses to overwrite an existing version. The
workflow can also be dispatched manually. Never put publishing credentials in
package files or commit them to the repository.
Developing
cd clients/typescript
bun install
bun test
bun run typecheck
bun run build # dist/