Skip to content

JS local resolver sticky assignments stored in cookies #51

@nicklasl

Description

@nicklasl

Cookie-Based Materialization Repository - Design Investigation

Problem Statement

We want to create a CookieMaterializationRepository that stores materialization data in HTTP cookies. The challenge is: how do we pass request-scoped cookie access (req/res) to the repository when the OpenFeature provider is a singleton?

Context

  • Provider is singleton: Created once at app startup, not per-request
  • Cookies are per-request: Need access to current request/response objects
  • Cookie scope: Single cookie per user containing only their materialization data
  • Data structure: { [materialization: string]: MaterializationInfo }

Investigation: Can We Pass Data Through OpenFeature's API?

We investigated three potential approaches to pass the cookie adapter through OpenFeature's API.

Option 1: EvaluationContext ❌

Approach: Pass cookie adapter via the context parameter in getBooleanValue().

await client.getBooleanValue('my-flag', false, {
  targetingKey: 'user-123',
  __cookieAdapter: adapter  // Can we do this?
});

Finding: Does NOT work

Reason: EvaluationContext has a strict type definition:

type EvaluationContextValue = PrimitiveValue | Date | {
    [key: string]: EvaluationContextValue;
} | EvaluationContextValue[];

type EvaluationContext = {
    targetingKey?: string;
} & Record<string, EvaluationContextValue>;

Functions are explicitly NOT allowed in EvaluationContextValue. The cookie adapter contains functions (getCookie, setCookie), so it cannot be passed through context.

Source: /node_modules/@openfeature/core/dist/types.d.ts:18-31

Option 2: Hooks and HookHints ❌

Approach: Use FlagEvaluationOptions.hookHints to pass the adapter.

await client.getBooleanValue('my-flag', false, context, {
  hookHints: { __cookieAdapter: adapter }  // Can hooks help?
});

Finding: Does NOT work

Reason 1: While HookHints is typed as Record<string, unknown> (which CAN hold functions), the hints are only passed to hooks, not to the provider.

Reason 2: The provider interface signature is:

resolveBooleanEvaluation(
  flagKey: string,
  defaultValue: boolean,
  context: EvaluationContext,
  logger: Logger  // <-- NOT options!
): Promise<ResolutionDetails<boolean>>

The FlagEvaluationOptions (containing hooks and hookHints) are consumed by the OpenFeature SDK to:

  1. Register additional hooks for this evaluation
  2. Pass hints to those hooks

But the provider never receives the options. The provider only gets:

  • flagKey
  • defaultValue
  • context (EvaluationContext)
  • logger

Source: /node_modules/@openfeature/server-sdk/dist/types.d.ts

Option 3: Provider Options at Initialization ❌

Approach: Pass cookie adapter when creating the provider.

const provider = createConfidenceServerProvider({
  // ...config
  cookieAdapter: adapter  // Won't work - adapter is request-scoped!
});

Finding: Does NOT work

Reason: The provider is created once at startup, but we need different cookie adapters for each request (different req/res objects).

Solution: AsyncLocalStorage ✅

Since there's no way to pass request-scoped data through OpenFeature's API, we must use Node.js AsyncLocalStorage.

Architecture

// CookieStorage.ts
import { AsyncLocalStorage } from 'async_hooks';

export interface CookieAdapter {
  getCookie(name: string): string | undefined;
  setCookie(name: string, value: string, options: CookieOptions): void;
}

export const cookieAdapterStorage = new AsyncLocalStorage<CookieAdapter>();
// CookieMaterializationRepo.ts
export class CookieMaterializationRepo implements MaterializationRepository {
  async loadMaterializedAssignmentsForUnit(
    unit: string,
    materialization: string
  ): Promise<Map<string, MaterializationInfo>> {
    const adapter = cookieAdapterStorage.getStore();
    if (!adapter) {
      throw new Error('No cookie adapter in async context');
    }

    const cookieValue = adapter.getCookie(this.cookieName);
    // ... parse and return materialization data
  }

