These pages document @evanion/feature 0.1.1, the version on npm.
Astro SSR
To manage feature flags in a server-rendered environment, you can integrate a feature store directly into your request pipeline. In the storefront example located in apps/storefront, the app uses output: 'server' to fetch a feature document from shop-api (GET /api/features), install it into a module-scoped store, and bind that store to the current visitor. This process populates Astro.locals.features, which the cart page then reads to toggle the express-pickup flag. Because the storefront ships no JavaScript to the browser and contains no islands, this page demonstrates how a server-rendered page reads a flag from the application’s own code and explains how a client-side island would receive a decision from the library.
Why the store is held at module scope
In the storefront, featureStore in apps/storefront/src/lib/features.ts holds the store in module scope, similar to how the access matrix is held in policy.ts. This store is built from the document served by GET /api/features and is replaced only when the version changes or the 60-second FEATURES_REVALIDATE_MS window expires.
The construction process (parseFeatureConfig) validates, deep-clones, and deep-freezes the document; every subsequent decision is a local call over this frozen copy. Because a Features instance holds definitions but no specific actor, a single store serves every request and user, with each read taking its context as an argument. This ensures the same definitions apply to every visitor. When a matching version is detected, the installed store is reused to avoid re-validating the same bytes during a poll. If a fetch fails or the library refuses a document, the held store remains in place and the window stays open so the next request can retry. If nothing is held, the system defaults to a store with no definitions, turning every flag off. Because parseFeatureConfig reports a refused document rather than throwing, a bad revision will not cause the page to fail. To prevent redundant network calls, any renders in flight when the window expires share a single fetch (inFlight).
export const FEATURES_REVALIDATE_MS = 60_000;
/** What a request resolves on while no document has been installed: every flag off. */
const NO_FEATURES: Features = createFeatures([]);
let held: HeldFeatures | undefined;
let checkedAt = 0;
let inFlight: Promise<Features> | undefined;
/**
* The store this process decides on.
*
* Module scope and not per request: construction validates, deep-clones and
* deep-freezes the document, every decision after that is a local function call
* over the frozen copy, and the definitions are the same for every visitor. A
* `Features` holds definitions and no actor, so one store serves every request
* and every user. Each read takes its context as an argument, and
* `store.for(context)` binds one.
*
* Two things replace what is held: a version the shop-api reports that differs
* from the held one, and the {@link FEATURES_REVALIDATE_MS} window expiring,
* which is what makes this app ask at all. A matching version re-uses the
* installed store, so re-validating the same bytes on a poll is skipped.
*
* A fetch that fails, or a document the library refuses, leaves the held store
* in place and the window open, so the next request asks again. With nothing
* held the answer is a store that turns every flag off, which draws the page
* without the controls a flag adds.
*/
export async function featureStore(api: ShopApi): Promise<Features> {
if (held && Date.now() - checkedAt < FEATURES_REVALIDATE_MS) {
return held.features;
}
// One fetch for however many renders are in flight when the window expires.
inFlight ??= refresh(api).finally(() => {
inFlight = undefined;
});
return inFlight;
}When refreshing the store, a version that matches the currently held one only updates the checkedAt timestamp. A document that parses successfully replaces the held store.
async function refresh(api: ShopApi): Promise<Features> {
const document = await api.features();
if (!document.ok) return held?.features ?? NO_FEATURES;
const { version, config } = document.value;
if (held && version !== undefined && held.version === version) {
checkedAt = Date.now();
return held.features;
}
const parsed = parseFeatureConfig(config);
if (!parsed.ok) return held?.features ?? NO_FEATURES;
held = { version, features: parsed.features };
checkedAt = Date.now();
return parsed.features;
}Binding the store to the visitor in middleware
To ensure feature flags are available throughout the request, the middleware in src/middleware.ts runs on every request. This occurs because the app is server-rendered; in a prerendered build, this would run only once at build time.
The middleware awaits policyAccess and featureStore, then writes context.locals.features = features.for({ targetingKey: subject.id }) alongside the existing members (subject, api, cart, and access). The for method reads the clock once, ensuring every page and component within a single request makes decisions at the same instant. The targetingKey is the value used for rollout bucketing. For example, the shop’s new-checkout flag ramps at 25 percent by targetingKey; since express-pickup depends on new-checkout, a visitor outside the ramp sees both flags as off. The middleware only awaits a fetch when the process holds no document or the revalidation window has expired.
export const onRequest = defineMiddleware(async (context, next) => {
const subject = readSubject(context.cookies);
context.locals.subject = subject;
context.locals.api = ShopApi.forRequest(context.request, subject);
context.locals.cart = readCart(context.cookies);
context.locals.access = await policyAccess(context.locals.api);
const features = await featureStore(context.locals.api);
context.locals.features = features.for({ targetingKey: subject.id });
const response = await next();
// Echoed on the way out so the id is readable with `curl -I` as well as in
// the page, which is what makes the hop observable without a browser.
response.headers.set(CORRELATION_HEADER, context.locals.api.correlationId);
return response;
});Typing locals.features
To provide type safety for the bound store, env.d.ts declares the member features: BoundFeatures without a type argument. This is marked as required because the middleware always binds a handle; if no document is available, it simply turns every flag off, allowing a page to read a flag without testing for the handle’s existence.
By omitting the type argument, BoundFeatures accepts any string key, and any key not carried by the document resolves to “off.” In the storefront, the document serves as the contract since no type is shared with the service. This differs from the admin app, which declares AdminFlags to trigger compile errors for unknown keys.
declare global {
namespace App {
interface Locals {
api: ShopApi;
cart: CartLine[];
subject: StorefrontSubject;
access: Access | undefined;
features: BoundFeatures;
}
}
}Reading a flag in a page
To control the UI based on a feature, cart.astro reads the express-pickup flag using features.isEnabled('express-pickup') to determine whether to draw a line in the cart actions. This acts as a render gate, deciding what the page draws. It does not govern business logic such as who may order, which is handled by order.create in the access matrix. The shop-api resolves flags independently using its own copy.
{
features.isEnabled('express-pickup') && (
<p
class="baize-text baize-text--tone-moss baize-text--size-sm"
data-flag="express-pickup"
>
Express pickup is on: collect this order in store once it is
placed.
</p>
)
}The following test verifies this behavior by rendering the page through the container API with a store where express-pickup is toggled on and off, asserting whether the data-flag attribute appears.
it('offers express pickup only while the flag is on', async () => {
shopApiStub();
const on = await viewCart(CUSTOMER_SUBJECT, true);
const off = await viewCart(CUSTOMER_SUBJECT, false);
expect(on).toContain('data-flag="express-pickup"');
expect(off).not.toContain('data-flag="express-pickup"');
});What an island receives
When considering how a decision reaches a component running in the browser, there are two patterns.
The first, used in this repository, is for server-rendered pages: the page reads the flag and emits HTML. Nothing crosses to the browser, so nothing needs to be adopted. cart.astro follows this pattern.
The second pattern is for islands that require a decision. Since the storefront currently has no islands, this is not shown in the storefront code, but the library provides a tested example. In this scenario, an island receives a DecisionSet as a prop. A non-React island then calls adoptDecisions to read it. The set is generated via features.snapshot() on the bound handle within the page frontmatter. The adoptDecisions function compares the version of the shipped set with the version of the store the island holds, reports any mismatch via onDivergence, and returns the decisions.
import {
,
,
,
} from '@evanion/feature';
import type { , } from '@evanion/feature';
const = [
{
: 'checkout-cta',
: true,
: [
{ : 'control', : 50 },
{ : 'blue', : 50 },
],
},
];
const = ((), { : 'v1' });
const = ((), { : 'v0' });
// The server resolves the set inside the request.
const = ();
const = .(.({ : 'cust-0042' }));
// The client parses it and adopts it with a store built from the same document.
const = .() as ;
const : [] = [];
const = ();
const = (, , {
: { : 'cust-0042' },
: () => .(),
});
['checkout-cta']?.; // -> 'control'
.; // -> 0
// A client still holding the previous document reports the mismatch.
const = ();
(, , {
: { : 'cust-0042' },
: () => .(),
});
.(() => .); // -> ['config-version']
[0]?.; // -> "these decisions were resolved under config version v1 and this store holds v0, so the two processes hold different configurations. Reload the store from the document the server used, or pass onVersionMismatch: 're-resolve'."Where to go next
- How do two processes agree?: learn about the version check and the divergence report.
- How do I serve my configuration?: explore the document
shop-apiserves and how a holder reloads it. - Many Processes: understand how one document is managed across many holders.
- API reference: detailed documentation for
for,snapshot,adoptDecisions, andDecisionSet.