Skip to content

feat: serve the FDv2 client-side protocol from the dev server - #764

Draft
nieblara wants to merge 4 commits into
mainfrom
cursor/fdv2-client-side-support-c95b
Draft

feat: serve the FDv2 client-side protocol from the dev server#764
nieblara wants to merge 4 commits into
mainfrom
cursor/fdv2-client-side-support-c95b

Conversation

@nieblara

Copy link
Copy Markdown
Contributor

Requirements

  • I have added test coverage for new or changed functionality
  • I have followed the repository's pull request submission guidelines
  • I have validated my changes against all supported platform versions

Related issues

FDv2 Client-Side Support, and the Tech Spec: FDv2 Client Side Protocol.

This is the client-side mirror of the server-side work: #699 (payload_version, reused as-is), #701 (GET /sdk/poll), #703 (GET /sdk/stream).

Describe the solution you've provided

The dev server only spoke the FDv1 client-side endpoints, so the moment a customer upgraded a client-side SDK to an FDv2-capable version and pointed it at the dev server, the routes the SDK asked for were not there. This adds them:

Purpose Path Methods
Polling /sdk/poll/eval POST, REPORT, OPTIONS
Polling /sdk/poll/eval/{context} GET, OPTIONS
Streaming /sdk/stream/eval POST, REPORT, OPTIONS
Streaming /sdk/stream/eval/{context} GET, OPTIONS

FDv2 unifies the browser and mobile endpoints, so this one pair of routes covers what four FDv1 route families did (/eval/{envId}, /meval, /sdk/evalx/{envId}, /msdk/evalx). Those remain in place for SDKs that have not moved yet.

Notable points:

  • The flag-eval object kind. Client-side put-objects carry a pre-evaluated result instead of a flag configuration. The kind is hyphenated — flag-eval, not flag_eval. Confirmed against the SDK side rather than taken from the tech spec: flagEvalMapper.ts and PollingBase.ts both register the processor under 'flag-eval'. The object drops the payload version (the put-object envelope carries it) and the flag key, and matches FlagEvaluationResult in the js-core client SDK.
  • The protocol builders are parameterized, not forked. buildInitialResponse, buildFullTransferResponse, makePutObjectEvent, buildFlagChangeEvents and the stream observer now take an fdv2ObjectEncoder that carries the kind and the object shape. Everything else about the two protocols is identical, so PollV2/StreamV2 and PollClientV2/StreamClientV2 are four thin entry points over a shared core. Server-side output is byte-for-byte unchanged.
  • Both authentication modes. Client-side SDKs send a mobile key or a client-side ID, on the Authorization header (mobile) or in the auth query parameter (browser — see BrowserClient.ts). The header wins when both are present, per the spec. The dev server has no real credentials, so this is resolution plumbing: whichever value arrives is the project key.
  • CORS on every route, including preflight. The existing v2 server-side routes are bare router.Handle(...) because server-side SDKs do not need CORS. These four are registered through a subrouter with a CORS middleware that allows GET, POST and REPORT plus the Authorization header. The gorilla CORS handler answers the preflight before the rest of the chain runs, which matters because a preflight carries no credential and would otherwise be rejected with a 401.
  • Context in the path or the body. GET carries a base64url context in the URL, POST and REPORT carry it in the body. The dev server does not support targeting — it serves the same variation of a flag regardless of who is evaluating — so the context is parsed to reject malformed requests the way the real service would, and then discarded. Decoding accepts url-safe and standard base64 with or without padding, since SDKs across platforms differ here (js-core strips padding and swaps +/ for -_).
  • withReasons. When the SDK asks for reasons, each result carries {"kind":"FALLTHROUGH"}. The single variation the dev server exposes is the fallthrough of an on flag, which is what the server-side representation already reports.
  • samplingRatio is part of the representation for protocol fidelity but stays unset, since the dev server never samples and SDKs treat an absent ratio as 1.

Verification

Beyond the unit and handler tests, I ran the real ldcli dev-server against a project imported from a file (no LaunchDarkly credentials needed) and exercised every route with curl:

  • All four routes on all methods, with the credential on the header, in the auth query parameter, and with the header taking precedence; 401 with no credential.
  • 400 for a malformed base64 path segment, base64 that is not JSON, an empty body, and a context missing its key; 200 for single-kind, non-user-kind, multi-kind, attribute-bearing, anonymous and private-attribute contexts.
  • OPTIONS preflight on all four routes for GET, POST and REPORT — all 200 with Access-Control-Allow-Origin, and Access-Control-Allow-Methods: REPORT where REPORT is requested.
  • Two-phase init: poll for the initial payload, then open a stream with that selector as ?basis and receive intentCode: none / up-to-date followed by only the deltas as overrides landed, with the payload version and selector advancing on each one.
  • Regression pass over the endpoints this refactor touches or sits beside: /sdk/poll and /sdk/stream still emit kind: flag with the full configuration object, and /all, /eval/{envId}/{ctx}, /meval/{ctx}, /sdk/evalx/{envId}/contexts/{ctx}, /msdk/evalx/contexts/{ctx} and /sdk/goals/{envId} all still respond as before.

