diff --git a/package.json b/package.json index a281e467d..dc18ce728 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "packages/sdk/server-ai/examples/vercel-ai", "packages/telemetry/browser-telemetry", "contract-tests", - "packages/sdk/combined-browser" + "packages/sdk/combined-browser", + "packages/sdk/shopify-oxygen" ], "private": true, "scripts": { diff --git a/packages/sdk/shopify-oxygen/.gitignore b/packages/sdk/shopify-oxygen/.gitignore new file mode 100644 index 000000000..169c30f3a --- /dev/null +++ b/packages/sdk/shopify-oxygen/.gitignore @@ -0,0 +1,2 @@ +# Local module builds +*.tgz diff --git a/packages/sdk/shopify-oxygen/CHANGELOG.md b/packages/sdk/shopify-oxygen/CHANGELOG.md new file mode 100644 index 000000000..13349a5f1 --- /dev/null +++ b/packages/sdk/shopify-oxygen/CHANGELOG.md @@ -0,0 +1,4 @@ +Change log +================================================ + +All notable changes to `@launchdarkly/shopify-oxygen-sdk` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org). diff --git a/packages/sdk/shopify-oxygen/README.md b/packages/sdk/shopify-oxygen/README.md new file mode 100644 index 000000000..c9dc94859 --- /dev/null +++ b/packages/sdk/shopify-oxygen/README.md @@ -0,0 +1,159 @@ +LaunchDarkly Server SDK for Shopify Oxygen Runtimes +=========================== + + + +# ⛔️⛔️⛔️⛔️ + +> [!CAUTION] +> *This version of the SDK is a **beta** version and should not be considered ready for production use while this message is visible.* + +# ☝️☝️☝️☝️☝️☝️ + +LaunchDarkly overview +------------------------- +[LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves trillions feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today! + +[![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly) + +Supported Oxygen runtime versions +------------------------- + +This version of the LaunchDarkly SDK has been tested with Oxygen compatibility date `2025-01-01`. +> Check [worker compatibility date](https://shopify.dev/docs/storefronts/headless/hydrogen/deployments/oxygen-runtime#worker-compatibility-flags) + +Getting started +----------- + + + +Install this package: +``` +npm install @launchdarkly/shopify-oxygen-sdk --save +``` + +Import the module +``` +import {init} from '@launchdarkly/shopify-oxygen-sdk'; +``` + +Declare required variables +``` +const sdkKey = 'your-sdk-key'; +const options = {}; + +const flagKey = 'your-flag'; +const context = { + kind: 'user', + key: 'example-user-key', + name: 'tester', +}; +const defaultValue = false; +``` + +Basic SDK usage example +``` +const ldClient = await init(sdkKey, options); +await ldClient.waitForInitialization({timeout: 10}); +const flagValue = await ldClient.variation(flagKey, context, defaultValue); +``` + +Options +----------- +The SDK accepts an `options` object as its second argument to `init(sdkKey, options)`. The supported options for this SDK are shown below. + +### cache + +`cache` defines how this SDK interacts with [Oxygen's native cache api](https://shopify.dev/docs/storefronts/headless/hydrogen/deployments/oxygen-runtime#cache-api). + +| Option | Type | Default | Description | +| ------------- | -------- | ------- | ------------------------------------------------ | +| `ttlSeconds` | number | 30 | Time-to-live for cache entries, in seconds. | +| `name` | string | 'launchdarkly-cache' | Name for the cache instance. | +| `enabled` | boolean | true | Whether caching is enabled. | + +Example: +```js +const options = { + cache: { + ttlSeconds: 60, // cache values for 60 seconds within the request + name: 'my-custom-cache', + enabled: true, + } +} +``` + +### logger + +By default, the SDK uses an internal logger for diagnostic output. You may provide your own logger by specifying a compatible logger object under `logger`. + +| Option | Type | Default | Description | +|----------|--------|------------------------------|----------------------------------------| +| logger | object | a basic internal logger | Optional custom logger implementation. | + +Example: +```js +const options = { + logger: myCustomLogger, // must match the LD logger interface +} +``` +--- +See the source for default values and logic: +- [validateOptions.ts](./src/utils/validateOptions.ts) +- [createOptions.ts](./src/utils/createOptions.ts) + + + + +Learn more +----------- + +Read our [documentation](https://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. + + + +Testing +------- + +We run integration tests for all our SDKs using a centralized test harness. This approach gives us the ability to test for consistency across SDKs, as well as test networking behavior in a long-running application. These tests cover each method in the SDK, and verify that event sending, flag evaluation, stream reconnection, and other aspects of the SDK all behave correctly. + +Contributing +------------ + +We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](../../../CONTRIBUTING.md) for instructions on how to contribute to this SDK. + +About LaunchDarkly +----------- + +* LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: + * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. + * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. + * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline. +* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Read [our documentation](https://docs.launchdarkly.com/docs) for a complete list. +* Explore LaunchDarkly + * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information + * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides + * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation + * [launchdarkly.com/blog](https://launchdarkly.com/blog/ "LaunchDarkly Blog Documentation") for the latest product updates + + \ No newline at end of file diff --git a/packages/sdk/shopify-oxygen/__tests__/index.test.ts b/packages/sdk/shopify-oxygen/__tests__/index.test.ts new file mode 100644 index 000000000..13bf3bd37 --- /dev/null +++ b/packages/sdk/shopify-oxygen/__tests__/index.test.ts @@ -0,0 +1,203 @@ +import { LDClient, LDContext } from '@launchdarkly/js-server-sdk-common'; + +import { init, OxygenLDOptions } from '../src/index'; +import { setupTestEnvironment } from './setup'; + +const sdkKey = 'test-sdk-key'; +const flagKey1 = 'testFlag1'; +const flagKey2 = 'testFlag2'; +const flagKey3 = 'testFlag3'; +const context: LDContext = { kind: 'user', key: 'test-user-key-1' }; + +describe('Shopify Oxygen SDK', () => { + describe('initialization tests', () => { + beforeEach(async () => { + await setupTestEnvironment(); + }); + + it('will initialize successfully with default options', async () => { + const ldClient = init(sdkKey); + await ldClient.waitForInitialization(); + expect(ldClient).toBeDefined(); + ldClient.close(); + }); + + it('will initialize successfully with custom options', async () => { + const ldClient = init(sdkKey, { + sendEvents: false, + cache: { + enabled: false, + }, + } as OxygenLDOptions); + await ldClient.waitForInitialization(); + expect(ldClient).toBeDefined(); + ldClient.close(); + }); + + it('will fail to initialize if there is no SDK key', () => { + expect(() => init(null as any)).toThrow(); + }); + }); + + describe('polling tests', () => { + beforeEach(async () => { + await setupTestEnvironment(); + }); + + describe('without caching', () => { + let ldClient: LDClient; + + beforeEach(async () => { + // Ensure fetch is set up before creating client + ldClient = init(sdkKey, { + cache: { + enabled: false, + }, + } as OxygenLDOptions); + await ldClient.waitForInitialization(); + }); + + afterEach(() => { + if (ldClient) { + ldClient.close(); + } + }); + + it('Should not cache any requests', async () => { + await ldClient.variation(flagKey1, context, false); + await ldClient.allFlagsState(context); + await ldClient.variationDetail(flagKey3, context, false); + expect(caches.open).toHaveBeenCalledTimes(0); + }); + + describe('flags', () => { + it('variation default', async () => { + const value = await ldClient.variation(flagKey1, context, false); + + expect(value).toBeTruthy(); + + expect(caches.open).toHaveBeenCalledTimes(0); + }); + + it('variation default rollout', async () => { + const contextWithEmail = { ...context, email: 'test@yahoo.com' }; + const value = await ldClient.variation(flagKey2, contextWithEmail, false); + const detail = await ldClient.variationDetail(flagKey2, contextWithEmail, false); + + expect(detail).toEqual({ + reason: { kind: 'FALLTHROUGH' }, + value: true, + variationIndex: 0, + }); + expect(value).toBeTruthy(); + + expect(caches.open).toHaveBeenCalledTimes(0); + }); + + it('rule match', async () => { + const contextWithEmail = { ...context, email: 'test@falsemail.com' }; + const value = await ldClient.variation(flagKey1, contextWithEmail, false); + const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false); + + expect(detail).toEqual({ + reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 }, + value: false, + variationIndex: 1, + }); + expect(value).toBeFalsy(); + + expect(caches.open).toHaveBeenCalledTimes(0); + }); + + it('fallthrough', async () => { + const contextWithEmail = { ...context, email: 'test@yahoo.com' }; + const value = await ldClient.variation(flagKey1, contextWithEmail, false); + const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false); + + expect(detail).toEqual({ + reason: { kind: 'FALLTHROUGH' }, + value: true, + variationIndex: 0, + }); + expect(value).toBeTruthy(); + + expect(caches.open).toHaveBeenCalledTimes(0); + }); + + it('allFlags fallthrough', async () => { + const allFlags = await ldClient.allFlagsState(context); + + expect(allFlags).toBeDefined(); + expect(allFlags.toJSON()).toEqual({ + $flagsState: { + testFlag1: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, + testFlag2: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, + testFlag3: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, + }, + $valid: true, + testFlag1: true, + testFlag2: true, + testFlag3: true, + }); + + expect(caches.open).toHaveBeenCalledTimes(0); + }); + }); + + describe('segments', () => { + it('segment by country', async () => { + const contextWithCountry = { ...context, country: 'australia' }; + const value = await ldClient.variation(flagKey3, contextWithCountry, false); + const detail = await ldClient.variationDetail(flagKey3, contextWithCountry, false); + + expect(detail).toEqual({ + reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 }, + value: false, + variationIndex: 1, + }); + expect(value).toBeFalsy(); + + expect(caches.open).toHaveBeenCalledTimes(0); + }); + }); + }); + + describe('with caching', () => { + let ldClient: LDClient; + + beforeEach(async () => { + // Ensure fetch is set up before creating client + ldClient = init(sdkKey); + await ldClient.waitForInitialization(); + }); + + afterEach(() => { + if (ldClient) { + ldClient.close(); + } + }); + + it('will cache across multiple variation calls', async () => { + await ldClient.variation(flagKey1, context, false); + await ldClient.variation(flagKey2, context, false); + + // Should only fetch once due to caching + expect(caches.open).toHaveBeenCalledTimes(1); + }); + + it('will cache across multiple allFlags calls', async () => { + await ldClient.allFlagsState(context); + await ldClient.allFlagsState(context); + + expect(caches.open).toHaveBeenCalledTimes(1); + }); + + it('will cache between allFlags and variation', async () => { + await ldClient.variation(flagKey1, context, false); + await ldClient.allFlagsState(context); + + expect(caches.open).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/packages/sdk/shopify-oxygen/__tests__/setup.ts b/packages/sdk/shopify-oxygen/__tests__/setup.ts new file mode 100644 index 000000000..d29fe5778 --- /dev/null +++ b/packages/sdk/shopify-oxygen/__tests__/setup.ts @@ -0,0 +1,86 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { jest } from '@jest/globals'; + +import * as allFlagsSegments from './testData.json'; + +// @ts-ignore +global.setInterval = () => ({}) as any; + +// @ts-ignore +global.clearInterval = () => {}; + +// @ts-ignore +global.setTimeout = () => ({}) as any; + +// @ts-ignore +global.clearTimeout = () => {}; + +// Setup test environment with mocks +export const setupTestEnvironment = async () => { + // Setup Cache API mock + const matchFn = jest.fn(); + // @ts-ignore - Mock implementation + matchFn.mockResolvedValue(undefined); + const putFn = jest.fn(); + // @ts-ignore - Mock implementation + putFn.mockResolvedValue(undefined); + const mockCache = { + match: matchFn as any, + put: putFn as any, + }; + + const openFn = jest.fn(); + // @ts-ignore - Mock implementation + openFn.mockResolvedValue(mockCache); + // @ts-ignore - Mock Cache API for testing + global.caches = { + open: openFn as any, + }; + + // @ts-ignore - Mock implementation + global.fetch = jest.fn((url: string) => { + // Match any URL containing /sdk/latest-all which should be the only URL that we are interested in. + if (url.includes('/sdk/latest-all') || url.endsWith('/sdk/latest-all')) { + const jsonFn = jest.fn(); + // @ts-ignore - Mock implementation + jsonFn.mockResolvedValue(allFlagsSegments); + const textFn = jest.fn(); + // @ts-ignore - Mock implementation + textFn.mockResolvedValue(JSON.stringify(allFlagsSegments)); + const arrayBufferFn = jest.fn(); + // @ts-ignore - Mock implementation + arrayBufferFn.mockResolvedValue(new ArrayBuffer(0)); + const mockResponse: any = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'Content-Type': 'application/json' }), + json: jsonFn, + text: textFn, + arrayBuffer: arrayBufferFn, + clone: jest.fn().mockReturnThis(), + }; + return Promise.resolve(mockResponse); + } + + const jsonFn = jest.fn(); + // @ts-ignore - Mock implementation + jsonFn.mockResolvedValue({}); + const textFn = jest.fn(); + // @ts-ignore - Mock implementation + textFn.mockResolvedValue(''); + const arrayBufferFn = jest.fn(); + // @ts-ignore - Mock implementation + arrayBufferFn.mockResolvedValue(new ArrayBuffer(0)); + const mockResponse: any = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: jsonFn, + text: textFn, + arrayBuffer: arrayBufferFn, + }; + return Promise.resolve(mockResponse); + }); +}; diff --git a/packages/sdk/shopify-oxygen/__tests__/testData.json b/packages/sdk/shopify-oxygen/__tests__/testData.json new file mode 100644 index 000000000..fa615bcdd --- /dev/null +++ b/packages/sdk/shopify-oxygen/__tests__/testData.json @@ -0,0 +1,172 @@ +{ + "flags": { + "testFlag1": { + "key": "testFlag1", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [ + { + "variation": 1, + "id": "rule1", + "clauses": [ + { + "contextKind": "user", + "attribute": "/email", + "op": "contains", + "values": ["falsemail"], + "negate": false + } + ], + "trackEvents": false, + "rollout": { + "bucketBy": "bucket", + "variations": [{ "variation": 1, "weight": 100 }] + } + } + ], + "fallthrough": { + "variation": 0 + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + }, + "testFlag2": { + "key": "testFlag2", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [], + "fallthrough": { + "variation": 0, + "rollout": { + "bucketBy": "bucket", + "variations": [{ "variation": 1, "weight": 100 }], + "contextKind": "user", + "attribute": "/email" + } + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + }, + "testFlag3": { + "key": "testFlag3", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [ + { + "variation": 1, + "id": "rule1", + "clauses": [ + { + "op": "segmentMatch", + "values": ["testSegment1"], + "negate": false + } + ], + "trackEvents": false + } + ], + "fallthrough": { + "variation": 0 + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + } + }, + "segments": { + "testSegment1": { + "name": "testSegment1", + "tags": [], + "creationDate": 1676063792158, + "key": "testSegment1", + "included": [], + "excluded": [], + "includedContexts": [], + "excludedContexts": [], + "_links": { + "parent": { "href": "/api/v2/segments/default/test", "type": "application/json" }, + "self": { + "href": "/api/v2/segments/default/test/beta-users-1", + "type": "application/json" + }, + "site": { "href": "/default/test/segments/beta-users-1", "type": "text/html" } + }, + "rules": [ + { + "id": "rule-country", + "clauses": [ + { + "attribute": "country", + "op": "in", + "values": ["australia"], + "negate": false + } + ] + } + ], + "version": 1, + "deleted": false, + "_access": { "denied": [], "allowed": [] }, + "generation": 1 + }, + "testSegment2": { + "name": "testSegment2", + "tags": [], + "creationDate": 1676063792158, + "key": "testSegment2", + "included": [], + "excluded": [], + "includedContexts": [], + "excludedContexts": [], + "_links": { + "parent": { "href": "/api/v2/segments/default/test", "type": "application/json" }, + "self": { + "href": "/api/v2/segments/default/test/beta-users-1", + "type": "application/json" + }, + "site": { "href": "/default/test/segments/beta-users-1", "type": "text/html" } + }, + "rules": [], + "version": 1, + "deleted": false, + "_access": { "denied": [], "allowed": [] }, + "generation": 1 + } + } +} + diff --git a/packages/sdk/shopify-oxygen/jest.config.cjs b/packages/sdk/shopify-oxygen/jest.config.cjs new file mode 100644 index 000000000..a6dd13c6f --- /dev/null +++ b/packages/sdk/shopify-oxygen/jest.config.cjs @@ -0,0 +1,9 @@ +module.exports = { + "transform": { "^.+\\.ts?$": "ts-jest" }, + "testMatch": ["**/*.test.ts?(x)"], + "testPathIgnorePatterns": ["node_modules", "dist"], + "modulePathIgnorePatterns": ["dist"], + "testEnvironment": "node", + "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"], + "collectCoverageFrom": ["src/**/*.ts"] +}; diff --git a/packages/sdk/shopify-oxygen/package.json b/packages/sdk/shopify-oxygen/package.json new file mode 100644 index 000000000..7ca3f4013 --- /dev/null +++ b/packages/sdk/shopify-oxygen/package.json @@ -0,0 +1,73 @@ +{ + "name": "@launchdarkly/shopify-oxygen-sdk", + "version": "0.1.0", + "description": "LaunchDarkly Server-Side SDK for Shopify Oxygen Runtime", + "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/shopify-oxygen", + "repository": { + "type": "git", + "url": "https://github.com/launchdarkly/js-core.git" + }, + "license": "Apache-2.0", + "packageManager": "yarn@3.4.1", + "keywords": [ + "launchdarkly", + "shopify", + "oxygen", + "edge", + "worker" + ], + "type": "module", + "exports": { + ".": { + "require": { + "types": "./dist/index.d.cts", + "require": "./dist/index.cjs" + }, + "import": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "clean": "rimraf dist", + "build": "tsup", + "tsw": "yarn tsc --watch", + "start": "rimraf dist && yarn tsw", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "test": "NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" jest --ci --runInBand", + "coverage": "yarn test --coverage", + "check": "yarn lint && yarn build && yarn test" + }, + "dependencies": { + "@launchdarkly/js-server-sdk-common": "2.16.2", + "crypto-js": "^4.1.1" + }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@shopify/oxygen-workers-types": "^4.2.0", + "@trivago/prettier-plugin-sort-imports": "^4.1.1", + "@types/crypto-js": "^4.1.1", + "@types/jest": "^29.5.0", + "@typescript-eslint/eslint-plugin": "^6.20.0", + "@typescript-eslint/parser": "^6.20.0", + "eslint": "^8.45.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-airbnb-typescript": "^17.1.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jest": "^27.6.3", + "jest": "^29.5.0", + "rimraf": "^5.0.1", + "ts-jest": "^29.1.0", + "tsup": "^8.3.5", + "typescript": "5.1.6" + } +} diff --git a/packages/sdk/shopify-oxygen/src/global.d.ts b/packages/sdk/shopify-oxygen/src/global.d.ts new file mode 100644 index 000000000..df3cf93a4 --- /dev/null +++ b/packages/sdk/shopify-oxygen/src/global.d.ts @@ -0,0 +1,7 @@ +/** + * Shopify Oxygen Workers Types + * @see https://shopify.dev/docs/storefronts/headless/hydrogen/deployments/oxygen-runtime + */ +import '@shopify/oxygen-workers-types'; + +export {}; diff --git a/packages/sdk/shopify-oxygen/src/index.ts b/packages/sdk/shopify-oxygen/src/index.ts new file mode 100644 index 000000000..06237d1ea --- /dev/null +++ b/packages/sdk/shopify-oxygen/src/index.ts @@ -0,0 +1,27 @@ +import { LDClientImpl, LDOptions } from '@launchdarkly/js-server-sdk-common'; + +import Platform from './platform'; +import platformInfo from './platform/OxygenInfo'; +// Polyfill timer functions for Shopify Oxygen runtime +import './polyfills/timers'; +import { createCallbacks, createOptions, OxygenLDOptions, validateOptions } from './utils'; + +export * from '@launchdarkly/js-server-sdk-common'; +export type { OxygenLDOptions }; + +class LDClient extends LDClientImpl { + // sdkKey is only used to query featureStore, not to initialize with LD servers + constructor(sdkKey: string, platform: Platform, options: LDOptions) { + super(sdkKey, platform, options, createCallbacks(options.logger)); + } +} + +export const init = (sdkKey: string, options: OxygenLDOptions = {}): LDClient => { + // this throws if options are invalid + validateOptions(sdkKey); + + const finalOptions = createOptions(options); + const { cache: cacheOptions = {}, ...ldOptions } = finalOptions; + + return new LDClient(sdkKey, new Platform(platformInfo, cacheOptions), ldOptions); +}; diff --git a/packages/sdk/shopify-oxygen/src/platform/OxygenCrypto/cryptoJSHasher.ts b/packages/sdk/shopify-oxygen/src/platform/OxygenCrypto/cryptoJSHasher.ts new file mode 100644 index 000000000..4aec22105 --- /dev/null +++ b/packages/sdk/shopify-oxygen/src/platform/OxygenCrypto/cryptoJSHasher.ts @@ -0,0 +1,49 @@ +import CryptoJS from 'crypto-js'; + +import { Hasher as LDHasher } from '@launchdarkly/js-server-sdk-common'; + +import { SupportedHashAlgorithm, SupportedOutputEncoding } from './types'; + +export default class CryptoJSHasher implements LDHasher { + private _cryptoJSHasher; + + constructor(algorithm: SupportedHashAlgorithm) { + let algo; + + switch (algorithm) { + case 'sha1': + algo = CryptoJS.algo.SHA1; + break; + case 'sha256': + algo = CryptoJS.algo.SHA256; + break; + default: + throw new Error('unsupported hash algorithm. Only sha1 and sha256 are supported.'); + } + + this._cryptoJSHasher = algo.create(); + } + + digest(encoding: SupportedOutputEncoding): string { + const result = this._cryptoJSHasher.finalize(); + + let enc; + switch (encoding) { + case 'base64': + enc = CryptoJS.enc.Base64; + break; + case 'hex': + enc = CryptoJS.enc.Hex; + break; + default: + throw new Error('unsupported output encoding. Only base64 and hex are supported.'); + } + + return result.toString(enc); + } + + update(data: string): this { + this._cryptoJSHasher.update(data); + return this; + } +} diff --git a/packages/sdk/shopify-oxygen/src/platform/OxygenCrypto/cryptoJSHmac.ts b/packages/sdk/shopify-oxygen/src/platform/OxygenCrypto/cryptoJSHmac.ts new file mode 100644 index 000000000..98e8976bb --- /dev/null +++ b/packages/sdk/shopify-oxygen/src/platform/OxygenCrypto/cryptoJSHmac.ts @@ -0,0 +1,45 @@ +import CryptoJS from 'crypto-js'; + +import { Hmac as LDHmac } from '@launchdarkly/js-server-sdk-common'; + +import { SupportedHashAlgorithm, SupportedOutputEncoding } from './types'; + +export default class CryptoJSHmac implements LDHmac { + private _cryptoJSHmac; + + constructor(algorithm: SupportedHashAlgorithm, key: string) { + let algo; + + switch (algorithm) { + case 'sha1': + algo = CryptoJS.algo.SHA1; + break; + case 'sha256': + algo = CryptoJS.algo.SHA256; + break; + default: + throw new Error('unsupported hash algorithm. Only sha1 and sha256 are supported.'); + } + + this._cryptoJSHmac = CryptoJS.algo.HMAC.create(algo, key); + } + + digest(encoding: SupportedOutputEncoding): string { + const result = this._cryptoJSHmac.finalize(); + + if (encoding === 'base64') { + return result.toString(CryptoJS.enc.Base64); + } + + if (encoding === 'hex') { + return result.toString(CryptoJS.enc.Hex); + } + + throw new Error('unsupported output encoding. Only base64 and hex are supported.'); + } + + update(data: string): this { + this._cryptoJSHmac.update(data); + return this; + } +} diff --git a/packages/sdk/shopify-oxygen/src/platform/OxygenCrypto/index.ts b/packages/sdk/shopify-oxygen/src/platform/OxygenCrypto/index.ts new file mode 100644 index 000000000..98b018bad --- /dev/null +++ b/packages/sdk/shopify-oxygen/src/platform/OxygenCrypto/index.ts @@ -0,0 +1,19 @@ +import type { Crypto, Hasher, Hmac } from '@launchdarkly/js-server-sdk-common'; + +import CryptoJSHasher from './cryptoJSHasher'; +import CryptoJSHmac from './cryptoJSHmac'; +import { SupportedHashAlgorithm } from './types'; + +export default class EdgeCrypto implements Crypto { + createHash(algorithm: SupportedHashAlgorithm): Hasher { + return new CryptoJSHasher(algorithm); + } + + createHmac(algorithm: SupportedHashAlgorithm, key: string): Hmac { + return new CryptoJSHmac(algorithm, key); + } + + randomUUID(): string { + return crypto.randomUUID(); + } +} diff --git a/packages/sdk/shopify-oxygen/src/platform/OxygenCrypto/types.ts b/packages/sdk/shopify-oxygen/src/platform/OxygenCrypto/types.ts new file mode 100644 index 000000000..3cf314d1f --- /dev/null +++ b/packages/sdk/shopify-oxygen/src/platform/OxygenCrypto/types.ts @@ -0,0 +1,2 @@ +export type SupportedHashAlgorithm = 'sha1' | 'sha256'; +export type SupportedOutputEncoding = 'base64' | 'hex'; diff --git a/packages/sdk/shopify-oxygen/src/platform/OxygenInfo.ts b/packages/sdk/shopify-oxygen/src/platform/OxygenInfo.ts new file mode 100644 index 000000000..63505b3ca --- /dev/null +++ b/packages/sdk/shopify-oxygen/src/platform/OxygenInfo.ts @@ -0,0 +1,28 @@ +import type { Info, PlatformData, SdkData } from '@launchdarkly/js-server-sdk-common'; + +const name = '@launchdarkly/shopify-oxygen-sdk'; +const version = '0.1.0'; // x-release-please-version + +class OxygenPlatformInfo implements Info { + constructor( + private _platformName: string, + private _sdkName: string, + private _sdkVersion: string, + ) {} + + platformData(): PlatformData { + return { + name: this._platformName, + }; + } + + sdkData(): SdkData { + return { + name: this._sdkName, + version: this._sdkVersion, + userAgentBase: 'ShopifyOxygenSDK', + }; + } +} + +export default new OxygenPlatformInfo('Shopify Oxygen', name, version); diff --git a/packages/sdk/shopify-oxygen/src/platform/OxygenRequests.ts b/packages/sdk/shopify-oxygen/src/platform/OxygenRequests.ts new file mode 100644 index 000000000..d46b08cab --- /dev/null +++ b/packages/sdk/shopify-oxygen/src/platform/OxygenRequests.ts @@ -0,0 +1,115 @@ +import { NullEventSource } from '@launchdarkly/js-server-sdk-common'; +import type { + EventSource, + EventSourceCapabilities, + EventSourceInitDict, + Options, + platform, +} from '@launchdarkly/js-server-sdk-common'; + +import { OxygenCacheOptions } from '../utils/validateOptions'; + +export default class OxygenRequests implements platform.Requests { + private _cache: Cache | null = null; + private _cacheOptions: OxygenCacheOptions; + private _cacheInitPromise: Promise | null = null; + + constructor(cacheOptions: OxygenCacheOptions = {}) { + this._cacheOptions = cacheOptions; + + const { enabled, name } = this._cacheOptions; + + if (enabled && name) { + this._cacheInitPromise = this._initializeCache(name); + } + } + + private async _initializeCache(cacheName: string): Promise { + // Check if Cache API is available + if (typeof caches !== 'undefined') { + this._cache = await caches.open(cacheName); + return this._cache; + } + + return null; + } + + private async _addCacheControlHeaders(response: Response): Promise { + // Read the body first to ensure the stream is consumed + const { ttlSeconds } = this._cacheOptions; + const body = await response.arrayBuffer(); + const newHeaders = new Headers(response.headers); + newHeaders.set('Cache-Control', `public, max-age=${ttlSeconds}`); + newHeaders.set('Date', new Date().toUTCString()); + + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); + } + + async fetch(url: string, options: Options = {}): Promise { + // Ensure cache is initialized + const cache = this._cache || (await this._cacheInitPromise); + const finalOptions = { + method: 'GET', + ...options, + }; + + if (!cache || finalOptions.method.toLowerCase() !== 'get') { + // Fall back to direct fetch if one of the following conditions are met: + // - Cache API not available + // - Cache is not enabled per initialization options + // - Not a GET request (for now, we mostly interested in caching the feature poll request) + return fetch(url, options); + } + + const request = new Request(url, finalOptions); + + const cachedResponse = await cache.match(request); + + if (cachedResponse) { + return cachedResponse as platform.Response; + } + return this._fetchAndCache(url, options, request, cache); + } + + private async _fetchAndCache( + url: string, + options: Options, + request: Request, + cache: Cache, + ): Promise { + const response = await fetch(url, options); + + // Only cache successful GET requests + if (cache && response.ok && (!options.method || options.method === 'GET')) { + // Clone the response to get two branches: one for caching, one for returning + const responseClone = response.clone(); + const responseWithCacheControl = await this._addCacheControlHeaders(responseClone); + + // Cache the response (don't await to avoid blocking) + // The Cache API will consume the response body + cache.put(request, responseWithCacheControl).catch(() => { + // Ignore cache errors, we'll try again next time + }); + + return response as platform.Response; + } + + return response as platform.Response; + } + + createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { + return new NullEventSource(url, eventSourceInitDict); + } + + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: false, + headers: false, + customMethod: false, + }; + } +} diff --git a/packages/sdk/shopify-oxygen/src/platform/index.ts b/packages/sdk/shopify-oxygen/src/platform/index.ts new file mode 100644 index 000000000..75798e4fc --- /dev/null +++ b/packages/sdk/shopify-oxygen/src/platform/index.ts @@ -0,0 +1,19 @@ +import type { Info } from '@launchdarkly/js-server-sdk-common'; +import { platform } from '@launchdarkly/js-server-sdk-common'; + +import { OxygenCacheOptions } from '../utils/validateOptions'; +import OxygenCrypto from './OxygenCrypto'; +import OxygenRequests from './OxygenRequests'; + +export default class OxygenPlatform implements platform.Platform { + info: Info; + + crypto: platform.Crypto = new OxygenCrypto(); + + requests: platform.Requests; + + constructor(info: Info, cacheOptions: OxygenCacheOptions = {}) { + this.info = info; + this.requests = new OxygenRequests(cacheOptions); + } +} diff --git a/packages/sdk/shopify-oxygen/src/polyfills/timers.ts b/packages/sdk/shopify-oxygen/src/polyfills/timers.ts new file mode 100644 index 000000000..f017e55ce --- /dev/null +++ b/packages/sdk/shopify-oxygen/src/polyfills/timers.ts @@ -0,0 +1,32 @@ +// Polyfill timer functions for Shopify Oxygen runtime +// NOTE: we only provide these polyfills if they are not already available +// since the Oxygen runtime provide these functions in the request handler context, +// but not in the global context. + +if (typeof globalThis.setInterval === 'undefined') { + // @ts-ignore - Polyfill implementation doesn't need full Node.js typing + globalThis.setInterval = () => + // Return a no-op handle that can be passed to clearInterval + ({}) as any; +} + +if (typeof globalThis.clearInterval === 'undefined') { + // @ts-ignore - Polyfill implementation doesn't need full Node.js typing + globalThis.clearInterval = () => { + // No-op + }; +} + +if (typeof globalThis.setTimeout === 'undefined') { + // @ts-ignore - Polyfill implementation doesn't need full Node.js typing + globalThis.setTimeout = () => + // Return a no-op handle that can be passed to clearTimeout + ({}) as any; +} + +if (typeof globalThis.clearTimeout === 'undefined') { + // @ts-ignore - Polyfill implementation doesn't need full Node.js typing + globalThis.clearTimeout = () => { + // No-op + }; +} diff --git a/packages/sdk/shopify-oxygen/src/utils/createCallbacks.ts b/packages/sdk/shopify-oxygen/src/utils/createCallbacks.ts new file mode 100644 index 000000000..8f61da24b --- /dev/null +++ b/packages/sdk/shopify-oxygen/src/utils/createCallbacks.ts @@ -0,0 +1,13 @@ +import { LDLogger } from '@launchdarkly/js-server-sdk-common'; + +// This is an empty callback since we Oxygen workers don't support event emitters. +// eslint-disable-next-line import/prefer-default-export +export const createCallbacks = (logger?: LDLogger) => ({ + onError: (err: Error) => { + logger?.error?.(err.message); + }, + onFailed: (_err: Error) => {}, + onReady: () => {}, + onUpdate: (_key: string) => {}, + hasEventListeners: () => false, +}); diff --git a/packages/sdk/shopify-oxygen/src/utils/createOptions.ts b/packages/sdk/shopify-oxygen/src/utils/createOptions.ts new file mode 100644 index 000000000..a7eb80eac --- /dev/null +++ b/packages/sdk/shopify-oxygen/src/utils/createOptions.ts @@ -0,0 +1,41 @@ +// eslint-disable-next-line max-classes-per-file +import { BasicLogger, LDOptions } from '@launchdarkly/js-server-sdk-common'; + +import { OxygenLDOptions } from './validateOptions'; + +// Most limitations could be explained in the following references: +// - https://shopify.dev/docs/storefronts/headless/hydrogen/fundamentals +// - https://shopify.dev/docs/storefronts/headless/hydrogen/deployments/oxygen-runtime +export const defaultOptions: LDOptions & OxygenLDOptions = { + // Streaming does not make sense for Oxygen worker environments as they are not designed to be long-lived. + // Specifically "Outbound API requests must complete within 2 minutes" + stream: false, + + // TODO: make sure this is necessary + diagnosticOptOut: true, + + // 2 minutes is the maximum allowed time for outbound API requests + // so we set this to anything above that since we only want to have 1 + // poll request per request handler execution. + pollInterval: 300, + + logger: new BasicLogger({ name: 'Shopify Oxygen SDK' }), + cache: { + ttlSeconds: 30, + name: 'launchdarkly-cache', + enabled: true, + }, +}; + +export const createOptions = (options: LDOptions & OxygenLDOptions = {}) => { + const finalOptions = { + ...defaultOptions, + ...options, + cache: { + ...defaultOptions.cache, + ...options.cache, + }, + }; + finalOptions.logger?.debug(`Using LD options: ${JSON.stringify(finalOptions)}`); + return finalOptions; +}; diff --git a/packages/sdk/shopify-oxygen/src/utils/index.ts b/packages/sdk/shopify-oxygen/src/utils/index.ts new file mode 100644 index 000000000..f421097b2 --- /dev/null +++ b/packages/sdk/shopify-oxygen/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './createCallbacks'; +export * from './createOptions'; +export * from './validateOptions'; diff --git a/packages/sdk/shopify-oxygen/src/utils/validateOptions.ts b/packages/sdk/shopify-oxygen/src/utils/validateOptions.ts new file mode 100644 index 000000000..283dad53d --- /dev/null +++ b/packages/sdk/shopify-oxygen/src/utils/validateOptions.ts @@ -0,0 +1,22 @@ +import { LDOptions as LDOptionsCommon } from '@launchdarkly/js-server-sdk-common'; + +export type OxygenCacheOptions = { + // The time-to-live for the cache in seconds. The default is 30 seconds since that is the + // minimum allowed polling interval for other SDKs. If this SDK is too noisy, then we can + // enforce cache and a minimum ttl here. + ttlSeconds?: number; + name?: string; + enabled?: boolean; +}; + +export type OxygenLDOptions = Pick & { + cache?: OxygenCacheOptions; +}; + +export const validateOptions = (sdkKey: string) => { + if (!sdkKey) { + throw new Error('You must configure the client with a client key'); + } + + return true; +}; diff --git a/packages/sdk/shopify-oxygen/tsconfig.eslint.json b/packages/sdk/shopify-oxygen/tsconfig.eslint.json new file mode 100644 index 000000000..15a190c6a --- /dev/null +++ b/packages/sdk/shopify-oxygen/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "include": ["/**/*.ts"], + "exclude": ["node_modules"] +} + diff --git a/packages/sdk/shopify-oxygen/tsconfig.json b/packages/sdk/shopify-oxygen/tsconfig.json new file mode 100644 index 000000000..00deb6ef6 --- /dev/null +++ b/packages/sdk/shopify-oxygen/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "lib": ["es6"], + "module": "ES6", + "moduleResolution": "node", + "noImplicitOverride": true, + "outDir": "dist", + "resolveJsonModule": true, + "rootDir": ".", + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "stripInternal": true, + "target": "ES2017", + "types": ["jest"] + }, + "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__", "example", "tsup.config.ts"] +} diff --git a/packages/sdk/shopify-oxygen/tsconfig.ref.json b/packages/sdk/shopify-oxygen/tsconfig.ref.json new file mode 100644 index 000000000..d5b9e9c75 --- /dev/null +++ b/packages/sdk/shopify-oxygen/tsconfig.ref.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__", "example"] +} diff --git a/packages/sdk/shopify-oxygen/tsup.config.ts b/packages/sdk/shopify-oxygen/tsup.config.ts new file mode 100644 index 000000000..d0a43aa70 --- /dev/null +++ b/packages/sdk/shopify-oxygen/tsup.config.ts @@ -0,0 +1,27 @@ +// 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', + }, + minify: true, + format: ['esm', 'cjs'], + splitting: false, + sourcemap: true, + clean: true, + // Externalize node_modules - we're using js-server-sdk-common which doesn't need polyfills + dts: true, + metafile: true, + esbuildOptions(opts) { + // This would normally be `^_(?!meta|_)`, but go doesn't support negative look-ahead assertions, + // so we need to craft something that works without it. + // So start of line followed by a character that isn't followed by m or underscore, but we + // want other things that do start with m, so we need to progressively handle more characters + // of meta with exclusions. + // eslint-disable-next-line no-param-reassign + opts.mangleProps = /^_([^m|_]|m[^e]|me[^t]|met[^a])/; + }, +});