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.
shipsilently/edge When to use this instead of the Node SDK
| Node SDK | Edge SDK | |
|---|---|---|
| Evaluation | Async, network-backed | Synchronous, in-memory |
| Hot-path I/O | One request (cached) | None |
| Startup | None required | One load() |
| Data source | ShipSilently API | Hosted, your KV, or a static blob |
| Best for | Servers, request handlers | Workers, 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/edgebun add @shipsilently/edgeyarn add @shipsilently/edgepnpm add @shipsilently/edgeHosted (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.
// 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 }; 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.
// 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:
// 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`);
},
}; 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.
// 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:
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
| Method | Returns | Notes |
|---|---|---|
EdgeClient.init(config) | EdgeClient | Requires apiKey (hosted) or storage (BYO). |
load() | Promise<void> | Fetch the blob into memory. Call before evaluating. |
ready() | boolean | True once a blob has loaded. |
environmentId() | string | Resolved env UUID (known after load() when hosted). |
getBoolean(key, ctx, default) | boolean | Type-guarded; returns the default on a type mismatch. |
getString(key, ctx, default) | string | As above. |
getNumber(key, ctx, default) | number | As above. |
getObject(key, ctx, default) | T | As above; arrays are rejected as a mismatch. |
evaluate(key, ctx, default) | EvaluationResult | Full result with reason and ruleId. |
getAll(ctx) | Record<string, EvaluationResult> | Every flag in the blob, evaluated for one context. |
subscribe(onChange) | () => void | Fires only when the blob actually moves. No-op without a push channel. |
close() | void | Releases provider resources. |
Configuration
| Option | Type | Notes |
|---|---|---|
apiKey | string | Required for hosted. Unused by kv / staticProvider. |
envId | string | Required for kv (forms the key flags:{envId}). Hosted derives it from the blob. |
storage | StorageProvider | Omit for hosted. |
apiUrl | string | Hosted only. Defaults to https://api.shipsilently.com. |
pollingIntervalMs | number | Hosted only. Cadence when SSE is unavailable. Default 30 000. |
fetch | typeof fetch | Hosted only. Injectable for tests. |
onError | (err) => void | Recoverable provider errors. Default: swallowed. |
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.