A client-side FDv2 stream during two overrides:

event:server-intent
data:{"payloads":[{"id":"my-project","target":1,"intentCode":"xfer-full","reason":"payload-missing"}]}

event:put-object
data:{"version":1,"kind":"flag-eval","key":"bool-flag","object":{"flagVersion":3,"value":true,"variation":0,"trackEvents":true}}

event:payload-transferred
data:{"state":"(p:my-project:1)","version":1}

event:server-intent
data:{"payloads":[{"id":"my-project","target":2,"intentCode":"xfer-changes","reason":"update"}]}

event:put-object
data:{"version":2,"kind":"flag-eval","key":"bool-flag","object":{"flagVersion":4,"value":false,"variation":0,"trackEvents":true}}

event:payload-transferred
data:{"state":"(p:my-project:2)","version":2}

Describe alternatives you've considered

  • Forking the FDv2 event builders for the client-side protocol. Rejected: the two protocols differ only in the put-object kind and object shape, so a single encoder parameter keeps one implementation of basis handling, transfer intents and selector construction.
  • Duplicating the poll and stream handlers. Rejected for the same reason — the client-side handlers would have been copies differing in one value.
  • Delta transfers. Not supported, consistent with #701: a client with a stale basis gets a full payload. Tracking the change history deltas need is overkill for a local dev server, and the spec notes fb@edge is not doing delta responses for client-side polling either.
  • Ignoring the context entirely rather than parsing it. Rejected: parsing lets a developer see a malformed context at dev time the way they would in production. The parsed value is still discarded, and the tests cover a spread of legitimate context shapes so this cannot silently lock an SDK out of its data.

Additional context

Two things from the design doc I deliberately did not implement, both worth a look from a reviewer:

  1. Ping on the stream. The CSFDV2 spec requires client SDKs to tolerate ping on an FDv2 stream, and Pingstream itself is out of CS FDv2 GA scope. The dev server never emits ping, so I read this as "don't break if you see one" and there is nothing to do on the server side — js-core's StreamingFDv2Base.ts attaches its own ping listener and simply never fires it here. Worth confirming with Ryan Lamb.
  2. x-ld-fd-fallback. Client SDKs fall back to FDv1 when a response carries this header. The dev server serves FDv2 fully, so it never sends it.

Keepalives on the client-side stream are SSE comments (:\n\n) rather than heart-beat events, matching what the server-side FDv2 stream already does.

The package doc in internal/dev_server/sdk/docs.go now lists the FDv2 routes, including the two server-side ones that were missing.

Open in Web Open in Cursor 

cursoragent and others added 4 commits July 29, 2026 20:20
Client-side SDKs are moving to the FDv2 endpoints, which the dev server did
not serve at all: an SDK upgraded to an FDv2-capable version and pointed at
the dev server asked for routes that were not there.

Adds POST/REPORT /sdk/poll/eval, GET /sdk/poll/eval/{context},
POST/REPORT /sdk/stream/eval and GET /sdk/stream/eval/{context}. FDv2
unifies the browser and mobile endpoints, so this one pair of routes covers
what /eval/{envId}, /meval, /sdk/evalx/{envId} and /msdk/evalx did in FDv1.

The protocol event builders are parameterized over the put-object kind and
object shape rather than forked, so the two protocols differ only in that
client-side put-objects carry a pre-evaluated flag-eval result where
server-side ones carry the flag configuration.

Co-authored-by: Ramon Niebla <nieblara@users.noreply.github.com>
Covers the flag-eval object shape, base64 context decoding across the
url-safe and standard variants, the credential and context matrices for
both the path and body forms, CORS preflight on all four routes, and an
end-to-end SSE stream carrying an initial transfer plus an override.

Co-authored-by: Ramon Niebla <nieblara@users.noreply.github.com>
Co-authored-by: Ramon Niebla <nieblara@users.noreply.github.com>
The two handlers loaded the project, applied overrides, and built the
initial response identically.

Co-authored-by: Ramon Niebla <nieblara@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants