SDK

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.

Evaluating on a hot path? This client calls the API for each evaluation (backed by a local cache). If you want every decision to be a synchronous in-memory lookup with no network at all, use the Edge SDK instead.

Install

npm install @shipsilently/node
bun add @shipsilently/node
yarn add @shipsilently/node
pnpm add @shipsilently/node

Initialize the client

Create one client at app startup and reuse it for the lifetime of the process.

flags.ts
// 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

OptionTypeDefaultNotes
apiKeystringRequired. Environment-scoped server key (sk_live_…).
apiUrlstringhttps://api.shipsilently.comOverride for a self-hosted deployment.
fetchtypeof fetchglobalInject a custom implementation, mainly for tests.
analyticsAnalyticsConfigenabledSee analytics.
retryRetryConfigenabledSee 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.

checkout.ts
// 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.

request.ts
// 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.

worker.ts
// 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.

analytics.ts
// 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();
}
Flush before a serverless process freezes. Buffered events are lost when an isolate is reclaimed. Call 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.

resilience.ts
// 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

MethodReturnsNotes
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)TSynchronous cache read. Never fetches.
getAll()Record<string, EvaluationResult>Snapshot of the local cache.
stream(context, onChange, options?)() => voidReturns an unsubscribe function.
flushAnalytics()Promise<number>Sends buffered events; resolves with the count.
close()voidReleases timers and connections.

Types

The SDK ships with full TypeScript types. The most common shapes:

types.ts
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 env binding. Pass flushAnalytics() to ctx.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

Do not ship an SDK key to the browser. ShipSilently has one kind of key today, and it is a server key: it can read every flag configuration in its environment, including rules and segment definitions that may encode customer names or internal plans. There is no public, evaluate-only client key yet.

For browser and mobile clients, the safe pattern is a thin endpoint of your own:

app/api/flags/route.ts
// 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.