Skip to content

Commit e70e484

Browse files
authored
feat: adding shopify oxygen server sdk (#991)
**Requirements** - [x] I have added test coverage for new or changed functionality - [ ] I have followed the repository's [pull request submission guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests) - [x] I have validated my changes against all supported platform versions **Related issues** Provide links to any issues in this repository or elsewhere relating to this pull request. **Describe the solution you've provided** Provide a clear and concise description of what you expect to happen. **Describe alternatives you've considered** Provide a clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context about the pull request here. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds a new Shopify Oxygen server SDK package with Oxygen-specific platform, cache-backed polling, crypto utilities, initialization helpers, and tests. > > - **New package**: `packages/sdk/shopify-oxygen` added to workspaces. > - **Platform**: > - `OxygenRequests`: GET request caching via Oxygen Cache API with `Cache-Control` headers; uses `NullEventSource`. > - `OxygenCrypto`: SHA1/SHA256 hash/HMAC via `crypto-js`; `randomUUID` passthrough. > - `OxygenInfo` and platform wiring in `platform/index.ts`. > - Timer polyfills for Oxygen runtime (`src/polyfills/timers.ts`). > - **SDK entry & options**: > - `init(sdkKey, options)` using `LDClientImpl` with callbacks stub; exports `OxygenLDOptions`. > - Defaults: streaming disabled, `pollInterval=300`, diagnostics opt-out, logger, and configurable cache (`ttlSeconds`, `name`, `enabled`). > - Option validation (`validateOptions`), option merging (`createOptions`). > - **Tests**: > - Jest setup with mocked `caches`, `fetch`, timers; JSON flag/segment fixtures. > - Coverage for initialization, flag evaluation (rules/rollouts/segments), and caching behavior. > - **Tooling & docs**: > - `package.json`, `jest.config.cjs`, `tsconfig*`, `tsup.config.ts`. > - `README.md` with usage/options and `CHANGELOG.md`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d1377ba. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 213fc79 commit e70e484

27 files changed

+1196
-1
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
"packages/sdk/server-ai/examples/vercel-ai",
4242
"packages/telemetry/browser-telemetry",
4343
"contract-tests",
44-
"packages/sdk/combined-browser"
44+
"packages/sdk/combined-browser",
45+
"packages/sdk/shopify-oxygen"
4546
],
4647
"private": true,
4748
"scripts": {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Local module builds
2+
*.tgz
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Change log
2+
================================================
3+
4+
All notable changes to `@launchdarkly/shopify-oxygen-sdk` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org).
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
LaunchDarkly Server SDK for Shopify Oxygen Runtimes
2+
===========================
3+
4+
<!-- TODO nothing is live yet
5+
[![NPM][npm-badge]][npm-link]
6+
[![Actions Status][ci-badge]][ci-link]
7+
[![Documentation][ghp-badge]][ghp-link]
8+
[![NPM][npm-dm-badge]][npm-link]
9+
[![NPM][npm-dt-badge]][npm-link]
10+
-->
11+
12+
# ⛔️⛔️⛔️⛔️
13+
14+
> [!CAUTION]
15+
> *This version of the SDK is a **beta** version and should not be considered ready for production use while this message is visible.*
16+
17+
# ☝️☝️☝️☝️☝️☝️
18+
19+
LaunchDarkly overview
20+
-------------------------
21+
[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!
22+
23+
[![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly)
24+
25+
Supported Oxygen runtime versions
26+
-------------------------
27+
28+
This version of the LaunchDarkly SDK has been tested with Oxygen compatibility date `2025-01-01`.
29+
> Check [worker compatibility date](https://shopify.dev/docs/storefronts/headless/hydrogen/deployments/oxygen-runtime#worker-compatibility-flags)
30+
31+
Getting started
32+
-----------
33+
34+
<!-- TODO no LD documentation yet
35+
Refer to the [SDK documentation](https://docs.launchdarkly.com/sdk/client-side/android#getting-started) for instructions on getting started with using the SDK.
36+
-->
37+
38+
Install this package:
39+
```
40+
npm install @launchdarkly/shopify-oxygen-sdk --save
41+
```
42+
43+
Import the module
44+
```
45+
import {init} from '@launchdarkly/shopify-oxygen-sdk';
46+
```
47+
48+
Declare required variables
49+
```
50+
const sdkKey = 'your-sdk-key';
51+
const options = {};
52+
53+
const flagKey = 'your-flag';
54+
const context = {
55+
kind: 'user',
56+
key: 'example-user-key',
57+
name: 'tester',
58+
};
59+
const defaultValue = false;
60+
```
61+
62+
Basic SDK usage example
63+
```
64+
const ldClient = await init(sdkKey, options);
65+
await ldClient.waitForInitialization({timeout: 10});
66+
const flagValue = await ldClient.variation(flagKey, context, defaultValue);
67+
```
68+
69+
Options
70+
-----------
71+
The SDK accepts an `options` object as its second argument to `init(sdkKey, options)`. The supported options for this SDK are shown below.
72+
73+
### cache
74+
75+
`cache` defines how this SDK interacts with [Oxygen's native cache api](https://shopify.dev/docs/storefronts/headless/hydrogen/deployments/oxygen-runtime#cache-api).
76+
77+
| Option | Type | Default | Description |
78+
| ------------- | -------- | ------- | ------------------------------------------------ |
79+
| `ttlSeconds` | number | 30 | Time-to-live for cache entries, in seconds. |
80+
| `name` | string | 'launchdarkly-cache' | Name for the cache instance. |
81+
| `enabled` | boolean | true | Whether caching is enabled. |
82+
83+
Example:
84+
```js
85+
const options = {
86+
cache: {
87+
ttlSeconds: 60, // cache values for 60 seconds within the request
88+
name: 'my-custom-cache',
89+
enabled: true,
90+
}
91+
}
92+
```
93+
94+
### logger
95+
96+
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`.
97+
98+
| Option | Type | Default | Description |
99+
|----------|--------|------------------------------|----------------------------------------|
100+
| logger | object | a basic internal logger | Optional custom logger implementation. |
101+
102+
Example:
103+
```js
104+
const options = {
105+
logger: myCustomLogger, // must match the LD logger interface
106+
}
107+
```
108+
---
109+
See the source for default values and logic:
110+
- [validateOptions.ts](./src/utils/validateOptions.ts)
111+
- [createOptions.ts](./src/utils/createOptions.ts)
112+
113+
114+
<!-- TODO add in reference to example -->
115+
116+
Learn more
117+
-----------
118+
119+
Read our [documentation](https://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly.
120+
121+
<!-- TODO nothing is generated yet
122+
You can also head straight to the [complete reference guide for this SDK](https://docs.launchdarkly.com/sdk/server-side/java) or our [code-generated API documentation](https://launchdarkly.github.io/java-server-sdk/).
123+
-->
124+
125+
Testing
126+
-------
127+
128+
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.
129+
130+
Contributing
131+
------------
132+
133+
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.
134+
135+
About LaunchDarkly
136+
-----------
137+
138+
* 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:
139+
* 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.
140+
* 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?).
141+
* 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.
142+
* 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.
143+
* 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.
144+
* Explore LaunchDarkly
145+
* [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information
146+
* [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides
147+
* [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation
148+
* [launchdarkly.com/blog](https://launchdarkly.com/blog/ "LaunchDarkly Blog Documentation") for the latest product updates
149+
150+
<!-- TODO nothing is live yet
151+
[ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/react-native.yml/badge.svg
152+
[ci-link]: https://github.com/launchdarkly/js-core/actions/workflows/react-native.yml
153+
[npm-badge]: https://img.shields.io/npm/v/@launchdarkly/react-native-client-sdk.svg?style=flat-square
154+
[npm-link]: https://www.npmjs.com/package/@launchdarkly/react-native-client-sdk
155+
[ghp-badge]: https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8
156+
[ghp-link]: https://launchdarkly.github.io/js-core/packages/sdk/react-native/docs/
157+
[npm-dm-badge]: https://img.shields.io/npm/dm/@launchdarkly/react-native-client-sdk.svg?style=flat-square
158+
[npm-dt-badge]: https://img.shields.io/npm/dt/@launchdarkly/react-native-client-sdk.svg?style=flat-square
159+
-->
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { LDClient, LDContext } from '@launchdarkly/js-server-sdk-common';
2+
3+
import { init, OxygenLDOptions } from '../src/index';
4+
import { setupTestEnvironment } from './setup';
5+
6+
const sdkKey = 'test-sdk-key';
7+
const flagKey1 = 'testFlag1';
8+
const flagKey2 = 'testFlag2';
9+
const flagKey3 = 'testFlag3';
10+
const context: LDContext = { kind: 'user', key: 'test-user-key-1' };
11+
12+
describe('Shopify Oxygen SDK', () => {
13+
describe('initialization tests', () => {
14+
beforeEach(async () => {
15+
await setupTestEnvironment();
16+
});
17+
18+
it('will initialize successfully with default options', async () => {
19+
const ldClient = init(sdkKey);
20+
await ldClient.waitForInitialization();
21+
expect(ldClient).toBeDefined();
22+
ldClient.close();
23+
});
24+
25+
it('will initialize successfully with custom options', async () => {
26+
const ldClient = init(sdkKey, {
27+
sendEvents: false,
28+
cache: {
29+
enabled: false,
30+
},
31+
} as OxygenLDOptions);
32+
await ldClient.waitForInitialization();
33+
expect(ldClient).toBeDefined();
34+
ldClient.close();
35+
});
36+
37+
it('will fail to initialize if there is no SDK key', () => {
38+
expect(() => init(null as any)).toThrow();
39+
});
40+
});
41+
42+
describe('polling tests', () => {
43+
beforeEach(async () => {
44+
await setupTestEnvironment();
45+
});
46+
47+
describe('without caching', () => {
48+
let ldClient: LDClient;
49+
50+
beforeEach(async () => {
51+
// Ensure fetch is set up before creating client
52+
ldClient = init(sdkKey, {
53+
cache: {
54+
enabled: false,
55+
},
56+
} as OxygenLDOptions);
57+
await ldClient.waitForInitialization();
58+
});
59+
60+
afterEach(() => {
61+
if (ldClient) {
62+
ldClient.close();
63+
}
64+
});
65+
66+
it('Should not cache any requests', async () => {
67+
await ldClient.variation(flagKey1, context, false);
68+
await ldClient.allFlagsState(context);
69+
await ldClient.variationDetail(flagKey3, context, false);
70+
expect(caches.open).toHaveBeenCalledTimes(0);
71+
});
72+
73+
describe('flags', () => {
74+
it('variation default', async () => {
75+
const value = await ldClient.variation(flagKey1, context, false);
76+
77+
expect(value).toBeTruthy();
78+
79+
expect(caches.open).toHaveBeenCalledTimes(0);
80+
});
81+
82+
it('variation default rollout', async () => {
83+
const contextWithEmail = { ...context, email: '[email protected]' };
84+
const value = await ldClient.variation(flagKey2, contextWithEmail, false);
85+
const detail = await ldClient.variationDetail(flagKey2, contextWithEmail, false);
86+
87+
expect(detail).toEqual({
88+
reason: { kind: 'FALLTHROUGH' },
89+
value: true,
90+
variationIndex: 0,
91+
});
92+
expect(value).toBeTruthy();
93+
94+
expect(caches.open).toHaveBeenCalledTimes(0);
95+
});
96+
97+
it('rule match', async () => {
98+
const contextWithEmail = { ...context, email: '[email protected]' };
99+
const value = await ldClient.variation(flagKey1, contextWithEmail, false);
100+
const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false);
101+
102+
expect(detail).toEqual({
103+
reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 },
104+
value: false,
105+
variationIndex: 1,
106+
});
107+
expect(value).toBeFalsy();
108+
109+
expect(caches.open).toHaveBeenCalledTimes(0);
110+
});
111+
112+
it('fallthrough', async () => {
113+
const contextWithEmail = { ...context, email: '[email protected]' };
114+
const value = await ldClient.variation(flagKey1, contextWithEmail, false);
115+
const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false);
116+
117+
expect(detail).toEqual({
118+
reason: { kind: 'FALLTHROUGH' },
119+
value: true,
120+
variationIndex: 0,
121+
});
122+
expect(value).toBeTruthy();
123+
124+
expect(caches.open).toHaveBeenCalledTimes(0);
125+
});
126+
127+
it('allFlags fallthrough', async () => {
128+
const allFlags = await ldClient.allFlagsState(context);
129+
130+
expect(allFlags).toBeDefined();
131+
expect(allFlags.toJSON()).toEqual({
132+
$flagsState: {
133+
testFlag1: { debugEventsUntilDate: 2000, variation: 0, version: 2 },
134+
testFlag2: { debugEventsUntilDate: 2000, variation: 0, version: 2 },
135+
testFlag3: { debugEventsUntilDate: 2000, variation: 0, version: 2 },
136+
},
137+
$valid: true,
138+
testFlag1: true,
139+
testFlag2: true,
140+
testFlag3: true,
141+
});
142+
143+
expect(caches.open).toHaveBeenCalledTimes(0);
144+
});
145+
});
146+
147+
describe('segments', () => {
148+
it('segment by country', async () => {
149+
const contextWithCountry = { ...context, country: 'australia' };
150+
const value = await ldClient.variation(flagKey3, contextWithCountry, false);
151+
const detail = await ldClient.variationDetail(flagKey3, contextWithCountry, false);
152+
153+
expect(detail).toEqual({
154+
reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 },
155+
value: false,
156+
variationIndex: 1,
157+
});
158+
expect(value).toBeFalsy();
159+
160+
expect(caches.open).toHaveBeenCalledTimes(0);
161+
});
162+
});
163+
});
164+
165+
describe('with caching', () => {
166+
let ldClient: LDClient;
167+
168+
beforeEach(async () => {
169+
// Ensure fetch is set up before creating client
170+
ldClient = init(sdkKey);
171+
await ldClient.waitForInitialization();
172+
});
173+
174+
afterEach(() => {
175+
if (ldClient) {
176+
ldClient.close();
177+
}
178+
});
179+
180+
it('will cache across multiple variation calls', async () => {
181+
await ldClient.variation(flagKey1, context, false);
182+
await ldClient.variation(flagKey2, context, false);
183+
184+
// Should only fetch once due to caching
185+
expect(caches.open).toHaveBeenCalledTimes(1);
186+
});
187+
188+
it('will cache across multiple allFlags calls', async () => {
189+
await ldClient.allFlagsState(context);
190+
await ldClient.allFlagsState(context);
191+
192+
expect(caches.open).toHaveBeenCalledTimes(1);
193+
});
194+
195+
it('will cache between allFlags and variation', async () => {
196+
await ldClient.variation(flagKey1, context, false);
197+
await ldClient.allFlagsState(context);
198+
199+
expect(caches.open).toHaveBeenCalledTimes(1);
200+
});
201+
});
202+
});
203+
});

0 commit comments

Comments
 (0)