Skip to content

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

Open
nieblara wants to merge 8 commits into
mainfrom
cursor/fdv2-client-side-support-c95b
Open

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

Conversation

@nieblara

@nieblara nieblara commented Jul 29, 2026 •

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

This PR uses the same pattern as the earlier server-side work in #699, #701, and #703.

Describe the solution you've provided

Summary

ldcli dev-server runs a local copy of LaunchDarkly. Your app can then get flag values from your computer instead of from the LaunchDarkly service.

An SDK is the LaunchDarkly library that your app uses to get flag values. The newer client-side SDKs (the SDKs for browsers and mobile apps) use a newer protocol, FDv2. FDv2 uses different endpoints that an SDK calls. The dev server did not have these endpoints. As a result, an upgraded client-side SDK got no flag values from the dev server.

This PR adds the missing endpoints. Older SDKs and server-side SDKs will get the same responses as before.

New endpoints

The context is the user or device that the SDK gets flag values for. The SDK sends the context with each request.

Purpose Path Method
Get all flag values one time /sdk/poll/eval POST, with the context in the body
Get all flag values one time /sdk/poll/eval/{context} GET, with the context in the URL
Get all flag values, then get each change /sdk/stream/eval POST, with the context in the body
Get all flag values, then get each change /sdk/stream/eval/{context} GET, with the context in the URL

All four endpoints also accept the OPTIONS requests that browsers send for CORS. CORS is the set of browser rules that controls which websites can call a server. Client-side SDKs for browsers and for mobile apps use the same four endpoints. These endpoints replace four older groups of endpoints. The older endpoints stay available for SDKs that did not upgrade.

How the dev server handles a request

The dev server does these steps in this order:

  1. It answers the preflight request. A browser sends this request first. This request asks whether the server permits the real request. The dev server permits GET, POST, and the Authorization header. This step comes first because a preflight request does not include a credential.
  2. It finds the credential, which is the key that the SDK sends. Mobile SDKs send it in the Authorization header. Browser SDKs send it in the auth query parameter. If both are present, the dev server uses the header. If neither is present, the dev server returns 401.
  3. It uses the credential as the project key. The dev server has no real keys, so any credential value is a project key.
  4. It reads the context. A GET request puts the context in the URL, encoded as base64. A POST request puts the context in the body, as JSON. If the context is not valid base64 or not valid JSON, the dev server returns 400.
  5. It sends the flag values. The response has the current value of each flag, with your local overrides. An override is a flag value that you set in the dev server. On the streaming endpoints, the dev server then sends each change to a flag value.

The dev server gives the same flag values to all users. As a result, it does not use the context after step 4.

Each flag in the response looks like this:

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

Other details

Server-side SDKs get the full rules for each flag, and they calculate the value themselves. Client-side SDKs get only the result, with the kind flag-eval. The kind is flag-eval with a hyphen. It is not flag_eval. The JS SDK reads only flag-eval (flagEvalMapper.ts). It ignores all other kinds and does not show an error.

The server-side responses and the client-side responses use the same code. This code takes an encoder, which is a small value that sets the object kind and the object fields. The two responses are different only in these two parts. The server-side output did not change. It is the same as before, byte for byte.

The older client-side endpoints accept the HTTP method REPORT. FDv2 does not use REPORT. The JS SDK sends only GET or POST (FDv2Requestor.ts). As a result, the new endpoints return 405 for REPORT.

If an SDK connects again with an older version of the flags, the dev server sends all the flag values again. It does not send only the changes. The payloads are small, and the server-side endpoints do the same thing.

If the SDK asks for evaluation reasons (withReasons=true), each flag has the reason FALLTHROUGH. If no targeting rule matches, a flag gives its fallthrough value. The dev server has no targeting rules, so each value is the fallthrough value.

Tests

The unit tests and handler tests cover these areas:

  • The shape of the response
  • All the ways that an SDK can send the credential
  • Many types of context
  • Requests with bad input
  • CORS on all four endpoints
  • A stream that receives a flag change

We also ran the dev server with a project imported from a file. This test does not need a LaunchDarkly account. The results were:

  • GET and POST return the flag values.
  • REPORT returns 405.
  • A request with no credential returns 401.
  • Bad base64, bad JSON, or an empty body returns 400.
  • A stream sends all the flag values first. Then it sends the change for each override that you set.

We also ran this branch and main at the same time, with the same project. All existing endpoints returned the same responses, byte for byte. This includes the server-side endpoints and the older client-side endpoints.

Describe alternatives you've considered

We did not write separate code for the client-side responses. The two protocols are different only in the object kind and the object fields. With one shared implementation, there is only one copy of the shared parts.

We did not accept REPORT on the new endpoints, as the older endpoints do. FDv2 SDKs use POST instead.

We did not require a complete context, for example a context with a key. The dev server does not use the context. A stricter rule can only reject a request that the dev server can serve. The dev server still rejects JSON that is not valid.

We did not send only the changes to an SDK that connects again. The reasons are in "Other details".

Additional context

FDv2 SDKs must accept a ping message on the stream. The dev server does not send ping. As a result, this PR does not need to do anything for it.

If a response includes the x-ld-fd-fallback header, an FDv2 SDK goes back to the older protocol. The dev server supports FDv2 fully, so it never sends this header.

At intervals, the stream sends an SSE comment, which is a line with no event data. The comment keeps the connection open. The server-side FDv2 stream does the same thing.

The package documentation in internal/dev_server/sdk/docs.go now lists all the FDv2 endpoints. This includes two server-side endpoints that were missing.

cursoragent and others added 5 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>
Keep main's per-route CORS method lists and this branch's FDv2 client
routes. FDv1 eval routes use CorsHeadersForMethods; the new client-side
FDv2 routes keep ClientFdv2CorsHeaders so Authorization stays allowed.

Co-authored-by: Ramon Niebla <nieblara@users.noreply.github.com>
@cursor
cursor Bot marked this pull request as ready for review September 28, 2026 23:09
@cursor
cursor Bot requested a review from a team as a code owner September 28, 2026 23:09
cursoragent and others added 2 commits September 29, 2026 05:27
The FDv2 client-side protocol takes the evaluation context in a POST body
or a GET path; REPORT is not part of it, and the JS SDK never sends it.
POST /sdk/poll/eval and POST /sdk/stream/eval now answer REPORT with 405,
and their CORS configuration no longer allows it.

The FDv1 client-side routes still accept REPORT.

Co-authored-by: Ramon Niebla <nieblara@users.noreply.github.com>
The dev server serves the same flag values to everyone, so it never uses
the evaluation context. Parsing it as a full LaunchDarkly context only
added a way to reject a request the dev server could have served.

Requests are still rejected with 400 when the context is not base64 or not
well-formed JSON. A well-formed context missing fields such as key is now
accepted.

Co-authored-by: Ramon Niebla <nieblara@users.noreply.github.com>
@nieblara
nieblara requested review from a team and removed request for a team September 29, 2026 05:55
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