JavaScript / TypeScript
The official ShipSilently SDK for Node.js, Bun, Deno, Cloudflare Workers, and the browser. Ships as ESM and CJS with full TypeScript types.
Install
npm install @shipsilently/nodebun add @shipsilently/nodeyarn add @shipsilently/nodepnpm add @shipsilently/nodeInitialize the client
Create one client at app startup and reuse it for the lifetime of the process.
// flags.ts — create one client at startup and reuse it for the process lifetime.
// The client holds an in-memory flag cache and (optionally) a background
// analytics buffer, so constructing one per request throws both away.
import { ShipSilentlyClient } from '@shipsilently/node';
export const flags = new ShipSilentlyClient({
apiKey: process.env.SHIPSILENTLY_KEY!,
}); ShipSilently.init(config) is available as an alternative to
new ShipSilentlyClient(config); they are the same thing.
Configuration
| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | string | — | Required. Environment-scoped server key (sk_live_…). |
apiUrl | string | https://api.shipsilently.com | Override for a self-hosted deployment. |
fetch | typeof fetch | global | Inject a custom implementation, mainly for tests. |
analytics | AnalyticsConfig | enabled | See analytics. |
retry | RetryConfig | enabled | See reconnection. |
Evaluate a single flag
evaluate() is an async lookup for one flag. The third argument
is the default returned on unknown flags or failures, and it is what fixes
the return type.
// One flag, one network call. The third argument is both the fallback AND the
// type source — `useNewFlow` is `boolean`, `heroVariant` is `string`, inferred
// with no generics to write out.
import { flags } from './client';
interface User {
id: string;
plan: string;
country: string;
}
export async function renderCheckout(user: User) {
const useNewFlow = await flags.evaluate(
'new-checkout-v2',
{ userId: user.id, plan: user.plan, country: user.country },
false,
);
const heroVariant = await flags.evaluate('hero-variant', { userId: user.id }, 'control');
return { useNewFlow, heroVariant };
} Evaluate all flags at once
evaluateAll() returns every flag in one round trip and hydrates
the local cache. Pair it with the synchronous get() for the rest
of the request.
// Fetch every flag once at the request boundary, then read synchronously.
// This is the pattern you want on a hot path: one round trip, then `get()`
// calls that never touch the network.
import { flags } from './client';
export async function handleRequest(userId: string, plan: string) {
// One call hydrates the in-memory cache for this context.
await flags.evaluateAll({ userId, plan });
// Synchronous reads — no await, served from cache.
const showBanner = flags.get('promo-banner', false);
const tier = flags.get('pricing-tier', 'standard');
const limits = flags.get('rate-limits', { rpm: 60 });
// Full snapshot, including each flag's `reason` and matched `ruleId`.
const snapshot = flags.getAll();
return { showBanner, tier, limits, snapshot };
} get() never fetches. It reads the cache
populated by evaluateAll(), evaluate(), or
stream(). Call it before the cache is warm and you get your
default with reason flag_not_found, which is also recorded as a
flag check. Warm the cache first.
Stream flag updates
For long-running services, subscribe to a Server-Sent Events stream so flag
changes propagate without polling. stream() returns an
unsubscribe function.
// Long-running services should stream. The client opens an SSE connection and
// keeps the local cache warm, so `get()` always reflects the current config
// without polling.
import type { StreamConnectionState } from '@shipsilently/node';
import { flags } from './client';
const unsubscribe = flags.stream(
{ userId: 'system' },
(updated) => {
console.log('flags refreshed:', Object.keys(updated));
},
{
// Cadence used whenever SSE is unavailable. Default: 30_000.
pollingIntervalMs: 30_000,
// Observe the transport. `reconnecting` means SSE dropped but polling is
// keeping data fresh — it is not an outage, and not worth paging on.
onStateChange: (state: StreamConnectionState) => {
console.log('[shipsilently] transport:', state);
},
},
);
// On shutdown: stop the stream, then flush any buffered analytics.
process.on('SIGTERM', async () => {
unsubscribe();
await flags.flushAnalytics();
flags.close();
});
The onStateChange callback reports the transport as
streaming, reconnecting, or polling.
Only polling is terminal for a subscription, and it is reached
on a free plan, in a runtime without EventSource, or when
retry.enabled is false after a failure.
Evaluation analytics
Analytics are on by default. The client buffers evaluation events and flushes them in the background; this is what powers stale-flag detection in the dashboard.
// Evaluation analytics are ON by default. Every `get()` and every locally
// served fallback is buffered and flushed in the background — that is what
// powers stale-flag detection in the dashboard.
//
// Networked `evaluate()` successes are NOT double-counted: the server already
// recorded those, so the client only reports what only it can see.
import { ShipSilentlyClient } from '@shipsilently/node';
export const flags = new ShipSilentlyClient({
apiKey: process.env.SHIPSILENTLY_KEY!,
analytics: {
enabled: true,
flushIntervalMs: 30_000, // default 30s, floor 1s
maxBatchSize: 100, // force a flush at this many buffered events
maxBufferSize: 5_000, // drop events past this (back-pressure guard)
samplingRate: 1.0, // 1.0 = every evaluation; 0.1 = 10%
},
});
// Opt out entirely — no buffer, no timers, no analytics requests.
export const quiet = new ShipSilentlyClient({
apiKey: process.env.SHIPSILENTLY_KEY!,
analytics: { enabled: false },
});
// Serverless / short-lived processes should flush before the runtime freezes,
// otherwise buffered events die with the isolate. Returns the count sent.
export async function beforeExit(): Promise<number> {
return flags.flushAnalytics();
} await flags.flushAnalytics() at the end of a Lambda handler, or
hand it to ctx.waitUntil() in a Cloudflare Worker.
Reconnection & failure behavior
The stream reconnects with full-jitter exponential backoff and never gives up, so a flag-service outage never requires an application restart.
// Reconnection and backoff. Defaults are sensible; tune only if you have a
// reason. The stream reconnects with full-jitter exponential backoff and never
// gives up, so a flag outage never requires an app restart.
import { ShipSilentlyClient } from '@shipsilently/node';
export const flags = new ShipSilentlyClient({
apiKey: process.env.SHIPSILENTLY_KEY!,
retry: {
enabled: true, // false = one failure downgrades to polling permanently
baseDelayMs: 1_000, // first reconnect delay
maxDelayMs: 60_000, // backoff ceiling
heartbeatTimeoutMs: 90_000, // server beats every 30s; tolerate two misses
},
});
// Inject fetch to test failure paths without a network.
export const offline = new ShipSilentlyClient({
apiKey: 'sk_live_test',
fetch: async () => new Response('boom', { status: 500 }),
});
Failed evaluations serve the last-known-good cached value rather than
collapsing to your compile-time default, including on 401 and
403. Resilience documents each
failure class in full.
API reference
| Method | Returns | Notes |
|---|---|---|
evaluate(key, context, default) | Promise<T> | One network call, single attempt, no retry. |
evaluateAll(context) | Promise<Record<string, EvaluationResult>> | One call for every flag; hydrates the cache. |
get(key, default) | T | Synchronous cache read. Never fetches. |
getAll() | Record<string, EvaluationResult> | Snapshot of the local cache. |
stream(context, onChange, options?) | () => void | Returns an unsubscribe function. |
flushAnalytics() | Promise<number> | Sends buffered events; resolves with the count. |
close() | void | Releases timers and connections. |
Types
The SDK ships with full TypeScript types. The most common shapes:
export type FlagValue = boolean | string | number | Record<string, unknown>;
export interface UserContext {
userId?: string;
email?: string;
country?: string;
plan?: string;
[key: string]: string | number | boolean | undefined;
}
export interface EvaluationResult {
flagKey: string;
value: FlagValue;
reason: 'default' | 'rule_match' | 'rollout' | 'flag_disabled' | 'flag_not_found';
ruleId?: string;
}
export type StreamConnectionState = 'streaming' | 'polling' | 'reconnecting'; Framework integrations
The SDK has zero framework dependencies and runs anywhere standard fetch is available:
- Next.js: initialize once in a server-only module; call
evaluateAll()in route handlers or Server Components. For Client Components use the React SDK. - Cloudflare Workers: read the key from the
envbinding. PassflushAnalytics()toctx.waitUntil(). Consider the Edge SDK for zero-latency local evaluation. - Bun / Deno: works natively, no polyfills needed.
- AWS Lambda: keep the client in module scope so it survives warm invocations, and flush analytics before returning.
Browser usage
For browser and mobile clients, the safe pattern is a thin endpoint of your own:
// Your server holds the key and returns only resolved values.
import { flags } from '@/lib/flags';
export async function GET(request: Request) {
const user = await getSession(request);
const resolved = await flags.evaluateAll({ userId: user.id, plan: user.plan });
// Send values only — never the rules that produced them.
return Response.json(
Object.fromEntries(Object.entries(resolved).map(([key, r]) => [key, r.value])),
);
} A public evaluate-only key type is on the roadmap. Until it lands, treat every ShipSilently key as a server secret.