SDK

Edge SDK

Local evaluation for edge runtimes. After one async load(), every flag decision is a synchronous, in-memory, sub-millisecond call, with no network on the hot path.

View source on GitHub — shipsilently/edge

When to use this instead of the Node SDK

Node SDKEdge SDK
EvaluationAsync, network-backedSynchronous, in-memory
Hot-path I/OOne request (cached)None
StartupNone requiredOne load()
Data sourceShipSilently APIHosted, your KV, or a static blob
Best forServers, request handlersWorkers, edge middleware, hot loops

Both clients share one evaluation engine, @shipsilently/eval-core, which the Worker itself also imports. A rule resolves identically on the server and in every client, and a differential test keeps the bucketing hash byte-for-byte identical across them.

Install

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

Hosted (zero config)

The default. ShipSilently hosts the flag blob and the sync pipeline; you supply an API key. The client fetches GET /v1/blob and revalidates with an ETag, so steady-state refreshes are cheap 304s.

worker.ts
// Zero-config edge client. After one async `load()`, every evaluation is a
// synchronous, in-memory decision against the flag blob — no network, no await,
// no per-request latency. This is the right client for Workers and any hot path.
import { EdgeClient } from '@shipsilently/edge';

const flags = EdgeClient.init({ apiKey: process.env.SHIPSILENTLY_KEY! });

// Load once at startup (or at the top of a Worker's fetch handler).
await flags.load();

// Typed accessors — each returns the default if the flag is missing or is a
// different type, so a mistyped flag can never crash the caller.
const enabled = flags.getBoolean('new-checkout', { userId: 'u_123' }, false);
const variant = flags.getString('hero-variant', { userId: 'u_123' }, 'control');
const limit = flags.getNumber('rate-limit', { userId: 'u_123' }, 60);
const config = flags.getObject('checkout-config', { userId: 'u_123' }, { retries: 3 });

// Full result when you need the reason or the matched rule.
const result = flags.evaluate('new-checkout', { userId: 'u_123' }, false);
console.log(result.reason, result.ruleId);

// Refresh in the background when the control plane changes.
const unsubscribe = flags.subscribe(() => {
  console.log('flag blob updated');
});

export { enabled, variant, limit, config, unsubscribe };
Before load() resolves, every evaluation returns its default. Check flags.ready() if you need to distinguish "not loaded yet" from "genuinely off".

Bring your own KV

The blob lives in your Cloudflare KV namespace and is read in-isolate: a single KV.get(), no HTTP, no auth token on the read path. ShipSilently remains the control plane; you own the data path.

worker.ts
// Bring-your-own KV: the flag blob lives in *your* Cloudflare KV namespace,
// read in-isolate with zero HTTP and zero auth on the hot path. ShipSilently
// stays the control plane; you own the data path.
//
// Reads are a single `KV.get(key, 'json')`. You keep the key fresh with
// `syncToKv()` from a Cron Worker — see sync-worker.ts.
import { EdgeClient, kv } from '@shipsilently/edge';

interface Env {
  MY_KV: { get(key: string, type: 'json'): Promise<unknown> };
  SHIPSILENTLY_ENV_ID: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const flags = EdgeClient.init({
      storage: kv(env.MY_KV),
      // Required for the kv provider — it forms the key `flags:{envId}`.
      envId: env.SHIPSILENTLY_ENV_ID,
    });

    await flags.load();

    const userId = new URL(request.url).searchParams.get('user') ?? 'anon';
    const useNewFlow = flags.getBoolean('new-checkout', { userId }, false);

    return Response.json({ useNewFlow, ready: flags.ready() });
  },
};

You are responsible for populating the key. syncToKv() does it from a Cron-triggered Worker:

sync-worker.ts
// Keeps a bring-your-own KV namespace mirroring the ShipSilently control plane.
// Run it on a Cron trigger (every minute is plenty).
//
// `syncToKv` overwrites and never deletes, so readers never observe a
// transient missing key. If the fetch fails it returns null and leaves your
// existing KV value untouched — serve stale rather than wipe.
import { syncToKv } from '@shipsilently/edge';

interface Env {
  MY_KV: { put(key: string, value: string): Promise<unknown> };
  SHIPSILENTLY_KEY: string;
  SHIPSILENTLY_ENV_ID: string;
}

