Build-time planning
Not on npm yet
npm install @evanion/feature does not resolve. The package is private: true, so a release run versions and tags it without publishing: npm cannot configure a trusted publisher for a package that does not exist on the registry, and the first version has to go up by hand.
plan() partitions every feature into resolvable now and deferred, for a build
that does not have the whole evaluation context.
import { } from '@evanion/feature';
const = ([
{
: 'new-checkout',
: true,
: [{ : 'a-share', : { : 10, : 'targetingKey' } }],
},
]);
.({ : new ('2026-11-01T00:00:00Z') }); // -> { 'new-checkout': { key: 'new-checkout', resolved: 'deferred', needs: ['targetingKey'] } }The new checkout is deferred because its rollout reads targetingKey, which a
build does not have. A static site emits only the deferred set and resolves
that per request; an API calls resolve() per request. The resolvable cases go through the same decide
as resolve does.
A plan entry
interface PlanEntry<F> {
key: F;
resolved: boolean | 'deferred';
needs: readonly string[]; // sorted; empty unless deferred
decision?: Decision<F>; // present when `resolved` is a boolean
}resolved: true or false is a settled answer, and decision carries it with
its reason. resolved: 'deferred' means some rule still needs context this plan
did not have, and needs lists the fields, sorted.
A deferred feature’s needs includes whatever its deferred parents need, so an
entry’s list is everything the request has to supply before this feature can be
decided at all.
What makes a rule resolvable
A rule whose fields are all present in the context can be decided now; one that still needs a field cannot.
features.plan({ targetingKey: 'cust-0101' });
// new-checkout: { resolved: 'deferred', needs: ['now'] }The rollout resolved — targetingKey was there — and the time window did not.
createFeatures([
{
key: 'staff-only',
enabled: true,
rules: [
{
id: 'staff-first',
when: [{ field: 'role', op: 'eq', value: 'bookseller' }],
},
],
},
]).plan({});
// { 'staff-only': { key: 'staff-only', resolved: 'deferred', needs: ['role'] } }A feature with enabled: false is resolved: false immediately, whatever its
rules need. A feature with no rules is resolved: true, given enabled.
now is deferred unless the feature opts in
now is deliberately not treated as available, even though plan() fills it in
the same way resolve() does:
createFeatures([
{
key: 'x',
enabled: true,
freezeTimeAtBuild: true,
rules: [
{
id: 'live',
when: [{ field: 'now', op: 'after', value: '2026-01-01T00:00:00Z' }],
},
],
},
{ key: 'y', enabled: true, dependsOn: ['x'] },
]).plan({ now: new Date('2026-11-01T00:00:00Z') });
// x: { resolved: true, needs: [], decision: { enabled: true, reason: 'rule-match', rule: 'live' } }
// y: { resolved: true, needs: [], decision: { enabled: true, reason: 'default-on' } }Whether a date window may be frozen into a build is a deploy-cadence decision, and it belongs to whoever owns the feature rather than to whoever runs the build. A site that rebuilds hourly can freeze a window that opens on a date; a site that rebuilds monthly cannot, and the same flag in both places has to be able to say so.
Without freezeTimeAtBuild, a now-dependent rule is deferred and 'now'
appears in needs. The static build emits the rule and the request resolves it.
Using a plan
The two halves go to different places.
const plan = features.plan({ now: new Date() });
const settled = Object.fromEntries(
Object.entries(plan)
.filter(([, entry]) => entry.resolved !== 'deferred')
.map(([key, entry]) => [key, entry.decision!]),
);
const deferred = Object.values(plan)
.filter((entry) => entry.resolved === 'deferred')
.map((entry) => entry.key);settled is a Decisions record, which is exactly what
<FeatureProvider decisions={…}> takes — see React. Ship it
with the build. deferred is the list the runtime still has to resolve, and the
union of their needs is the context the runtime has to assemble.
A flag that is settled false at build time can be stripped from the bundle
entirely. One that is deferred cannot, because the branch it gates has to exist
for the request that turns it on.