diff --git a/.github/workflows/openfeature-cloudflare-server.yaml b/.github/workflows/openfeature-cloudflare-server.yaml index ed53d7cb73..18e55afa6b 100644 --- a/.github/workflows/openfeature-cloudflare-server.yaml +++ b/.github/workflows/openfeature-cloudflare-server.yaml @@ -31,3 +31,20 @@ jobs: with: workspace_name: '@launchdarkly/openfeature-cloudflare-server' workspace_path: packages/sdk/openfeature-cloudflare-server + + build-test-openfeature-cloudflare-server-example: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./actions/setup-yarn + - name: Install Dependencies + run: yarn workspaces focus @launchdarkly/hello-openfeature-cloudflare-server + - name: Install Playwright browsers + run: yarn workspace @launchdarkly/hello-openfeature-cloudflare-server playwright install --with-deps chromium + - id: shared + name: Shared CI Steps + uses: ./actions/ci + with: + workspace_name: '@launchdarkly/hello-openfeature-cloudflare-server' + workspace_path: packages/sdk/openfeature-cloudflare-server/examples/getting-started + should_build_docs: false diff --git a/package.json b/package.json index 7fcd5c422d..177a99290a 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "packages/sdk/openfeature-node-server", "packages/sdk/openfeature-node-server/examples/getting-started", "packages/sdk/openfeature-cloudflare-server", + "packages/sdk/openfeature-cloudflare-server/examples/getting-started", "packages/sdk/vue", "packages/sdk/vue/contract-tests", "packages/sdk/vue/examples/getting-started" diff --git a/packages/sdk/openfeature-cloudflare-server/examples/getting-started/.gitignore b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/.gitignore new file mode 100644 index 0000000000..ca7aeea163 --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/.gitignore @@ -0,0 +1,4 @@ +/node_modules +/dist +/.wrangler +/test-results diff --git a/packages/sdk/openfeature-cloudflare-server/examples/getting-started/README.md b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/README.md new file mode 100644 index 0000000000..7834fa8acb --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/README.md @@ -0,0 +1,108 @@ +# LaunchDarkly sample OpenFeature Cloudflare Workers application + +We've built a simple Cloudflare Worker that demonstrates how the LaunchDarkly OpenFeature +provider for Cloudflare Workers (`@launchdarkly/openfeature-cloudflare-server`) works. + +Below, you'll find the build procedure. For more comprehensive instructions, you can visit your [Quickstart page](https://app.launchdarkly.com/quickstart#/) or the [Cloudflare SDK reference guide](https://docs.launchdarkly.com/sdk/server-side/cloudflare). + +This demo requires Node 22 or higher and yarn. It also requires the Wrangler CLI (v3, installed as a dev dependency of this example). The steps below run entirely against Wrangler's local simulated storage, so a Cloudflare account and login are not required to follow them; they're only needed if you want to seed the real remote preview KV namespace or run `yarn deploy`. See the [wrangler docs](https://developers.cloudflare.com/workers/wrangler/commands/#login) on how to log in to your Cloudflare account if you do need that. + +The underlying Cloudflare SDK reads flag data from a Cloudflare KV namespace rather than connecting to LaunchDarkly to fetch flags; this example configures the provider to send analytics events to LaunchDarkly in the background (see the flush comment in [src/index.ts](./src/index.ts)). The steps below seed a local KV namespace with the sample flag data in [src/testData.json](./src/testData.json), so you can run the example without configuring the integration first. + +## Build instructions + +1. [src/index.ts](./src/index.ts) ships with `clientSideID` hardcoded to a placeholder value: + + ```ts + const clientSideID = 'test-client-side-id'; + ``` + + This placeholder matches the key the steps below seed in local KV (and what the test suite + uses), so you can follow this walkthrough without editing it. For actual use, replace it with + your real client-side ID: + + ```ts + const clientSideID = 'my-client-side-id'; + ``` + + If you do this, remember to also seed local KV under the new key in step 5 below, and to + revert the edit (or not commit it) before running + `yarn workspace @launchdarkly/hello-openfeature-cloudflare-server test`, since the test suite + expects the placeholder value. + +2. If there is an existing boolean feature flag in your LaunchDarkly project that you want to evaluate, set `flagKey` in [src/index.ts](./src/index.ts) to the flag key: + + ```ts + const flagKey = 'my-flag-key'; + ``` + + Otherwise, `sample-feature` will be used by default. + + Note that the local seed data in [src/testData.json](./src/testData.json) only contains the + `sample-feature` flag. If you point `flagKey` at your own flag key, you'll see + `evaluates to false` in the response, but the SDK will log + `Unknown feature flag ""; returning default value` to the console explaining why - + you'll need real KV data (from the LaunchDarkly Cloudflare integration) for that flag to + evaluate correctly. + +3. Build the SDK and this example. At the root of the js-core repo: + + ```bash + yarn && yarn build + ``` + +4. The placeholder `YOUR_KV_ID` and `YOUR_PREVIEW_KV_ID` values in [wrangler.toml](./wrangler.toml) work fine as-is for the local walkthrough below; `wrangler dev` and `wrangler kv` with `--local` operate against Wrangler's local simulated storage regardless of what the configured namespace ID looks like. You only need to replace them with your own Cloudflare KV namespace IDs if you want to deploy this worker or seed the real remote preview KV namespace: + + ```toml + kv_namespaces = [{ binding = "LD_KV", id = "YOUR_KV_ID", preview_id = "YOUR_PREVIEW_KV_ID" }] + ``` + +5. Seed the local KV namespace with the sample flag data. The key must be your client-side ID prefixed with `LD-Env-`; the Cloudflare SDK uses that prefix to distinguish LaunchDarkly data from other data in the namespace. In the example below the client-side ID is the `test-client-side-id` placeholder that ships in [src/index.ts](./src/index.ts). + + `yarn start` (`wrangler dev`) runs entirely against Wrangler's local simulated storage by default, so the `--local` flag below is required for the seeded data to be visible to the running example. Without `--local`, the data is written to the actual remote preview KV namespace instead, which the default (local) `wrangler dev` mode used in this README does not read. + + ```bash + npx wrangler kv key put --binding=LD_KV "LD-Env-test-client-side-id" --path ./src/testData.json --preview --local + ``` + + (substitute your own client-side ID for `test-client-side-id` here if you set one in step 1) + +6. View that data to confirm it is present: + + ```bash + npx wrangler kv key get --binding=LD_KV "LD-Env-test-client-side-id" --preview --local + ``` + +7. On the command line, run the worker: + + ```bash + yarn start + ``` + + Then open the URL that `wrangler dev` prints (`http://localhost:8787` by default). The page shows the message: + + > The sample-feature feature flag evaluates to true. + + in white text on a green (`#00844B`) background, because the flag evaluates to `true`. If it evaluated to `false`, the background would be dark grey (`#373841`). + + If you're using a placeholder or otherwise non-real client-side ID, the `wrangler dev` console will show an event-flush error line, something like `flushed events result: false, error: ...404...`. This is expected and does not mean the flag evaluation demo failed; only event delivery to LaunchDarkly is affected. + +Because a Cloudflare Worker only runs while it is handling a request, each request performs one flag evaluation. Update the flag data in KV and send another request to see the new value. + +## Running the tests + +The test is an end-to-end test: it loads the running worker in a headless browser. The first time you run it, install that browser: + +```bash +yarn workspace @launchdarkly/hello-openfeature-cloudflare-server playwright install chromium +``` + +Then: + +```bash +yarn workspace @launchdarkly/hello-openfeature-cloudflare-server test +``` + +That command builds the worker, seeds the local KV namespace with [src/testData.json](./src/testData.json) under the `clientSideID` constant hardcoded in [src/index.ts](./src/index.ts) (the same command as step 5 above, also available on its own as `yarn seed-kv`), and then hands off to [Playwright](https://playwright.dev/), which starts `wrangler dev` for you, opens `http://localhost:8787` in headless Chromium, and asserts the rendered page reports `feature flag evaluates to true` in white text on the green background. + +If you already have `yarn start` running, Playwright reuses it instead of starting a second server. diff --git a/packages/sdk/openfeature-cloudflare-server/examples/getting-started/e2e/verify.spec.ts b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/e2e/verify.spec.ts new file mode 100644 index 0000000000..d7ab1744fd --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/e2e/verify.spec.ts @@ -0,0 +1,17 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { expect, test as it } from '@playwright/test'; + +it('renders the flag value in white on the toggle-on background color', async ({ page }) => { + await page.goto('/'); + + // The seeded flag data in src/testData.json evaluates sample-feature to true. + await expect(page.locator('#flag-value')).toHaveText('The sample-feature feature flag evaluates to true.', { + timeout: 10_000, + }); + + // The message is displayed in #FFFFFF. + await expect(page.locator('#flag-value')).toHaveCSS('color', 'rgb(255, 255, 255)'); + + // The background is #00844B because the flag evaluates to true. + await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(0, 132, 75)'); +}); diff --git a/packages/sdk/openfeature-cloudflare-server/examples/getting-started/package.json b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/package.json new file mode 100644 index 0000000000..d588454f8d --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/package.json @@ -0,0 +1,36 @@ +{ + "name": "@launchdarkly/hello-openfeature-cloudflare-server", + "private": true, + "version": "0.1.0", + "type": "module", + "module": "./dist/index.mjs", + "dependencies": { + "@launchdarkly/openfeature-cloudflare-server": "0.1.0", + "@openfeature/core": "^1.10.0", + "@openfeature/server-sdk": "^1.16.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20230321.0", + "@eslint/js": "^9.0.0", + "@playwright/test": "^1.49.1", + "eslint": "^9.0.0", + "eslint-import-resolver-typescript": "^4.0.0", + "eslint-plugin-import-x": "^4.0.0", + "eslint-plugin-jest": "^28.0.0", + "globals": "^16.0.0", + "playwright": "^1.49.1", + "tsup": "^8.5.1", + "typescript": "5.1.6", + "typescript-eslint": "^8.0.0", + "wrangler": "3.114.17" + }, + "scripts": { + "build": "tsup", + "start": "wrangler dev", + "deploy": "wrangler deploy", + "lint": "eslint .", + "seed-kv": "wrangler kv key put --binding=LD_KV LD-Env-test-client-side-id --path ./src/testData.json --preview --local", + "test": "yarn build && yarn seed-kv && playwright test", + "clean": "rm -rf dist && rm -rf node_modules && rm -rf .yarn/cache && yarn build" + } +} diff --git a/packages/sdk/openfeature-cloudflare-server/examples/getting-started/playwright.config.ts b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/playwright.config.ts new file mode 100644 index 0000000000..3475445b85 --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/playwright.config.ts @@ -0,0 +1,21 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + reporter: [['list']], + use: { + baseURL: 'http://localhost:8787', + }, + webServer: { + // `yarn start` is `wrangler dev`, which builds the worker and serves it from + // Wrangler's local simulated storage on port 8787 by default. + command: 'yarn start', + url: 'http://localhost:8787', + // Building the worker and booting workerd can take longer than Playwright's + // 60s default allows for on a cold CI runner. + timeout: 120_000, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/packages/sdk/openfeature-cloudflare-server/examples/getting-started/src/bindings.d.ts b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/src/bindings.d.ts new file mode 100644 index 0000000000..850dd398bf --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/src/bindings.d.ts @@ -0,0 +1,3 @@ +interface Bindings { + LD_KV: KVNamespace; +} diff --git a/packages/sdk/openfeature-cloudflare-server/examples/getting-started/src/index.ts b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/src/index.ts new file mode 100644 index 0000000000..e82ab75521 --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/src/index.ts @@ -0,0 +1,84 @@ +import { OpenFeature } from '@openfeature/server-sdk'; + +import { LaunchDarklyProvider } from '@launchdarkly/openfeature-cloudflare-server'; + +// Set clientSideID to your client-side ID. This placeholder matches what the +// test suite seeds into local KV, so the example runs out of the box; swap it +// for your real client-side ID before real use. +const clientSideID = 'test-client-side-id'; + +// Set flagKey to the feature flag key you want to evaluate. +const flagKey = 'sample-feature'; + +// This context should appear on your LaunchDarkly contexts dashboard shortly after you run the demo. +const context = { + kind: 'user', + targetingKey: 'example-user-key', + name: 'Sandy', +}; + +// LaunchDarkly's dark mode toggle colors: off for false, on for true. +const toggleOffColor = '#373841'; +const toggleOnColor = '#00844B'; + +// OpenFeature.setProviderAndWait registers the provider on a global registry shared by +// every concurrent request in this isolate. Calling it per request would race concurrent +// invocations against each other, including each other's cleanup. Initialize once per +// isolate instead and reuse the client for every subsequent request, per +// https://github.com/launchdarkly/js-core/tree/main/packages/sdk/cloudflare#usage +// ("Applications should instantiate a single instance for the lifetime of the worker"). +// The check-then-set below has no `await` between the check and the assignment, so it +// can't race even under concurrent requests, since Workers runs a single-threaded event loop. +let provider: LaunchDarklyProvider | undefined; +let providerReady: Promise | undefined; + +function ensureProviderReady(env: Bindings): Promise { + if (!providerReady) { + provider = new LaunchDarklyProvider(clientSideID, env.LD_KV); + providerReady = OpenFeature.setProviderAndWait(provider); + } + return providerReady; +} + +export default { + async fetch(request: Request, env: Bindings, ctx: ExecutionContext): Promise { + await ensureProviderReady(env); + + const client = OpenFeature.getClient(); + const flagValue = await client.getBooleanValue(flagKey, false, context); + + const message = `The ${flagKey} feature flag evaluates to ${flagValue}.`; + const background = flagValue ? toggleOnColor : toggleOffColor; + + // Gotcha: flush on every request, or events queued here never reach LD's servers once + // this isolate recycles. Unlike provider registration above, flushing is scoped to + // this request only, so it's safe to call every time. Must flush inside waitUntil: + // without it, the Response below can return and tear down the Worker before an + // unflushed event batch finishes sending. + // https://developers.cloudflare.com/workers/runtime-apis/fetch-event/#waituntil + ctx.waitUntil( + provider!.getClient().flush((err: Error | null, res: boolean) => { + console.log(`flushed events result: ${res}, error: ${err}`); + }), + ); + + // The worker renders the page itself, so the flag value is styled inline. + // Both interpolations are safe to inline unescaped: flagKey is developer-supplied + // source code, not runtime input, and flagValue is a boolean. + const html = ` + + + + LaunchDarkly OpenFeature Cloudflare example + + +

