From 467abeb755e4b8645689bb5cfb4e2d43ffeff7ab Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 12:48:01 +0200 Subject: [PATCH 1/5] ci: Run the Flagsmith SDK behavioural spec --- .github/workflows/sdk-spec.yml | 16 +++++ .npmignore | 1 + renovate.json | 14 ++++ spec-adapter/Dockerfile | 24 +++++++ spec-adapter/adapter.mts | 113 +++++++++++++++++++++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 .github/workflows/sdk-spec.yml create mode 100644 spec-adapter/Dockerfile create mode 100644 spec-adapter/adapter.mts diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml new file mode 100644 index 00000000..6cbf98bf --- /dev/null +++ b/.github/workflows/sdk-spec.yml @@ -0,0 +1,16 @@ +name: SDK spec + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + sdk-spec: + # Keep the ref in `uses:` and `spec-ref` in sync. + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.1.1 + with: + spec-ref: v0.1.1 diff --git a/.npmignore b/.npmignore index c4c7620d..30c43b62 100644 --- a/.npmignore +++ b/.npmignore @@ -1 +1,2 @@ ./.idea +spec-adapter/ diff --git a/renovate.json b/renovate.json index b7a5f2e9..b1830e23 100644 --- a/renovate.json +++ b/renovate.json @@ -4,6 +4,15 @@ "ignorePresets": [":semanticPrefixFixDepsChoreOthers"], "semanticCommitType": "deps", "git-submodules": { "enabled": true }, + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": ["/^\\.github/workflows/sdk-spec\\.yml$/"], + "matchStrings": ["spec-ref: (?v[\\d.]+)"], + "depNameTemplate": "Flagsmith/sdk-spec", + "datasourceTemplate": "github-tags" + } + ], "packageRules": [ { "matchUpdateTypes": [ @@ -23,6 +32,11 @@ "matchManagers": ["git-submodules"], "versioning": "semver-coerced", "enabled": true + }, + { + "matchPackageNames": ["Flagsmith/sdk-spec"], + "groupName": "SDK spec", + "enabled": true } ] } diff --git a/spec-adapter/Dockerfile b/spec-adapter/Dockerfile new file mode 100644 index 00000000..9efa32a4 --- /dev/null +++ b/spec-adapter/Dockerfile @@ -0,0 +1,24 @@ +# Builds the SDK from source with the adapter for the Flagsmith SDK behavioural spec. +# Build from the repository root: docker build -f spec-adapter/Dockerfile . +FROM node:24-slim AS build + +WORKDIR /sdk +COPY package.json package-lock.json tsconfig*.json .npmignore ./ +# Skip the `prepare` script, which installs Git hooks. +RUN npm ci --ignore-scripts +COPY index.ts ./ +COPY sdk ./sdk +COPY flagsmith-engine ./flagsmith-engine +RUN npm run build && npm pack --ignore-scripts --pack-destination /tmp + +FROM node:24-slim + +WORKDIR /adapter +COPY --from=build /tmp/flagsmith-nodejs-*.tgz /tmp/ +RUN npm install --no-save --no-audit --no-fund /tmp/flagsmith-nodejs-*.tgz && rm /tmp/*.tgz + +COPY spec-adapter/adapter.mts ./ + +ENV PORT=8080 +EXPOSE 8080 +CMD ["node", "adapter.mts"] diff --git a/spec-adapter/adapter.mts b/spec-adapter/adapter.mts new file mode 100644 index 00000000..e8f17f1f --- /dev/null +++ b/spec-adapter/adapter.mts @@ -0,0 +1,113 @@ +/** + * Adapter that lets the Flagsmith SDK behavioural spec drive this SDK. + * + * Protocol: https://github.com/Flagsmith/sdk-spec/blob/main/ADAPTER.md + */ + +import { readFileSync } from 'node:fs'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { Flagsmith, type Flags } from '@flagsmith/nodejs'; + +const SDK_PACKAGE = '@flagsmith/nodejs'; +const { version } = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.resolve(SDK_PACKAGE)), 'utf8') +); + +const clients = new Map(); +let lastClientId = 0; + +const CLIENT_ROUTE = /^\/clients\/(?[^/]+)(?\/[a-z-]+)?$/; + +function serialiseFlags(flags: Flags) { + return { + flags: flags.allFlags().map(flag => ({ + featureName: flag.featureName, + enabled: flag.enabled, + // The SDK reports a null value as `undefined`, which JSON would drop. + value: flag.value ?? null + })) + }; +} + +function errorMessage(error: unknown): string { + if (!(error instanceof Error)) { + return String(error); + } + const message = `${error.name}: ${error.message}`; + return error.cause ? `${message} (caused by ${errorMessage(error.cause)})` : message; +} + +function send(res: ServerResponse, status: number, body?: unknown) { + const payload = body === undefined ? '' : JSON.stringify(body); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload) + }); + res.end(payload); +} + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const raw = Buffer.concat(chunks).toString(); + return raw ? JSON.parse(raw) : {}; +} + +async function route(req: IncomingMessage, res: ServerResponse) { + const { method, url: path } = req; + + if (method === 'GET' && path === '/health') { + return send(res, 200, { sdk: 'flagsmith-nodejs-client', version }); + } + + if (method === 'POST' && path === '/clients') { + const body = await readBody(req); + const clientId = String(++lastClientId); + clients.set( + clientId, + new Flagsmith({ environmentKey: body.environmentKey, apiUrl: body.apiUrl }) + ); + return send(res, 201, { clientId }); + } + + const match = CLIENT_ROUTE.exec(path ?? ''); + const client = match && clients.get(match.groups!.clientId); + if (!match || !client) { + return send(res, 404, { error: `No route for ${method} ${path}` }); + } + const { clientId, action } = match.groups!; + + if (method === 'DELETE' && action === undefined) { + clients.delete(clientId); + await client.close(); + return send(res, 204); + } + + if (method === 'POST' && action === '/environment-flags') { + return send(res, 200, serialiseFlags(await client.getEnvironmentFlags())); + } + + if (method === 'POST' && action === '/identity-flags') { + const body = await readBody(req); + const flags = await client.getIdentityFlags(body.identifier, body.traits ?? {}); + return send(res, 200, serialiseFlags(flags)); + } + + return send(res, 404, { error: `No route for ${method} ${path}` }); +} + +const server = createServer(async (req, res) => { + try { + await route(req, res); + } catch (error) { + // Report SDK errors to the runner. + console.error(error); + send(res, 500, { error: errorMessage(error) }); + } + console.log(`adapter: ${req.method} ${req.url} ${res.statusCode}`); +}); + +const port = Number(process.env.PORT ?? 8080); +server.listen(port, () => console.log(`adapter: listening on :${port}`)); From 8c821838fa8054525e73966a4606714b032c636b Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 13:07:19 +0200 Subject: [PATCH 2/5] ci: Implement the SDK spec as a steps file --- .github/workflows/sdk-spec.yml | 5 +- .npmignore | 2 +- {spec-adapter => sdk-spec}/Dockerfile | 17 ++-- sdk-spec/steps.mts | 34 ++++++++ spec-adapter/adapter.mts | 113 -------------------------- 5 files changed, 46 insertions(+), 125 deletions(-) rename {spec-adapter => sdk-spec}/Dockerfile (51%) create mode 100644 sdk-spec/steps.mts delete mode 100644 spec-adapter/adapter.mts diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index 6cbf98bf..fc05d17c 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,6 +11,7 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.1.1 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@feat/v0.2.0 with: - spec-ref: v0.1.1 + spec-ref: feat/v0.2.0 + language: nodejs diff --git a/.npmignore b/.npmignore index 30c43b62..f13707b8 100644 --- a/.npmignore +++ b/.npmignore @@ -1,2 +1,2 @@ ./.idea -spec-adapter/ +sdk-spec/ diff --git a/spec-adapter/Dockerfile b/sdk-spec/Dockerfile similarity index 51% rename from spec-adapter/Dockerfile rename to sdk-spec/Dockerfile index 9efa32a4..faea9da2 100644 --- a/spec-adapter/Dockerfile +++ b/sdk-spec/Dockerfile @@ -1,6 +1,6 @@ -# Builds the SDK from source with the adapter for the Flagsmith SDK behavioural spec. -# Build from the repository root: docker build -f spec-adapter/Dockerfile . -FROM node:24-slim AS build +# Runs this SDK, built from source, under the Flagsmith SDK behavioural spec. +# docker build -f sdk-spec/Dockerfile --build-context harness=/harness/nodejs . +FROM docker.io/library/node:24-slim AS build WORKDIR /sdk COPY package.json package-lock.json tsconfig*.json .npmignore ./ @@ -11,14 +11,13 @@ COPY sdk ./sdk COPY flagsmith-engine ./flagsmith-engine RUN npm run build && npm pack --ignore-scripts --pack-destination /tmp -FROM node:24-slim +FROM docker.io/library/node:24-slim -WORKDIR /adapter +WORKDIR /sdk COPY --from=build /tmp/flagsmith-nodejs-*.tgz /tmp/ RUN npm install --no-save --no-audit --no-fund /tmp/flagsmith-nodejs-*.tgz && rm /tmp/*.tgz -COPY spec-adapter/adapter.mts ./ +COPY --from=harness harness.mts /harness/harness.mts +COPY sdk-spec/steps.mts ./sdk-spec/steps.mts -ENV PORT=8080 -EXPOSE 8080 -CMD ["node", "adapter.mts"] +ENTRYPOINT ["node", "/harness/harness.mts", "sdk-spec/steps.mts"] diff --git a/sdk-spec/steps.mts b/sdk-spec/steps.mts new file mode 100644 index 00000000..3f06230c --- /dev/null +++ b/sdk-spec/steps.mts @@ -0,0 +1,34 @@ +/** + * Steps for the Flagsmith SDK behavioural spec: https://github.com/Flagsmith/sdk-spec + * + * Each function implements the Gherkin step in the comment above it. + */ + +import { Flagsmith, type Flags, type FlagsmithValue } from '@flagsmith/nodejs'; + +function asSpecFlags(flags: Flags) { + return flags.allFlags().map(flag => ({ + feature: flag.featureName, + enabled: flag.enabled, + value: flag.value + })); +} + +// Given an SDK client for the environment +export function createClient(environmentKey: string, apiUrl: string) { + return new Flagsmith({ environmentKey, apiUrl }); +} + +// When the application gets the environment flags +export async function getEnvironmentFlags(flagsmith: Flagsmith) { + return asSpecFlags(await flagsmith.getEnvironmentFlags()); +} + +// When the application gets the flags for identity "{identifier}" with traits: +export async function getIdentityFlags( + flagsmith: Flagsmith, + identifier: string, + traits: Record +) { + return asSpecFlags(await flagsmith.getIdentityFlags(identifier, traits)); +} diff --git a/spec-adapter/adapter.mts b/spec-adapter/adapter.mts deleted file mode 100644 index e8f17f1f..00000000 --- a/spec-adapter/adapter.mts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Adapter that lets the Flagsmith SDK behavioural spec drive this SDK. - * - * Protocol: https://github.com/Flagsmith/sdk-spec/blob/main/ADAPTER.md - */ - -import { readFileSync } from 'node:fs'; -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; -import { Flagsmith, type Flags } from '@flagsmith/nodejs'; - -const SDK_PACKAGE = '@flagsmith/nodejs'; -const { version } = JSON.parse( - readFileSync(new URL('../../package.json', import.meta.resolve(SDK_PACKAGE)), 'utf8') -); - -const clients = new Map(); -let lastClientId = 0; - -const CLIENT_ROUTE = /^\/clients\/(?[^/]+)(?\/[a-z-]+)?$/; - -function serialiseFlags(flags: Flags) { - return { - flags: flags.allFlags().map(flag => ({ - featureName: flag.featureName, - enabled: flag.enabled, - // The SDK reports a null value as `undefined`, which JSON would drop. - value: flag.value ?? null - })) - }; -} - -function errorMessage(error: unknown): string { - if (!(error instanceof Error)) { - return String(error); - } - const message = `${error.name}: ${error.message}`; - return error.cause ? `${message} (caused by ${errorMessage(error.cause)})` : message; -} - -function send(res: ServerResponse, status: number, body?: unknown) { - const payload = body === undefined ? '' : JSON.stringify(body); - res.writeHead(status, { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(payload) - }); - res.end(payload); -} - -async function readBody(req: IncomingMessage): Promise { - const chunks: Buffer[] = []; - for await (const chunk of req) { - chunks.push(chunk); - } - const raw = Buffer.concat(chunks).toString(); - return raw ? JSON.parse(raw) : {}; -} - -async function route(req: IncomingMessage, res: ServerResponse) { - const { method, url: path } = req; - - if (method === 'GET' && path === '/health') { - return send(res, 200, { sdk: 'flagsmith-nodejs-client', version }); - } - - if (method === 'POST' && path === '/clients') { - const body = await readBody(req); - const clientId = String(++lastClientId); - clients.set( - clientId, - new Flagsmith({ environmentKey: body.environmentKey, apiUrl: body.apiUrl }) - ); - return send(res, 201, { clientId }); - } - - const match = CLIENT_ROUTE.exec(path ?? ''); - const client = match && clients.get(match.groups!.clientId); - if (!match || !client) { - return send(res, 404, { error: `No route for ${method} ${path}` }); - } - const { clientId, action } = match.groups!; - - if (method === 'DELETE' && action === undefined) { - clients.delete(clientId); - await client.close(); - return send(res, 204); - } - - if (method === 'POST' && action === '/environment-flags') { - return send(res, 200, serialiseFlags(await client.getEnvironmentFlags())); - } - - if (method === 'POST' && action === '/identity-flags') { - const body = await readBody(req); - const flags = await client.getIdentityFlags(body.identifier, body.traits ?? {}); - return send(res, 200, serialiseFlags(flags)); - } - - return send(res, 404, { error: `No route for ${method} ${path}` }); -} - -const server = createServer(async (req, res) => { - try { - await route(req, res); - } catch (error) { - // Report SDK errors to the runner. - console.error(error); - send(res, 500, { error: errorMessage(error) }); - } - console.log(`adapter: ${req.method} ${req.url} ${res.statusCode}`); -}); - -const port = Number(process.env.PORT ?? 8080); -server.listen(port, () => console.log(`adapter: listening on :${port}`)); From 266187dc1f0d3fcd0806751c9fafd5eccd942fc3 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 13:34:25 +0200 Subject: [PATCH 3/5] ci: Pin the SDK spec to v0.2.0 --- .github/workflows/sdk-spec.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index fc05d17c..44571bed 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,7 +11,7 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@feat/v0.2.0 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.2.0 with: - spec-ref: feat/v0.2.0 + spec-ref: v0.2.0 language: nodejs From e72bb28a55598342ff4868d2da48e4ec3511a9d8 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 16:18:29 +0200 Subject: [PATCH 4/5] ci: Move to SDK spec v0.3.0 --- .github/workflows/sdk-spec.yml | 4 ++-- sdk-spec/steps.mts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sdk-spec.yml b/.github/workflows/sdk-spec.yml index 44571bed..cfb2ea1a 100644 --- a/.github/workflows/sdk-spec.yml +++ b/.github/workflows/sdk-spec.yml @@ -11,7 +11,7 @@ permissions: jobs: sdk-spec: # Keep the ref in `uses:` and `spec-ref` in sync. - uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.2.0 + uses: Flagsmith/sdk-spec/.github/workflows/run-spec.yml@v0.3.0 with: - spec-ref: v0.2.0 + spec-ref: v0.3.0 language: nodejs diff --git a/sdk-spec/steps.mts b/sdk-spec/steps.mts index 3f06230c..9d4b3c80 100644 --- a/sdk-spec/steps.mts +++ b/sdk-spec/steps.mts @@ -14,7 +14,7 @@ function asSpecFlags(flags: Flags) { })); } -// Given an SDK client for the environment +// Create the SDK client export function createClient(environmentKey: string, apiUrl: string) { return new Flagsmith({ environmentKey, apiUrl }); } From 20e22273e89e47951303fa2f81cee5cefd9e8353 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 21 Oct 2026 16:37:58 +0200 Subject: [PATCH 5/5] ci: Run the SDK spec against the v0.3.0 tag