export default {
  async scheduled(_event: unknown, env: Env): Promise<void> {
    const blob = await syncToKv(env.MY_KV, {
      apiKey: env.SHIPSILENTLY_KEY,
      envId: env.SHIPSILENTLY_ENV_ID,
    });

    if (!blob) {
      console.warn('[shipsilently] sync failed; KV left at last-known-good');
      return;
    }
    console.log(`[shipsilently] synced ${blob.flags.length} flags`);
  },
};
Sync overwrites, never deletes. A BYO reader therefore never observes a transient missing key. If a sync fails, your existing KV value is left untouched — the client serves stale rather than reverting to defaults.

Static (offline)

Hand the client a blob directly. No network, no KV. This is the fastest way to write deterministic tests against real targeting rules, and it works for air-gapped builds where the blob is baked in at deploy time.

flags.test.ts
// Fully offline: hand the client a blob you already have. No network, no KV.
// Ideal for unit tests, local development, and air-gapped builds where the
// blob is baked in at deploy time.
import { EdgeClient, staticProvider } from '@shipsilently/edge';
import type { FlagCacheBlob } from '@shipsilently/edge';

const blob: FlagCacheBlob = {
  orgId: 'org_test',
  envId: 'env_test',
  updatedAt: 0,
  segments: [],
  flags: [
    {
      uuid: '00000000-0000-4000-8000-000000000001',
      key: 'new-checkout',
      type: 'boolean',
      enabled: true,
      defaultValue: false,
      rules: [
        {
          uuid: '00000000-0000-4000-8000-000000000002',
          sortOrder: 0,
          name: 'internal staff',
          conditions: [{ attribute: 'email', operator: 'ends_with', value: '@example.com' }],
          serveValue: true,
          rolloutPercentage: null,
        },
      ],
    },
  ],
};

const flags = EdgeClient.init({ storage: staticProvider(blob) });
await flags.load();

// true — the rule matches.
export const staff = flags.getBoolean('new-checkout', { email: 'dev@example.com' }, false);

// false — no rule matches, so the flag's default value is served.
export const outsider = flags.getBoolean('new-checkout', { email: 'someone@else.com' }, false);

Custom providers

storage takes any StorageProvider, so the blob can come from R2, Durable Object storage, a database, or your own cache:

StorageProvider
interface StorageProvider {
  /** Current blob for envId, or null when it isn't available yet. */
  getBlob(envId: string): Promise<FlagCacheBlob | null>;

  /** Optional push channel. Invoke onChange when the blob may have moved. */
  subscribe?(envId: string, onChange: () => void): () => void;

  /** Release timers, connections, etc. */
  close?(): void;
}

API reference

MethodReturnsNotes
EdgeClient.init(config)EdgeClientRequires apiKey (hosted) or storage (BYO).
load()Promise<void>Fetch the blob into memory. Call before evaluating.
ready()booleanTrue once a blob has loaded.
environmentId()stringResolved env UUID (known after load() when hosted).
getBoolean(key, ctx, default)booleanType-guarded; returns the default on a type mismatch.
getString(key, ctx, default)stringAs above.
getNumber(key, ctx, default)numberAs above.
getObject(key, ctx, default)TAs above; arrays are rejected as a mismatch.
evaluate(key, ctx, default)EvaluationResultFull result with reason and ruleId.
getAll(ctx)Record<string, EvaluationResult>Every flag in the blob, evaluated for one context.
subscribe(onChange)() => voidFires only when the blob actually moves. No-op without a push channel.
close()voidReleases provider resources.

Configuration

OptionTypeNotes
apiKeystringRequired for hosted. Unused by kv / staticProvider.
envIdstringRequired for kv (forms the key flags:{envId}). Hosted derives it from the blob.
storageStorageProviderOmit for hosted.
apiUrlstringHosted only. Defaults to https://api.shipsilently.com.
pollingIntervalMsnumberHosted only. Cadence when SSE is unavailable. Default 30 000.
fetchtypeof fetchHosted only. Injectable for tests.
onError(err) => voidRecoverable provider errors. Default: swallowed.
Errors are silent by default. onError defaults to a no-op, so a misconfigured key or an unreachable API surfaces only as flags that never leave their defaults. Pass an onError that logs.

OpenFeature

This package also ships an OpenFeature provider on the @shipsilently/edge/openfeature subpath, so your call sites can stay vendor-neutral. See OpenFeature.