SDKs · JavaScript / TypeScript
JavaScript SDK
One package, two modes. In Node it evaluates locally from an in-memory ruleset. In the browser it asks the evaluation service and caches the answers for the session. The API shape is the same.
Install#
$ npm install @darkship/sdk
$ pnpm add @darkship/sdk
$ yarn add @darkship/sdkInitialise#
init() returns a client immediately and loads flags in the background. The key prefix selects the mode: ds_srv_ runs server mode, ds_cli_ runs client mode. The browser build refuses a ds_srv_ key at init.
import { init } from '@darkship/sdk';
export const ds = init({
sdkKey: process.env.DARKSHIP_SDK_KEY!,
// host: DARKSHIP_BASE_URL or baseUrl; changes arrive over SSE within seconds (polling is the fallback)
});
ds.on('error', (err) => logger.warn({ err }, 'darkship error'));
await ds.waitForReady({ timeout: 3000 });import { init } from '@darkship/sdk';
export const ds = init({
sdkKey: 'ds_cli_prod_Zk3...',
context: { key: user.id, plan: user.plan, country: user.country },
});
ds.on('update', ({ changed }) => rerender(changed));
await ds.waitForReady({ timeout: 2000 });Init options#
| Option | Type | Default | Description |
|---|---|---|---|
sdkKey | string | required | ds_srv_... or ds_cli_.... Determines mode and environment. |
context | Context | undefined | Client mode only: initial evaluation context. Change it with identify(). |
baseUrl | string | DARKSHIP_BASE_URL, else https://sdk.darkship.tech | Evaluation host. Override for a proxy or a self-hosted test double. https only; plain http is accepted for loopback hosts. |
allowInsecureHttp | boolean | false | Allow plain http on a non-loopback host (a private network or a test double). The SDK emits a warning: the key travels unencrypted. |
streaming | boolean | true | Server mode. Subscribes to SSE (GET /v1/sdk/stream): a change reaches the SDK within seconds. On any stream error the SDK keeps polling and reconnects. false for polling only (for example behind a proxy that buffers responses). |
pollIntervalMs | number | 30000 | Server mode. The fallback poll while the stream is down (every 5 minutes while it is connected). Jittered by ±20%. Retry-After is honoured and failures back off exponentially. |
timeoutMs | number | 3000 | Bound on every network call. SDK work never runs on your call path. |
bootstrap | Snapshot or allFlags() output | undefined | Server mode: a snapshot you saved earlier (exportSnapshot()), used until the first fetch succeeds. Client mode: the output of allFlags() from your server, for server-side rendering. |
persist | boolean | false | Client mode. Keeps the last resolved values (never the context) in localStorage. |
fetch | function | global fetch | Injectable, for tests and proxies. |
telemetry | boolean or object | true | Server mode. Aggregate counters only; false turns them off. See telemetry. |
telemetry.flushIntervalMs | number | 60000 | Also flushes on a size threshold and on close(). |
Every snapshot fetch (GET /v1/sdk/snapshot), including a 304, counts toward your plan's monthly requests. A fleet of 200 processes polling every 30 seconds is about 576,000 requests a day. With the default streaming: true a connected SDK only polls every 5 minutes as a safety net; if you turn streaming off, raise pollIntervalMs where fast propagation is not critical.
waitForReady#
await ds.waitForReady({ timeout }) resolves with true when a ruleset (or, in client mode, the first evaluation result) is available, and false on timeout. It never rejects. Evaluations before readiness return your default with reason NOT_READY.
Evaluate flags#
Each accessor takes the flag key, a context and a default in server mode: getBooleanValue(key, ctx, default). In client mode there is no context argument: getBooleanValue(key, default) uses the context set at init or by identify. They are synchronous and perform no I/O.
| Method | Alias | Returns | Flag type |
|---|---|---|---|
getBooleanValue(key, ctx, def) | boolVariation | boolean | boolean |
getStringValue(key, ctx, def) | stringVariation | string | string |
getIntValue(key, ctx, def) | intVariation | number (integer) | int |
getFloatValue(key, ctx, def) | floatVariation | number | float |
getJsonValue<T>(key, ctx, def) | jsonVariation | T | json |
getDateValue(key, ctx, def) | dateVariation | Date | date |
getNumberValue(key, ctx, def) | number | int or float | |
get*Detail(...) | *VariationDetail | { flagKey, value, variationKey, reason, ruleId?, bucket?, errorCode?, flagType? } | same as above |
Other methods: allFlags(ctx) (values only; usable as a client bootstrap), refresh(), isReady(), close(); server: exportSnapshot(), snapshotVersion(), flagKeys(); client: identify() (alias setContext()).
const d = ds.getBooleanDetail('new-checkout-flow', ctx, false);
// { flagKey: 'new-checkout-flow', value: true, variationKey: 'on', reason: 'FALLTHROUGH', bucket: 4127, flagType: 'boolean' }
const bad = ds.getIntDetail('promo-banner-text', ctx, 0);
// { flagKey: 'promo-banner-text', value: 0, reason: 'ERROR', errorCode: 'TYPE_MISMATCH' }Reasons and error codes#
reason | Meaning |
|---|---|
OFF | Environment disabled; served offVariation. |
TARGET_MATCH | A target list matched: the context key, email, a groups element or another listed attribute. |
RULE_MATCH | A rule matched and served a fixed variation. |
FALLTHROUGH | Enabled, nothing matched; served the default variation. When a percentage rollout decided (in a rule or the fallthrough) the detail carries bucket (0 to 9999); there is no separate rollout reason. |
DEFAULT | Your default was returned because the client was closed. |
NOT_READY | No ruleset yet; your default was returned, with errorCode PROVIDER_NOT_READY. |
ERROR | Something went wrong; your default was returned. See errorCode: FLAG_NOT_FOUND, TYPE_MISMATCH, INVALID_CONTEXT, MALFORMED_FLAG, GENERAL, PROVIDER_NOT_READY. |
Context#
A context is a plain object. key (the user id) is required: it is the default bucketing attribute and what key target lists match. email, name and groups are optional, conventional fields: target lists can match by email (normalised: NFC, trimmed, lowercased on both sides) or by groups (matches when any element is listed). Everything else is an attribute that rules can test. See target users by id, email or group.
type Context = {
key: string; // stable id: user id, device id, org id
email?: string; // matched case-insensitively by email lists and rules
name?: string;
groups?: string[]; // any element may match a groups list
[attribute: string]: string | number | boolean | string[] | undefined;
};
const ctx = {
key: 'usr_marta',
email: 'Marta@Acme.com',
groups: ['beta-testers', 'acme-staff'],
plan: 'pro', country: 'BR', app_version: '4.12.0', orgId: 'org_acme',
};- Use a stable key. A random key per request defeats sticky rollouts.
- Don't send secrets or personal data you don't need for targeting. In server mode the context never leaves your process; in client mode it is sent for evaluation, used in memory and discarded.
- Browser:
await ds.identify(newContext)replaces the context, triggers one re-fetch and emitsupdateif any value changed.
Events#
ds.on('ready', ({ version }) => log.info('ruleset', version));
ds.on('update', ({ version, changed }) => log.info('flags changed', changed));
ds.on('error', (err) => log.warn(err.code, err.message)); // never thrown, only emitted
ds.on('stale', () => log.warn('serving the last good ruleset'));
ds.on('recovered', () => log.info('control plane reachable again'));Telemetry and privacy#
The SDK counts evaluations in memory per flag × variation × one-minute window and flushes the counters to POST /v1/telemetry/counters on an interval, when a size threshold is reached, and on close(). These are the only fields that leave your process:
| Field | Example | Notes |
|---|---|---|
environment | derived from key | Never sent as free text. |
flag_key | checkout-v2 | |
variation | on | The variation name, not the value of a JSON flag. |
window_start | 2026-10-28T14:03:00Z | One-minute window. |
count | 1842 | |
sdk, sdk_version | js/0.1.0 | For support and deprecation planning. |
No context, no user id, no per-user history and no raw events are stored. The console shows distributions and counts. If you need "who saw what", log it in your own system.
Error handling and defaults#
Evaluation never throws. When anything is wrong you get the default you passed, plus a reason in the *Detail variants and an error event for connectivity problems.
| Situation | Returned | Reason / code |
|---|---|---|
| Called before the first ruleset | your default | NOT_READY / PROVIDER_NOT_READY |
| Flag key doesn't exist in this environment | your default | ERROR / FLAG_NOT_FOUND |
| Accessor type differs from the flag type | your default | ERROR / TYPE_MISMATCH |
| Network failure after a ruleset was loaded | evaluates from the last ruleset | unchanged; error event emitted |
Invalid context (for example no key) | your default | ERROR / INVALID_CONTEXT |
| Internal error | your default | ERROR / GENERAL |
init() itself throws synchronously only for programmer errors: no key, a malformed prefix, or a server key in a browser build.
Shutdown#
process.on('SIGTERM', async () => {
await ds.close({ timeout: 2000 }); // stop polling, flush counters (best effort), release timers
process.exit(0);
});In serverless functions call await ds.close() at the end of the invocation, or keep the client at module scope across warm invocations and flush before the runtime freezes.
Versions and support#
The package is at 0.1.0. Until 1.0, minor versions may include breaking changes, listed in the changelog. Server mode needs Node 18 or newer; client mode runs in evergreen browsers. Behaviour is verified against the shared conformance vectors through both the local and the remote path, and a change to targeting semantics ships in the same release as the eval service change.
Constructing the SDK with a ds_srv_ key in a browser, web worker or jsdom throws DarkshipConfigError("SERVER_KEY_IN_BROWSER").
Thanks. This helps us prioritise the docs.