${message}

+ + +`; + + return new Response(html, { + headers: { 'content-type': 'text/html; charset=utf-8' }, + }); + }, +}; diff --git a/packages/sdk/openfeature-cloudflare-server/examples/getting-started/src/testData.json b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/src/testData.json new file mode 100644 index 0000000000..6685737bf3 --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/src/testData.json @@ -0,0 +1,28 @@ +{ + "flags": { + "sample-feature": { + "key": "sample-feature", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [], + "fallthrough": { + "variation": 0 + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": null, + "version": 1, + "deleted": false + } + }, + "segments": {} +} diff --git a/packages/sdk/openfeature-cloudflare-server/examples/getting-started/tsconfig.json b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/tsconfig.json new file mode 100644 index 0000000000..a9541158f5 --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": [ + "es2021" + ], + "jsx": "react", + "esModuleInterop": true, + "module": "es2022", + "moduleResolution": "node", + "types": [ + "@cloudflare/workers-types" + ] , + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +} diff --git a/packages/sdk/openfeature-cloudflare-server/examples/getting-started/tsup.config.ts b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/tsup.config.ts new file mode 100644 index 0000000000..cd5500b4ce --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/tsup.config.ts @@ -0,0 +1,19 @@ +// It is a dev dependency and the linter doesn't understand. +// @ts-ignore - tsup is a dev dependency installed at runtime +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { index: 'src/index.ts' }, + format: ['esm'], + target: 'esnext', + sourcemap: true, + clean: true, + platform: 'node', + external: ['__STATIC_CONTENT_MANIFEST'], + noExternal: [/^@launchdarkly\//, /^@openfeature\//], + outExtension: () => ({ js: '.mjs' }), + esbuildOptions(opts) { + opts.conditions = ['worker', 'browser']; + }, +}); diff --git a/packages/sdk/openfeature-cloudflare-server/examples/getting-started/wrangler.toml b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/wrangler.toml new file mode 100644 index 0000000000..80d2e500ac --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/examples/getting-started/wrangler.toml @@ -0,0 +1,11 @@ +name = "example" +main = "dist/index.mjs" +# Must be >= 2024-09-23 (the nodejs_compat v2 threshold): tsup's build strips the +# "node:" prefix from the SDK's internal `events` import, and only nodejs_compat v2 +# auto-polyfills that bare form. Don't lower this without re-verifying the build. +compatibility_date = "2024-09-23" +compatibility_flags = [ "nodejs_compat" ] +kv_namespaces = [{ binding = "LD_KV", id = "YOUR_KV_ID", preview_id = "YOUR_PREVIEW_KV_ID" }] + +[build] +command = "npx tsup" diff --git a/release-please-config.json b/release-please-config.json index dfad7ea287..32007686eb 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -440,7 +440,14 @@ }, "packages/sdk/openfeature-cloudflare-server": { "bump-minor-pre-major": true, - "prerelease": true + "prerelease": true, + "extra-files": [ + { + "type": "json", + "path": "examples/getting-started/package.json", + "jsonpath": "$.dependencies['@launchdarkly/openfeature-cloudflare-server']" + } + ] }, "packages/sdk/vue": { "bump-minor-pre-major": true,