diff --git a/.github/workflows/node-client.yml b/.github/workflows/node-client.yml index 521fff68f4..cba7f4ad5e 100644 --- a/.github/workflows/node-client.yml +++ b/.github/workflows/node-client.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: # Node versions to run on. - version: [18, 22] + version: [20, 22] steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -31,4 +31,19 @@ jobs: with: workspace_name: '@launchdarkly/node-client-sdk' workspace_path: packages/sdk/node-client - # TODO: Add contract tests + - name: Install contract test service dependencies + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + run: yarn workspace @launchdarkly/node-client-sdk-contract-tests install --no-immutable + - name: Build shared contract test utils + run: yarn workspace @launchdarkly/js-contract-test-utils build:client + - name: Build the test service + run: yarn workspace @launchdarkly/node-client-sdk-contract-tests build + - name: Launch the test service in the background + run: yarn workspace @launchdarkly/node-client-sdk-contract-tests start 2>&1 & + - name: Run contract tests (FDv1) + uses: launchdarkly/gh-actions/actions/contract-tests@5adb11fd6953e1bc35d9cf1fc1b4374c464e3a8b # contract-tests-v1.3.0 + with: + test_service_port: 8000 + token: ${{ secrets.GITHUB_TOKEN }} + extra_params: '--skip-from=${{ github.workspace }}/packages/sdk/node-client/contract-tests/testharness-suppressions.txt' diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 9f8caa8e0a..ef4a1ee61f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -8,7 +8,7 @@ "packages/sdk/cloudflare": "2.7.25", "packages/sdk/combined-browser": "0.1.27", "packages/sdk/fastly": "0.2.15", - "packages/sdk/node-client": "0.0.4", + "packages/sdk/node-client": "0.0.1", "packages/sdk/react-native": "10.19.0", "packages/sdk/server-ai": "1.1.0", "packages/sdk/server-node": "9.11.2", diff --git a/package.json b/package.json index 51c375809a..179f79d54f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "packages/sdk/electron/example", "packages/sdk/electron/contract-tests/entity", "packages/sdk/node-client", + "packages/sdk/node-client/examples/hello-node-client", + "packages/sdk/node-client/contract-tests", "packages/sdk/fastly", "packages/sdk/fastly/example", "packages/sdk/react", diff --git a/packages/sdk/node-client/contract-tests/README.md b/packages/sdk/node-client/contract-tests/README.md new file mode 100644 index 0000000000..eee3e0543a --- /dev/null +++ b/packages/sdk/node-client/contract-tests/README.md @@ -0,0 +1,41 @@ +# Node Client SDK Contract Tests + +This directory contains the contract test implementation for the LaunchDarkly Client-Side SDK for Node.js using the [SDK Test Harness](https://github.com/launchdarkly/sdk-test-harness). + +The contract test service is an Express server that exposes a REST API on port 8000. The test harness sends commands to this service, which creates and manages SDK client instances and executes flag evaluations, events, and other operations. + +## Running locally + +From the SDK package directory (`packages/sdk/node-client`): + +```bash +yarn contract-tests +``` + +This builds the SDK and the contract-test service, starts the service in the background on port 8000, downloads the matching `sdk-test-harness` binary, and runs the harness against the service. The harness shuts the service down when it finishes via `-stop-service-at-end`. + +To run the service on its own (e.g. when iterating against a local checkout of `sdk-test-harness`): + +```bash +yarn contract-test-service +``` + +Then run the harness from your local clone in another terminal. + +## Suppressions + +Two suppression files cover tests that are not yet supported or are known to differ: + +- `testharness-suppressions.txt` -- default +- `testharness-suppressions-fdv2.txt` -- when running the harness from the `feat/fdv2` branch + +Override the suppressions file by setting the `SUPPRESSIONS` environment variable: + +```bash +SUPPRESSIONS=./contract-tests/testharness-suppressions-fdv2.txt yarn contract-tests +``` + +## Other environment variables + +- `TEST_HARNESS_PARAMS` -- extra params appended to the harness command line (e.g. `-run TestName`). +- `VERSION` -- the major version of `sdk-test-harness` to download. Defaults to `v2`. diff --git a/packages/sdk/node-client/contract-tests/package.json b/packages/sdk/node-client/contract-tests/package.json new file mode 100644 index 0000000000..50fff7a788 --- /dev/null +++ b/packages/sdk/node-client/contract-tests/package.json @@ -0,0 +1,26 @@ +{ + "name": "@launchdarkly/node-client-sdk-contract-tests", + "version": "0.0.0", + "main": "dist/src/index.js", + "scripts": { + "start": "node --inspect dist/src/index.js", + "build": "tsc", + "dev": "tsc --watch" + }, + "type": "module", + "author": "", + "license": "Apache-2.0", + "private": true, + "dependencies": { + "@launchdarkly/js-contract-test-utils": "workspace:^", + "@launchdarkly/node-client-sdk": "workspace:^", + "body-parser": "^1.19.0", + "express": "^4.17.1" + }, + "devDependencies": { + "@types/body-parser": "^1.19.2", + "@types/express": "^4.17.13", + "@types/node": "^18.11.9", + "typescript": "^5.5.3" + } +} diff --git a/packages/sdk/node-client/contract-tests/run-contract-tests.sh b/packages/sdk/node-client/contract-tests/run-contract-tests.sh new file mode 100755 index 0000000000..4903d48092 --- /dev/null +++ b/packages/sdk/node-client/contract-tests/run-contract-tests.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# Runs the SDK contract tests locally against a fresh build of @launchdarkly/node-client-sdk. +# +# Mirrors the GitHub Actions workflow at .github/workflows/node-client.yml: builds the SDK +# and its contract-test service, starts the service in the background, downloads the matching +# sdk-test-harness binary, and runs the harness against the service. +# +# Environment variables: +# SUPPRESSIONS Path to the suppressions file to pass via --skip-from. Defaults to +# ./testharness-suppressions.txt (next to this script). Use +# ./testharness-suppressions-fdv2.txt when running the harness from the +# feat/fdv2 branch. +# TEST_HARNESS_PARAMS Extra params appended to the harness command line. +# VERSION sdk-test-harness major version to download. Defaults to v2. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SUPPRESSIONS="${SUPPRESSIONS:-$SCRIPT_DIR/testharness-suppressions.txt}" +VERSION="${VERSION:-v2}" + +yarn workspaces foreach -pR --topological-dev --from '@launchdarkly/node-client-sdk' run build +yarn workspace @launchdarkly/js-contract-test-utils build:client +yarn workspace @launchdarkly/node-client-sdk-contract-tests build + +yarn workspace @launchdarkly/node-client-sdk-contract-tests start & +SERVICE_PID=$! +trap 'kill $SERVICE_PID 2>/dev/null || true' EXIT + +curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/main/downloader/run.sh \ + | VERSION="$VERSION" \ + PARAMS="-url http://localhost:8000 -debug -stop-service-at-end --skip-from=$SUPPRESSIONS $TEST_HARNESS_PARAMS" \ + sh diff --git a/packages/sdk/node-client/contract-tests/src/index.ts b/packages/sdk/node-client/contract-tests/src/index.ts new file mode 100644 index 0000000000..3ca91d9db1 --- /dev/null +++ b/packages/sdk/node-client/contract-tests/src/index.ts @@ -0,0 +1,122 @@ +import bodyParser from 'body-parser'; +import express, { Request, Response } from 'express'; +import { Server } from 'http'; + +import { ClientPool } from '@launchdarkly/js-contract-test-utils'; + +import { Log } from './log.js'; +import { badCommandError, newSdkClientEntity, SdkClientEntity } from './sdkClientEntity.js'; + +const app = express(); +let server: Server | null = null; + +const port = 8000; + +const clients = new ClientPool(); + +const mainLog = Log('service'); + +app.use(bodyParser.json()); + +app.get('/', (req: Request, res: Response) => { + res.header('Content-Type', 'application/json'); + res.json({ + capabilities: [ + 'client-side', + 'service-endpoints', + 'tags', + 'user-type', + 'inline-context', + 'inline-context-all', + 'client-prereq-events', + 'client-per-context-summaries', + 'evaluation-hooks', + 'track-hooks', + 'anonymous-redaction', + 'strongly-typed', + 'event-gzip', + 'flag-change-listeners', + 'tls:skip-verify-peer', + 'tls:custom-ca', + 'wrapper', + ], + }); +}); + +app.delete('/', (req: Request, res: Response) => { + mainLog.info('Test service has told us to exit'); + res.status(204); + res.send(); + + // Defer the following actions till after the response has been sent + setTimeout(() => { + if (server) { + server.close(() => process.exit()); + } + // We force-quit with process.exit because, even after closing the server, there could be some + // scheduled tasks lingering if an SDK instance didn't get cleaned up properly. + }, 1); +}); + +app.post('/', async (req: Request, res: Response) => { + const options = req.body; + + try { + const client = await newSdkClientEntity(options); + const clientId = clients.add(client); + + res.status(201); + res.set('Location', `/clients/${clientId}`); + } catch (e) { + res.status(500); + const message = e instanceof Error ? e.message : JSON.stringify(e); + mainLog.error(`Error creating client: ${message}`); + res.write(message); + } + res.send(); +}); + +app.post('/clients/:id', async (req: Request, res: Response) => { + const client = clients.get(req.params.id); + if (!client) { + res.status(404); + } else { + try { + const respValue = await client.doCommand(req.body); + if (respValue) { + res.status(200); + res.write(JSON.stringify(respValue)); + } else { + res.status(204); + } + } catch (e) { + const isBadRequest = e === badCommandError; + res.status(isBadRequest ? 400 : 500); + const message = e instanceof Error ? e.message : JSON.stringify(e); + res.write(message); + if (!isBadRequest && e instanceof Error && e.stack) { + // eslint-disable-next-line no-console + console.log(e.stack); + } + } + } + res.send(); +}); + +app.delete('/clients/:id', async (req: Request, res: Response) => { + const client = clients.get(req.params.id); + if (!client) { + res.status(404); + res.send(); + } else { + await client.close(); + clients.remove(req.params.id); + res.status(204); + res.send(); + } +}); + +server = app.listen(port, () => { + // eslint-disable-next-line no-console + console.log('Listening on port %d', port); +}); diff --git a/packages/sdk/node-client/contract-tests/src/log.ts b/packages/sdk/node-client/contract-tests/src/log.ts new file mode 100644 index 0000000000..23c6954406 --- /dev/null +++ b/packages/sdk/node-client/contract-tests/src/log.ts @@ -0,0 +1,27 @@ +import { basicLogger, LDLogger } from '@launchdarkly/node-client-sdk'; + +export interface Logger { + info: (message: string) => void; + error: (message: string) => void; +} + +export function Log(tag: string): Logger { + function doLog(level: string, message: string): void { + // eslint-disable-next-line no-console + console.log(`${new Date().toISOString()} [${tag}] ${level}: ${message}`); + } + return { + info: (message: string) => doLog('info', message), + error: (message: string) => doLog('error', message), + }; +} + +export function sdkLogger(tag: string): LDLogger { + return basicLogger({ + level: 'debug', + destination: (line: string) => { + // eslint-disable-next-line no-console + console.log(`${new Date().toISOString()} [${tag}.sdk] ${line}`); + }, + }); +} diff --git a/packages/sdk/node-client/contract-tests/src/sdkClientEntity.ts b/packages/sdk/node-client/contract-tests/src/sdkClientEntity.ts new file mode 100644 index 0000000000..712a14edec --- /dev/null +++ b/packages/sdk/node-client/contract-tests/src/sdkClientEntity.ts @@ -0,0 +1,375 @@ +import * as fs from 'fs'; + +import { + CommandParams, + CommandType, + CreateInstanceParams, + SDKConfigDataInitializer, + SDKConfigDataSynchronizer, + SDKConfigModeDefinition, + SDKConfigParams, + ValueType, +} from '@launchdarkly/js-contract-test-utils'; +import { ClientSideTestHook as TestHook } from '@launchdarkly/js-contract-test-utils/client'; +import { + createClient, + InitializerEntry, + LDClient, + LDContext, + LDOptions, + ModeDefinition, + SynchronizerEntry, +} from '@launchdarkly/node-client-sdk'; + +import { Log, sdkLogger } from './log.js'; + +const badCommandError = new Error('unsupported command'); +const malformedCommand = new Error('command was malformed'); +export { badCommandError }; + +function translateInitializer(init: SDKConfigDataInitializer): InitializerEntry | undefined { + if (init.polling) { + return { + type: 'polling', + ...(init.polling.pollIntervalMs !== undefined && { + pollInterval: init.polling.pollIntervalMs / 1000, + }), + ...(init.polling.baseUri && { + endpoints: { pollingBaseUri: init.polling.baseUri }, + }), + }; + } + return undefined; +} + +function translateSynchronizer(sync: SDKConfigDataSynchronizer): SynchronizerEntry | undefined { + if (sync.streaming) { + return { + type: 'streaming', + ...(sync.streaming.initialRetryDelayMs !== undefined && { + initialReconnectDelay: sync.streaming.initialRetryDelayMs / 1000, + }), + ...(sync.streaming.baseUri && { + endpoints: { streamingBaseUri: sync.streaming.baseUri }, + }), + }; + } + if (sync.polling) { + return { + type: 'polling', + ...(sync.polling.pollIntervalMs !== undefined && { + pollInterval: sync.polling.pollIntervalMs / 1000, + }), + ...(sync.polling.baseUri && { + endpoints: { pollingBaseUri: sync.polling.baseUri }, + }), + }; + } + return undefined; +} + +function translateModeDefinition(modeDef: SDKConfigModeDefinition): ModeDefinition { + const initializers: InitializerEntry[] = (modeDef.initializers ?? []) + .map(translateInitializer) + .filter((x): x is InitializerEntry => x !== undefined); + + const synchronizers: SynchronizerEntry[] = (modeDef.synchronizers ?? []) + .map(translateSynchronizer) + .filter((x): x is SynchronizerEntry => x !== undefined); + + return { initializers, synchronizers }; +} + +function makeSdkConfig(options: SDKConfigParams, tag: string): LDOptions { + if (!options.clientSide) { + throw new Error('configuration did not include clientSide options'); + } + + const isSet = (x?: unknown) => x !== null && x !== undefined; + const maybeTime = (seconds?: number) => (isSet(seconds) ? (seconds as number) / 1000 : undefined); + + const cf: LDOptions = { + logger: sdkLogger(tag), + diagnosticOptOut: true, + withReasons: options.clientSide.evaluationReasons, + useReport: options.clientSide.useReport ?? undefined, + disableCache: true, + }; + + if (options.serviceEndpoints) { + cf.streamUri = options.serviceEndpoints.streaming; + cf.baseUri = options.serviceEndpoints.polling; + cf.eventsUri = options.serviceEndpoints.events; + } + + if (options.dataSystem?.payloadFilter) { + cf.payloadFilterKey = options.dataSystem.payloadFilter; + } + + if (options.dataSystem) { + const dataSystem: Record = {}; + + const applyEndpointOverrides = (modeDef: SDKConfigModeDefinition) => { + (modeDef.synchronizers ?? []).forEach((sync) => { + if (sync.streaming?.baseUri) { + cf.streamUri = sync.streaming.baseUri; + cf.streamInitialReconnectDelay = maybeTime(sync.streaming.initialRetryDelayMs); + } + if (sync.polling?.baseUri) { + cf.baseUri = sync.polling.baseUri; + } + }); + (modeDef.initializers ?? []).forEach((init) => { + if (init.polling?.baseUri) { + cf.baseUri = init.polling.baseUri; + } + }); + }; + + if (options.dataSystem.connectionModeConfig) { + const connMode = options.dataSystem.connectionModeConfig; + dataSystem.automaticModeSwitching = connMode.initialConnectionMode + ? { type: 'manual', initialConnectionMode: connMode.initialConnectionMode } + : false; + + if (connMode.customConnectionModes) { + const connectionModes: Record = {}; + Object.entries(connMode.customConnectionModes).forEach(([modeName, modeDef]) => { + connectionModes[modeName] = translateModeDefinition(modeDef); + applyEndpointOverrides(modeDef); + }); + dataSystem.connectionModes = connectionModes; + } + } else if (options.dataSystem.initializers || options.dataSystem.synchronizers) { + const modeDef: SDKConfigModeDefinition = { + initializers: options.dataSystem.initializers, + synchronizers: options.dataSystem.synchronizers, + }; + dataSystem.automaticModeSwitching = { + type: 'manual', + initialConnectionMode: 'streaming', + }; + dataSystem.connectionModes = { + streaming: translateModeDefinition(modeDef), + }; + applyEndpointOverrides(modeDef); + } + + (cf as any).dataSystem = dataSystem; + } else { + if (options.streaming) { + if (options.streaming.baseUri) { + cf.streamUri = options.streaming.baseUri; + } + cf.initialConnectionMode = 'streaming'; + cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs); + } else if (options.polling) { + cf.initialConnectionMode = 'polling'; + } + + if (options.polling) { + if (options.polling.baseUri) { + cf.baseUri = options.polling.baseUri; + } + } + } + + if (options.events) { + if (options.events.baseUri) { + cf.eventsUri = options.events.baseUri; + } + cf.allAttributesPrivate = options.events.allAttributesPrivate; + cf.capacity = options.events.capacity; + cf.diagnosticOptOut = !options.events.enableDiagnostics; + cf.flushInterval = maybeTime(options.events.flushIntervalMs); + cf.privateAttributes = options.events.globalPrivateAttributes; + if (options.events.enableGzip) { + cf.enableEventCompression = true; + } + } else { + cf.sendEvents = false; + } + + if (options.tls) { + cf.tlsParams = {}; + if (options.tls.skipVerifyPeer) { + cf.tlsParams.rejectUnauthorized = false; + } + if (options.tls.customCAFile) { + cf.tlsParams.ca = fs.readFileSync(options.tls.customCAFile); + } + } + + if (options.tags) { + cf.applicationInfo = { + id: options.tags.applicationId, + version: options.tags.applicationVersion, + }; + } + + if (options.hooks) { + cf.hooks = TestHook.forClient(options.hooks.hooks); + } + + if (options.wrapper) { + if (options.wrapper.name) { + cf.wrapperName = options.wrapper.name; + } + if (options.wrapper.version) { + cf.wrapperVersion = options.wrapper.version; + } + } + + return cf; +} + +function makeDefaultInitialContext(): LDContext { + return { kind: 'user', key: 'key-not-specified' }; +} + +export interface SdkClientEntity { + close: () => Promise; + doCommand: (params: CommandParams) => Promise; +} + +type FlagChangeListener = (...args: unknown[]) => void; + +export async function newSdkClientEntity(options: CreateInstanceParams): Promise { + const c: any = {}; + const log = Log(options.tag); + const listeners = new Map(); + + log.info(`Creating client with configuration: ${JSON.stringify(options.configuration)}`); + + const timeout = + options.configuration.startWaitTimeMs !== null && + options.configuration.startWaitTimeMs !== undefined + ? options.configuration.startWaitTimeMs + : 5000; + const sdkConfig = makeSdkConfig(options.configuration, options.tag); + const initialContext: LDContext = + (options.configuration.clientSide?.initialUser as LDContext) || + (options.configuration.clientSide?.initialContext as LDContext) || + makeDefaultInitialContext(); + const client: LDClient = createClient( + options.configuration.credential || 'unknown-env-id', + initialContext, + sdkConfig, + ); + const startResult = await client.start({ timeout: timeout / 1000 }); + const failed = startResult.status !== 'complete'; + if (failed && !options.configuration.initCanFail) { + await client.close(); + throw new Error('client initialization failed'); + } + + c.close = async () => { + await client.close(); + log.info('Test ended'); + }; + + c.doCommand = async (params: CommandParams) => { + log.info(`Received command: ${params.command}`); + switch (params.command) { + case CommandType.EvaluateFlag: { + const pe = params.evaluate; + if (!pe) { + throw malformedCommand; + } + if (pe.detail) { + switch (pe.valueType) { + case ValueType.Bool: + return client.boolVariationDetail(pe.flagKey, pe.defaultValue as boolean); + case ValueType.Int: // Intentional fallthrough. + case ValueType.Double: + return client.numberVariationDetail(pe.flagKey, pe.defaultValue as number); + case ValueType.String: + return client.stringVariationDetail(pe.flagKey, pe.defaultValue as string); + default: + return client.variationDetail(pe.flagKey, pe.defaultValue); + } + } + switch (pe.valueType) { + case ValueType.Bool: + return { value: client.boolVariation(pe.flagKey, pe.defaultValue as boolean) }; + case ValueType.Int: // Intentional fallthrough. + case ValueType.Double: + return { value: client.numberVariation(pe.flagKey, pe.defaultValue as number) }; + case ValueType.String: + return { value: client.stringVariation(pe.flagKey, pe.defaultValue as string) }; + default: + return { value: client.variation(pe.flagKey, pe.defaultValue) }; + } + } + + case CommandType.EvaluateAllFlags: + return { state: client.allFlags() }; + + case CommandType.IdentifyEvent: { + const pi = params.identifyEvent; + if (!pi) { + throw malformedCommand; + } + await client.identify((pi.user as LDContext) || (pi.context as LDContext)); + return undefined; + } + + case CommandType.CustomEvent: { + const pce = params.customEvent; + if (!pce) { + throw malformedCommand; + } + client.track(pce.eventKey, pce.data, pce.metricValue); + return undefined; + } + + case CommandType.FlushEvents: + client.flush(); + return undefined; + + case CommandType.RegisterFlagChangeListener: { + const pr = params.registerFlagChangeListener; + if (!pr) { + throw malformedCommand; + } + const existing = listeners.get(pr.listenerId); + if (existing) { + client.off('change', existing); + } + const handler: FlagChangeListener = (...args) => { + // The common-base emitter dispatches 'change' with (context, flagKeys: string[]). + // Fan out one POST per flag so the harness sees individual notifications. + const flagKeys = Array.isArray(args[1]) ? (args[1] as string[]) : []; + flagKeys.forEach((flagKey) => { + fetch(pr.callbackUri, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ listenerId: pr.listenerId, flagKey }), + }).catch(() => {}); + }); + }; + listeners.set(pr.listenerId, handler); + client.on('change', handler); + return undefined; + } + + case CommandType.UnregisterListener: { + const pu = params.unregisterListener; + if (!pu) { + throw malformedCommand; + } + const handler = listeners.get(pu.listenerId); + if (handler) { + client.off('change', handler); + listeners.delete(pu.listenerId); + } + return undefined; + } + + default: + throw badCommandError; + } + }; + + return c; +} diff --git a/packages/sdk/node-client/contract-tests/testharness-suppressions-fdv2.txt b/packages/sdk/node-client/contract-tests/testharness-suppressions-fdv2.txt new file mode 100644 index 0000000000..f78cc2c8c1 --- /dev/null +++ b/packages/sdk/node-client/contract-tests/testharness-suppressions-fdv2.txt @@ -0,0 +1,7 @@ +# Tests in this file will be skipped by the LaunchDarkly SDK test harness running +# the FDv2-feature branch against the Node.js client-side SDK. Add a path per line. +# Lines beginning with '#' are comments. +streaming/fdv2/reconnection state management/saves previously known state +streaming/fdv2/reconnection state management/replaces previously known state +streaming/fdv2/reconnection state management/updates previously known state +streaming/fdv2/can discard partial events on errors diff --git a/packages/sdk/node-client/contract-tests/testharness-suppressions.txt b/packages/sdk/node-client/contract-tests/testharness-suppressions.txt new file mode 100644 index 0000000000..d01fd91e20 --- /dev/null +++ b/packages/sdk/node-client/contract-tests/testharness-suppressions.txt @@ -0,0 +1,2 @@ +# Tests in this file will be skipped by the LaunchDarkly SDK test harness for the +# Node.js client-side SDK. Add a path per line. Lines beginning with '#' are comments. diff --git a/packages/sdk/node-client/contract-tests/tsconfig.json b/packages/sdk/node-client/contract-tests/tsconfig.json new file mode 100644 index 0000000000..6ab84a3ba3 --- /dev/null +++ b/packages/sdk/node-client/contract-tests/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "target": "ES2020", + "lib": ["ES2020"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true, + "stripInternal": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__"] +} diff --git a/packages/sdk/node-client/contract-tests/tsconfig.ref.json b/packages/sdk/node-client/contract-tests/tsconfig.ref.json new file mode 100644 index 0000000000..a5e168f4ae --- /dev/null +++ b/packages/sdk/node-client/contract-tests/tsconfig.ref.json @@ -0,0 +1 @@ +{"extends":"./tsconfig.json","include":["src/**/*","package.json"],"compilerOptions":{"composite":true}} diff --git a/packages/sdk/node-client/examples/hello-node-client/README.md b/packages/sdk/node-client/examples/hello-node-client/README.md new file mode 100644 index 0000000000..c723709eca --- /dev/null +++ b/packages/sdk/node-client/examples/hello-node-client/README.md @@ -0,0 +1,43 @@ +# LaunchDarkly sample Node.js (client-side) application + +We've built a simple console application that demonstrates how LaunchDarkly's Client-Side SDK for Node.js 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 [Node.js (client-side) SDK reference guide](https://docs.launchdarkly.com/sdk/client-side/node-js). + +This demo requires Node.js 18 or higher. + +## Build instructions + +1. Set the value of the `clientSideId` variable in `src/index.ts` to your client-side ID: + + ```ts + const clientSideId = 'my-client-side-id'; + ``` + + Alternatively, set the `LAUNCHDARKLY_CLIENT_SIDE_ID` environment variable: + + ```bash + export LAUNCHDARKLY_CLIENT_SIDE_ID="my-client-side-id" + ``` + +2. If there is an existing boolean feature flag in your LaunchDarkly project that you want to evaluate, set `flagKey` to the flag key: + + ```ts + const flagKey = 'my-flag-key'; + ``` + + Otherwise, `sample-feature` will be used by default. + +3. From the repository root, install dependencies, build the SDK, and run the example: + + ```bash + yarn install + yarn workspaces foreach -pR --topological-dev --from '@launchdarkly/node-client-sdk' run build + yarn workspace hello-node-client start + ``` + + You should receive the message: + + > *** The 'sample-feature' feature flag evaluates to ``. + +The application will run continuously and react to flag changes in LaunchDarkly. To run it once and exit (e.g. in CI), set the `CI` environment variable to any value. diff --git a/packages/sdk/node-client/examples/hello-node-client/package.json b/packages/sdk/node-client/examples/hello-node-client/package.json new file mode 100644 index 0000000000..abd8000bc5 --- /dev/null +++ b/packages/sdk/node-client/examples/hello-node-client/package.json @@ -0,0 +1,18 @@ +{ + "name": "hello-node-client", + "version": "0.0.1", + "private": true, + "type": "module", + "description": "Hello-world example for @launchdarkly/node-client-sdk", + "scripts": { + "start": "tsx src/index.ts" + }, + "dependencies": { + "@launchdarkly/node-client-sdk": "0.0.1" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "tsx": "^4.19.2", + "typescript": "5.1.6" + } +} diff --git a/packages/sdk/node-client/examples/hello-node-client/src/index.ts b/packages/sdk/node-client/examples/hello-node-client/src/index.ts new file mode 100644 index 0000000000..497898367a --- /dev/null +++ b/packages/sdk/node-client/examples/hello-node-client/src/index.ts @@ -0,0 +1,78 @@ +import { createClient } from '@launchdarkly/node-client-sdk'; + +// Set clientSideId to your client-side ID. +const clientSideId = process.env.LAUNCHDARKLY_CLIENT_SIDE_ID ?? ''; + +// Set flagKey to the feature flag key you want to evaluate. +const flagKey = process.env.LAUNCHDARKLY_FLAG_KEY ?? 'sample-feature'; + +if (!clientSideId) { + console.error( + 'LaunchDarkly client-side ID is required: set the LAUNCHDARKLY_CLIENT_SIDE_ID environment variable and try again.', + ); + process.exit(1); +} + +if (!flagKey) { + console.error( + 'LaunchDarkly flag key is required: set the flagKey variable in src/index.ts, or the LAUNCHDARKLY_FLAG_KEY environment variable and try again.', + ); + process.exit(1); +} + +// Set up the evaluation context. This context should appear on your LaunchDarkly contexts +// dashboard soon after you run the demo. +const context = { + kind: 'user', + key: 'example-user-key', + name: 'Sandy', +}; + +const banner = ` ██ + ██ + ████████ + ███████ +██ LAUNCHDARKLY █ + ███████ + ████████ + ██ + ██ +`; + +function printValueAndBanner(flagValue: unknown) { + console.log(`*** The '${flagKey}' feature flag evaluates to ${flagValue}.`); + if (flagValue === true) { + console.log(banner); + } +} + +const client = createClient(clientSideId, context); + +const startResult = await client.start(); + +if (startResult.status !== 'complete') { + console.error( + `*** SDK failed to initialize (${startResult.status}). Please check your internet connection and SDK credential for any typo.`, + ); + process.exit(1); +} + +console.log('*** SDK successfully initialized!'); + +// Open a streaming subscription for this flag. The SDK opens a streaming connection by +// default (initialConnectionMode = 'streaming'), and this listener reactively prints a +// new line whenever the value changes in LaunchDarkly. +client.on(`change:${flagKey}`, async () => { + const updated = await client.variation(flagKey, false); + printValueAndBanner(updated); +}); + +const initialValue = await client.variation(flagKey, false); +printValueAndBanner(initialValue); + +// CI runs the hello app in 'one shot' mode so the CI job can inspect a single line of output. +// Outside CI, the app keeps running so flag changes in LaunchDarkly propagate live. +if (process.env.CI) { + await client.close(); + process.exit(0); +} diff --git a/packages/sdk/node-client/examples/hello-node-client/tsconfig.json b/packages/sdk/node-client/examples/hello-node-client/tsconfig.json new file mode 100644 index 0000000000..050ff6f77d --- /dev/null +++ b/packages/sdk/node-client/examples/hello-node-client/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "node", + "lib": ["es2022"], + "types": ["node"], + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/sdk/node-client/package.json b/packages/sdk/node-client/package.json index 8d9da18153..1ea10767d7 100644 --- a/packages/sdk/node-client/package.json +++ b/packages/sdk/node-client/package.json @@ -1,6 +1,6 @@ { "name": "@launchdarkly/node-client-sdk", - "version": "0.0.4", + "version": "0.0.1", "description": "LaunchDarkly Client-Side SDK for Node.js", "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/node-client", "repository": { @@ -8,7 +8,6 @@ "url": "https://github.com/launchdarkly/js-core.git" }, "license": "Apache-2.0", - "packageManager": "yarn@3.4.1", "keywords": [ "launchdarkly", "node", @@ -18,7 +17,7 @@ "feature management" ], "engines": { - "node": ">=18" + "node": ">=20" }, "type": "module", "main": "./dist/index.cjs", @@ -38,7 +37,9 @@ "build": "tsup", "lint": "eslint .", "lint:fix": "yarn lint --fix", - "test": "jest" + "test": "jest", + "contract-test-service": "yarn workspace @launchdarkly/node-client-sdk-contract-tests run build && yarn workspace @launchdarkly/node-client-sdk-contract-tests run start", + "contract-tests": "./contract-tests/run-contract-tests.sh" }, "dependencies": { "@launchdarkly/js-client-sdk-common": "workspace:^", diff --git a/release-please-config.json b/release-please-config.json index 4129a5307b..61f3d9244e 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -156,7 +156,12 @@ "packages/sdk/node-client": { "bump-minor-pre-major": true, "extra-files": [ - "src/platform/NodeInfo.ts" + "src/platform/NodeInfo.ts", + { + "type": "json", + "path": "examples/hello-node-client/package.json", + "jsonpath": "$.dependencies['@launchdarkly/node-client-sdk']" + } ] }, "packages/sdk/server-node": {