  async storeAssignment(
    unit: string,
    assignments: Map<string, MaterializationInfo>
  ): Promise<void> {
    const adapter = cookieAdapterStorage.getStore();
    if (!adapter) {
      throw new Error('No cookie adapter in async context');
    }

    // ... encode data
    adapter.setCookie(this.cookieName, encoded, options);
  }
}

Usage Pattern

// App startup (once)
const cookieRepo = new CookieMaterializationRepo();
const provider = createConfidenceServerProvider({
  flagClientSecret: process.env.CONFIDENCE_CLIENT_SECRET,
  apiClientId: process.env.CONFIDENCE_API_CLIENT_ID,
  apiClientSecret: process.env.CONFIDENCE_API_CLIENT_SECRET,
  stickyResolveStrategy: cookieRepo
});
await OpenFeature.setProviderAndWait(provider);

// Per-request (Express example)
app.get('/api/evaluate', async (req, res) => {
  const adapter: CookieAdapter = {
    getCookie: (name) => req.cookies[name],
    setCookie: (name, value, opts) => res.cookie(name, value, opts)
  };

  // Wrap the OpenFeature call in AsyncLocalStorage context
  await cookieAdapterStorage.run(adapter, async () => {
    const client = OpenFeature.getClient();
    const result = await client.getBooleanValue('my-flag', false, {
      targetingKey: req.userId
    });
    res.json({ enabled: result });
  });
});

Why AsyncLocalStorage Works

  1. Automatic context propagation: AsyncLocalStorage tracks async context across the call chain
  2. Thread-safe: Each request has its own isolated context
  3. No API pollution: Doesn't require modifying OpenFeature's API or types
  4. Standard pattern: Commonly used in Node.js for request-scoped data (logging, tracing, etc.)

Alternatives Considered and Rejected

Approach Why Rejected
Pass via EvaluationContext Functions not allowed in EvaluationContextValue type
Pass via FlagEvaluationOptions Options don't reach the provider, only hooks
Pass via provider constructor Provider is singleton, adapter is per-request
Global variable Not thread-safe, would mix data across requests
Modify OpenFeature types Would break compatibility, not maintainable

Framework Compatibility

The cookie adapter pattern can be adapted to different frameworks:

Express

const adapter = {
  getCookie: (name) => req.cookies[name],
  setCookie: (name, value, opts) => res.cookie(name, value, opts)
};

Next.js App Router

import { cookies } from 'next/headers';

const adapter = {
  getCookie: (name) => cookies().get(name)?.value,
  setCookie: (name, value, opts) => cookies().set(name, value, opts)
};

Hono

const adapter = {
  getCookie: (name) => c.req.cookie(name),
  setCookie: (name, value, opts) => c.cookie(name, value, opts)
};

Implementation Considerations

Cookie Size Limits

  • Cookies limited to ~4KB
  • Materialization data can grow large
  • Solutions:
    • Base64 encoding (required)
    • Compression (optional, adds CPU overhead)
    • Warn/error if cookie exceeds size limit

Cookie Options

{
  maxAge: 60 * 60 * 24 * 90, // 90 days (match server-side TTL)
  httpOnly: true,            // Prevent JS access
  secure: true,              // HTTPS only
  sameSite: 'lax',          // CSRF protection
  path: '/'                  // Available on all paths
}

Data Structure

Since the cookie is scoped to one user, we only store their materializations:

{
  "materializedSegments/abc123...": {
    "unitInInfo": true,
    "ruleToVariant": {
      "flags/my-flag/rules/xyz": "flags/my-flag/variants/enabled"
    }
  }
}

No need for unit keys - the cookie itself belongs to that user.

Conclusion

AsyncLocalStorage is the only viable solution for passing request-scoped cookie adapters to the OpenFeature provider's materialization repository. All other approaches are blocked by OpenFeature's API design, which (correctly) does not allow arbitrary data to flow from client calls to provider implementations.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions