diff --git a/.gitignore b/.gitignore index 710fa5a..a659a0a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,6 @@ src/js/* !src/js/__tests__ types js -.claude/worktrees +.claude/ +.DS_Store src/version.ts diff --git a/README.md b/README.md index 043607c..e117d2a 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,54 @@ -# A/B Smartly SDK [![npm version](https://badge.fury.io/js/%40absmartly%2Fjavascript-sdk.svg)](https://badge.fury.io/js/%40absmartly%2Fjavascript-sdk) +# ABsmartly JavaScript SDK [![npm version](https://badge.fury.io/js/%40absmartly%2Fjavascript-sdk.svg)](https://badge.fury.io/js/%40absmartly%2Fjavascript-sdk) -A/B Smartly - JavaScript SDK +A/B Smartly - JavaScript SDK. This is the official isomorphic JavaScript SDK for the [A/B Smartly](https://www.absmartly.com/) A/B testing platform, compatible with both Node.js and browser environments. ## Compatibility -The A/B Smartly Javascript SDK is an isomorphic library for Node.js (CommonJS and ES6) and browsers (UMD). +The A/B Smartly JavaScript SDK is an isomorphic library for Node.js (CommonJS and ES6) and browsers (UMD). -It's supported on Node.js version 6.x and npm 3.x or later. +- **Node.js**: Version 6.x and npm 3.x or later +- **Browsers**: IE 10+ and all modern browsers (Chrome, Firefox, Safari, Edge) -It's supported on IE 10+ and all the other major browsers. - -**Note**: IE 10 does not natively support Promises. -If you target IE 10, you must include a polyfill like [es6-promise](https://www.npmjs.com/package/es6-promise) or [rsvp](https://www.npmjs.com/package/rsvp). +**Note**: IE 10 does not natively support Promises. If you target IE 10, you must include a polyfill like [es6-promise](https://www.npmjs.com/package/es6-promise) or [rsvp](https://www.npmjs.com/package/rsvp). ## Installation -#### npm +### npm ```shell npm install @absmartly/javascript-sdk --save ``` -#### Import in your Javascript application +### Import in your JavaScript application + ```javascript -const absmartly = require('@absmartly/javascript-sdk'); +const absmartly = require("@absmartly/javascript-sdk"); + // OR with ES6 modules: -import absmartly from '@absmartly/javascript-sdk'; +import absmartly from "@absmartly/javascript-sdk"; ``` +### Directly in the browser -#### Directly in the browser You can include an optimized and pre-built package directly in your HTML code through [unpkg.com](https://www.unpkg.com). Simply add the following code to your `head` section to include the latest published version. + ```html - + ``` ## Getting Started -Please follow the [installation](#installation) instructions before trying the following code: +Please follow the [installation](#installation) instructions before trying the following code. + +### Initialization + +This example assumes an API Key, an Application, and an Environment have been created in the A/B Smartly web console. -#### Initialization -This example assumes an Api Key, an Application, and an Environment have been created in the A/B Smartly web console. ```javascript -// somewhere in your application initialization code const sdk = new absmartly.SDK({ - endpoint: 'https://sandbox.absmartly.io/v1', + endpoint: "https://your-company.absmartly.io/v1", apiKey: process.env.ABSMARTLY_API_KEY, environment: process.env.NODE_ENV, application: process.env.APPLICATION_NAME, @@ -63,90 +65,123 @@ const sdk = new absmartly.SDK({ }); ``` +**SDK Options** + +| Option | Type | Required? | Default | Description | +| :----------- | :--------- | :-------: | :-----: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| endpoint | `string` | ✅ | `null` | The URL to your API endpoint. Most commonly `"your-company.absmartly.io"` | +| apiKey | `string` | ✅ | `null` | Your API key which can be found on the Web Console. | +| environment | `string` | ✅ | `null` | The environment of the platform where the SDK is installed. Environments are created on the Web Console and should match the available environments in your infrastructure. | +| application | `string` | ✅ | `null` | The name of the application where the SDK is installed. Applications are created on the Web Console and should match the applications where your experiments will be running.| +| retries | `number` | ❌ | `5` | Number of retry attempts for failed HTTP requests. | +| timeout | `number` | ❌ | `3000` | HTTP request timeout in milliseconds. | +| eventLogger | `function` | ❌ | `null` | Callback to handle SDK events (ready, exposure, goal, etc.) | + #### Creating a new Context with raw promises ```javascript -// define a new context request const request = { units: { - session_id: '5ebf06d8cb5d8137290c4abb64155584fbdb64d8', + session_id: "5ebf06d8cb5d8137290c4abb64155584fbdb64d8", }, }; -// create context with raw promises const context = sdk.createContext(request); -context.ready().then((response) => { - console.log("ABSmartly Context ready!") -}).catch((error) => { - console.log(error); +context.ready().then(() => { + console.log("ABsmartly Context ready!"); + if (context.isFailed()) { + console.error("ABsmartly Context failed:", context.readyError()); + } + + const treatment = context.treatment("exp_test"); }); ``` -#### Creating a new Context with async/await +### With async/await + ```javascript -// define a new context request const request = { units: { - session_id: '5ebf06d8cb5d8137290c4abb64155584fbdb64d8', + session_id: "5ebf06d8cb5d8137290c4abb64155584fbdb64d8", }, }; -// create context with raw promises const context = sdk.createContext(request); -try { - await context.ready(); - console.log("ABSmartly Context ready!") -} catch (error) { - console.log(error); +await context.ready(); +if (context.isFailed()) { + console.error("ABsmartly Context failed:", context.readyError()); } + +const treatment = context.treatment("exp_test"); ``` -#### Creating a new Context with pre-fetched data -When doing full-stack experimentation with A/B Smartly, we recommend creating a context only once on the server-side. -Creating a context involves a round-trip to the A/B Smartly event collector. -We can avoid repeating the round-trip on the client-side by sending the server-side data embedded in the first document, for example, by rendering it on the template. -Then we can initialize the A/B Smartly context on the client-side directly with it. +### With Pre-fetched Data + +When doing full-stack experimentation with A/B Smartly, we recommend creating a context only once on the server-side. Creating a context involves a round-trip to the A/B Smartly event collector. We can avoid repeating the round-trip on the client-side by sending the server-side data embedded in the first document, for example, by rendering it on the template. Then we can initialize the A/B Smartly context on the client-side directly with it. ```html - - - + + + ``` -#### Setting extra units for a context -You can add additional units to a context by calling the `unit()` or the `units()` method. -This method may be used for example, when a user logs in to your application, and you want to use the new unit type to the context. -Please note that **you cannot override an already set unit type** as that would be a change of identity, and will throw an exception. In this case, you must create a new context instead. -The `unit()` and `units()` methods can be called before the context is ready. +### Refreshing the Context with Fresh Experiment Data + +For long-running single-page-applications (SPA), the context is usually created once when the application is first reached. However, any experiments being tracked in your production code, but started after the context was created, will not be triggered. To mitigate this, we can use the `refreshInterval` option when creating the context. ```javascript -context.unit('db_user_id', 1000013); +const request = { + units: { + session_id: "5ebf06d8cb5d8137290c4abb64155584fbdb64d8", + }, +}; -// or -context.units({ - db_user_id: 1000013, +const context = sdk.createContext(request, { + refreshInterval: 5 * 60 * 1000, // 5 minutes }); ``` -#### Setting context attributes -The `attribute()` and `attributes()` methods can be called before the context is ready. +Alternatively, the `refresh()` method can be called manually. The `refresh()` method pulls updated experiment data from the A/B Smartly collector and will trigger recently started experiments when `treatment()` is called again. + ```javascript -context.attribute('user_agent', navigator.userAgent); +setTimeout(async () => { + try { + await context.refresh(); + } catch (error) { + console.error(error); + } +}, 5 * 60 * 1000); +``` -context.attributes({ - customer_age: 'new_customer', +### Setting Extra Units + +You can add additional units to a context by calling the `unit()` or the `units()` method. This is useful when a user logs in to your application and you want to add the new unit type to the context. + +> **Note:** You cannot override an already set unit type as that would be a change of identity. In this case, you must create a new context instead. + +The `unit()` and `units()` methods can be called before the context is ready. + +```javascript +context.unit("db_user_id", 1000013); + +context.units({ + db_user_id: 1000013, }); ``` +## Basic Usage + +### Selecting a Treatment + #### Including system attributes You can opt in to automatically include system attributes (SDK name, SDK version, application, environment, and application version) in every publish payload. These are sent as context attributes and can be useful for debugging and filtering in the Web Console. @@ -171,20 +206,89 @@ These system attributes are prepended before any user-defined attributes. #### Selecting a treatment ```javascript -if (context.treament("exp_test_experiment") == 0) { +if (context.treatment("exp_test_experiment") === 0) { // user is in control group (variant 0) } else { // user is in treatment group } ``` -#### Tracking a goal achievement +### Treatment Variables + +Variables allow you to configure experiment variations without code changes. + +```javascript +const defaultButtonColor = "red"; +const buttonColor = context.variableValue("button.color", defaultButtonColor); +``` + +### Peek at Treatment Variants + +Although generally not recommended, it is sometimes necessary to peek at a treatment without triggering an exposure. The A/B Smartly SDK provides a `peek()` method for that. + +```javascript +if (context.peek("exp_test_experiment") === 0) { + // user is in control group (variant 0) +} else { + // user is in treatment group +} +``` + +#### Peeking at Variable Values + +```javascript +const buttonColor = context.peekVariableValue("button.color", "red"); +``` + +### Overriding Treatment Variants + +During development, it is useful to force a treatment for an experiment. This can be achieved with the `override()` and/or `overrides()` methods. The `override()` and `overrides()` methods can be called before the context is ready. + +```javascript +context.override("exp_test_experiment", 1); // force variant 1 + +context.overrides({ + exp_test_experiment: 1, + exp_another_experiment: 0, +}); +``` + +## Advanced + +### Context Attributes + +Attributes are used to pass meta-data about the user and/or the request. They can be used later in the Web Console to create segments or audiences. They can be set using the `attribute()` or `attributes()` methods, before or after the context is ready. + +```javascript +context.attribute("user_agent", navigator.userAgent); + +context.attributes({ + customer_age: "new_customer", +}); +``` + +### Custom Assignments + +Sometimes it may be necessary to override the automatic selection of a variant. For example, if you wish to have your variant chosen based on data from an API call. This can be accomplished using the `customAssignment()` method. + +```javascript +context.customAssignment("exp_test_experiment", 1); + +context.customAssignments({ + exp_test_experiment: 1, +}); +``` + +### Tracking Goals + Goals are created in the A/B Smartly web console. + ```javascript context.track("payment", { item_count: 1, total_amount: 1999.99 }); ``` -#### Publishing pending data +### Publishing Pending Data + Sometimes it is necessary to ensure all events have been published to the A/B Smartly collector, before proceeding. One such case is when the user is about to navigate away right before being exposed to a treatment. You can explicitly call the `publish()` method, which returns a promise, before navigating away. @@ -219,88 +323,55 @@ const context = sdk.createContext(request, { }); ``` -Alternatively, the `refresh()` method can be called manually. -The `refresh()` method pulls updated experiment data from the A/B Smartly collector and will trigger recently started experiments when `treatment()` is called again. -```javascript -setTimeout(async () => { - try { - context.refresh(); - } catch(error) { - console.error(error); - } -}, 5 * 60 * 1000); -``` +### Using a Custom Event Logger + +The A/B Smartly SDK can be instantiated with an event logger used for all contexts. In addition, an event logger can be specified when creating a particular context, in the `createContext` call options. The example below illustrates this with the implementation of the default event logger, used if none is specified. -#### Using a custom Event Logger -The A/B Smartly SDK can be instantiated with an event logger used for all contexts. -In addition, an event logger can be specified when creating a particular context, in the `createContext` call options. -The example below illustrates this with the implementation of the default event logger, used if none is specified. ```javascript const sdk = new absmartly.SDK({ - endpoint: 'https://sandbox-api.absmartly.com/v1', + endpoint: "https://your-company.absmartly.io/v1", apiKey: process.env.ABSMARTLY_API_KEY, environment: process.env.NODE_ENV, application: process.env.APPLICATION_NAME, eventLogger: (context, eventName, data) => { - if (eventName == "error") { + if (eventName === "error") { console.error(data); } }, }); ``` -The data parameter depends on the type of event. -Currently, the SDK logs the following events: +**Event Types** -| eventName | when | data | -|:---: |---|---| -| `"error"` | `Context` receives an error | error object thrown | -| `"ready"` | `Context` turns ready | data used to initialize the context | -| `"refresh"` | `Context.refresh()` method succeeds | data used to refresh the context | -| `"publish"` | `Context.publish()` method succeeds | data sent to the A/B Smartly event collector | -| `"exposure"` | `Context.treatment()` method succeeds on first exposure | exposure data enqueued for publishing | -| `"goal"` | `Context.track()` method succeeds | goal data enqueued for publishing | -| `"finalize"` | `Context.finalize()` method succeeds the first time | undefined | +The data parameter depends on the type of event. Currently, the SDK logs the following events: +| Event | When | Data | +| :----------- | :------------------------------------------------ | :-------------------------------------------- | +| `"error"` | Context receives an error | Error object thrown | +| `"ready"` | Context turns ready | Data used to initialize the context | +| `"refresh"` | `refresh()` method succeeds | Data used to refresh the context | +| `"publish"` | `publish()` method succeeds | Data sent to the A/B Smartly event collector | +| `"exposure"` | `treatment()` method succeeds on first exposure | Exposure data enqueued for publishing | +| `"goal"` | `track()` method succeeds | Goal data enqueued for publishing | +| `"finalize"` | `finalize()` method succeeds the first time | undefined | -#### Peek at treatment variants -Although generally not recommended, it is sometimes necessary to peek at a treatment without triggering an exposure. -The A/B Smartly SDK provides a `peek()` method for that. - -```javascript -if (context.peek("exp_test_experiment") == 0) { - // user is in control group (variant 0) -} else { - // user is in treatment group -} -``` - -#### Overriding treatment variants -During development, for example, it is useful to force a treatment for an experiment. This can be achieved with the `override()` and/or `overrides()` methods. -The `override()` and `overrides()` methods can be called before the context is ready. -```javascript - context.override("exp_test_experiment", 1); // force variant 1 of treatment - context.overrides({ - exp_test_experiment: 1, - exp_another_experiment: 0, - }); -``` +### HTTP Request Timeout -#### HTTP request timeout -It is possible to set a timeout per individual HTTP request, overriding the global timeout set for all request when instantiating the SDK object. +It is possible to set a timeout per individual HTTP request, overriding the global timeout set for all requests when instantiating the SDK object. -Here is an example of setting a timeout only for the createContext request. +Here is an example of setting a timeout only for the `createContext` request. ```javascript const context = sdk.createContext(request, { refreshPeriod: 5 * 60 * 1000 }, { - timeout: 1500 + timeout: 1500, }); ``` -#### HTTP Request cancellation -Sometimes it is useful to cancel an inflight HTTP request, for example, when the user is navigating away. The A/B Smartly SDK also supports a cancellation via an `AbortSignal`. An implementation of AbortController is provided for older platforms, but will use the native implementation where available. +### HTTP Request Cancellation + +Sometimes it is useful to cancel an inflight HTTP request, for example, when the user is navigating away. The A/B Smartly SDK supports cancellation via an `AbortSignal`. An implementation of AbortController is provided for older platforms, but will use the native implementation where available. Here is an example of a cancellation scenario. @@ -309,7 +380,7 @@ const controller = new absmartly.AbortController(); const context = sdk.createContext(request, { refreshPeriod: 5 * 60 * 1000 }, { - signal: controller.signal + signal: controller.signal, }); // abort request if not ready after 1500ms @@ -320,14 +391,171 @@ await context.ready(); clearTimeout(timeoutId); ``` +## Node.js Usage + +### Express.js Middleware Example + +```javascript +const absmartly = require("@absmartly/javascript-sdk"); + +const sdk = new absmartly.SDK({ + endpoint: "https://your-company.absmartly.io/v1", + apiKey: process.env.ABSMARTLY_API_KEY, + environment: "production", + application: "website", +}); + +app.use(async (req, res, next) => { + const context = sdk.createContext({ + units: { + session_id: req.cookies.session_id, + }, + }); + + await context.ready(); + if (context.isFailed()) { + console.error("ABsmartly context failed:", context.readyError()); + } + req.absmartly = context; + next(); +}); + +app.get("/landing", (req, res) => { + const context = req.absmartly; + const treatment = context.treatment("exp_landing_page"); + + if (treatment === 0) { + res.render("landing-control"); + } else { + res.render("landing-treatment"); + } +}); +``` + +### Server-Side Rendering (SSR) with Data Forwarding + +Create the context on the server and pass the data to the client to avoid a second round-trip. + +```javascript +app.get("/", async (req, res) => { + const context = sdk.createContext({ + units: { session_id: req.cookies.session_id }, + }); + + await context.ready(); + + const contextData = context.data(); + const treatment = context.treatment("exp_homepage"); + + res.render("index", { + treatment, + absmartlyData: JSON.stringify(contextData), + }); +}); +``` + +On the client side, initialize the context with the pre-fetched data: + +```javascript +const context = sdk.createContextWith( + { units: { session_id: sessionId } }, + JSON.parse(window.__ABSMARTLY_DATA__) +); +// context is immediately ready, no round-trip needed +``` + +## Browser Usage + +### Single-Page Application (SPA) Example + +```javascript +import absmartly from "@absmartly/javascript-sdk"; + +const sdk = new absmartly.SDK({ + endpoint: "https://your-company.absmartly.io/v1", + apiKey: "YOUR_API_KEY", + environment: "production", + application: "website", + eventLogger: (context, eventName, data) => { + if (eventName === "exposure") { + analytics.track("Experiment Viewed", { + experiment: data.name, + variant: data.variant, + }); + } + }, +}); + +const context = sdk.createContext({ + units: { + session_id: getUserSessionId(), + }, +}); + +await context.ready(); + +const showNewFeature = context.treatment("exp_new_feature") !== 0; + +if (showNewFeature) { + renderNewFeature(); +} else { + renderOldFeature(); +} + +document.getElementById("checkout-btn").addEventListener("click", () => { + context.track("checkout", { total: getCartTotal() }); +}); +``` + +## Migration Guide (v1 → v2) + +Version 2.0.0 contains breaking changes made for cross-SDK consistency and correctness. These align the JavaScript SDK with the Python, Swift, Java, and other A/B Smartly SDKs. Most applications will not need code changes, but review the items below. + +### `ready()` no longer resolves with the Error object on failure + +**Before:** `ready()` resolved with the Error object on failure (e.g., `const result = await context.ready()` would give you the Error). + +**After:** `ready()` always resolves with `true`. It is a "wait for initialization" signal — you should always proceed with experiment code after it settles, even on failure. The SDK returns control variants (`0`) and default values gracefully when the API is down. Use `isFailed()` and `readyError()` to check for errors if needed. + +```javascript +await context.ready(); +if (context.isFailed()) { + console.error("Context failed:", context.readyError()); +} +const variant = context.treatment("exp_test"); // returns 0 (control) on failure +``` + +**When this might be a problem:** If your code used the return value as the Error object (e.g., `const err = await context.ready(); logError(err)`), it will now receive `true` instead. Use `context.readyError()` to access the error instead. + +### Unit IDs containing astral characters now hash to canonical UTF-8 + +**Before:** `stringToUint8Array` (used to hash unit IDs for variant assignment) encoded each UTF-16 code unit independently. A character outside the Basic Multilingual Plane (≥ U+10000, e.g. an emoji) was encoded as an invalid CESU-8 byte sequence rather than canonical UTF-8. + +**After:** Unit IDs are encoded as canonical 4-byte UTF-8, matching the A/B Smartly collector (which hashes with `UTF_8`) and the SDKs already using native UTF-8 (Go, Python, Ruby, etc.). + +**When this might be a problem:** A unit ID that contains an astral character (emoji, rare CJK, etc.) may now be assigned a **different variant** than it was under v1. **Unit IDs composed entirely of BMP characters (≤ U+FFFF) — which covers essentially all typical session IDs, UUIDs, and user IDs — are unaffected.** This only changes assignment for units whose IDs contain astral characters. + +### `audienceMismatch` cache invalidation on indeterminate audiences + +When an audience cannot be evaluated to a boolean (a malformed or non-boolean filter), the cached assignment's `audienceMismatch` flag is now left unchanged instead of being reset to `false`. This keeps a previously valid cached assignment from being needlessly invalidated. Assignment results for well-formed audiences are unchanged. ## About A/B Smartly -**A/B Smartly** is the leading provider of state-of-the-art, on-premises, full-stack experimentation platforms for engineering and product teams that want to confidently deploy features as fast as they can develop them. -A/B Smartly's real-time analytics helps engineering and product teams ensure that new features will improve the customer experience without breaking or degrading performance and/or business metrics. + +**A/B Smartly** is the leading provider of state-of-the-art, on-premises, full-stack experimentation platforms for engineering and product teams that want to confidently deploy features as fast as they can develop them. A/B Smartly's real-time analytics helps engineering and product teams ensure that new features will improve the customer experience without breaking or degrading performance and/or business metrics. ### Have a look at our growing list of clients and SDKs: +- [JavaScript SDK](https://www.github.com/absmartly/javascript-sdk) (this package) +- [React SDK](https://www.github.com/absmartly/react-sdk) +- [Vue2 SDK](https://www.github.com/absmartly/vue2-sdk) +- [Vue3 SDK](https://www.github.com/absmartly/vue3-sdk) - [Java SDK](https://www.github.com/absmartly/java-sdk) -- [JavaScript SDK](https://www.github.com/absmartly/javascript-sdk) -- [PHP SDK](https://www.github.com/absmartly/php-sdk) +- [Android SDK](https://www.github.com/absmartly/android-sdk) - [Swift SDK](https://www.github.com/absmartly/swift-sdk) -- [Vue2 SDK](https://www.github.com/absmartly/vue2-sdk) +- [Dart SDK](https://www.github.com/absmartly/dart-sdk) +- [Flutter SDK](https://www.github.com/absmartly/flutter-sdk) +- [PHP SDK](https://www.github.com/absmartly/php-sdk) +- [Python3 SDK](https://www.github.com/absmartly/python3-sdk) +- [Go SDK](https://www.github.com/absmartly/go-sdk) +- [Ruby SDK](https://www.github.com/absmartly/ruby-sdk) +- [.NET SDK](https://www.github.com/absmartly/dotnet-sdk) +- [Rust SDK](https://www.github.com/absmartly/rust-sdk) diff --git a/package-lock.json b/package-lock.json index 50528ba..d921a01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@absmartly/javascript-sdk", - "version": "1.14.0-beta.1", + "version": "2.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@absmartly/javascript-sdk", - "version": "1.14.0-beta.1", + "version": "2.0.0", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.29.2", diff --git a/package.json b/package.json index 220053b..1f074df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@absmartly/javascript-sdk", - "version": "1.14.0-beta.1", + "version": "2.0.0", "description": "A/B Smartly Javascript SDK", "homepage": "https://github.com/absmartly/javascript-sdk#README.md", "bugs": "https://github.com/absmartly/javascript-sdk/issues", diff --git a/src/__tests__/abort-controller-shim.test.js b/src/__tests__/abort-controller-shim.test.js index fdc3ee0..cc227f5 100644 --- a/src/__tests__/abort-controller-shim.test.js +++ b/src/__tests__/abort-controller-shim.test.js @@ -88,4 +88,45 @@ describe("AbortController", () => { expect(aborter[Symbol.toStringTag]).toEqual("AbortController"); }); + + describe("signal.reason", () => { + it("should set default reason on abort()", () => { + const controller = new AbortController(); + controller.abort(); + expect(controller.signal.aborted).toBe(true); + expect(controller.signal.reason).toBeInstanceOf(Error); + expect(controller.signal.reason.message).toBe("The operation was aborted."); + }); + + it("should set custom reason on abort(reason)", () => { + const controller = new AbortController(); + const customReason = new Error("custom abort"); + controller.abort(customReason); + expect(controller.signal.reason).toBe(customReason); + }); + + it("should have undefined reason before abort", () => { + const controller = new AbortController(); + expect(controller.signal.reason).toBeUndefined(); + }); + }); + + describe("dispatchEvent onabort handling", () => { + it("should call onabort handler on abort dispatch", () => { + const signal = new AbortSignal(); + const handler = jest.fn(); + signal.onabort = handler; + signal.dispatchEvent({ type: "abort" }); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ type: "abort" }); + }); + + it("should not call onabort for non-abort events", () => { + const signal = new AbortSignal(); + const handler = jest.fn(); + signal.onabort = handler; + signal.dispatchEvent({ type: "other" }); + expect(handler).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/__tests__/client.test.js b/src/__tests__/client.test.js index fca41fd..d9e8e54 100644 --- a/src/__tests__/client.test.js +++ b/src/__tests__/client.test.js @@ -1188,4 +1188,19 @@ describe("Client", () => { done(); }); }); + + describe("timeout option", () => { + it("should accept an explicit timeout of 0 (nullish coalescing, not falsy)", () => { + const client = new Client({ + endpoint, + agent, + environment, + apiKey, + application, + timeout: 0, + }); + + expect(client).toBeInstanceOf(Client); + }); + }); }); diff --git a/src/__tests__/constructor-options.test.js b/src/__tests__/constructor-options.test.js new file mode 100644 index 0000000..4e0b437 --- /dev/null +++ b/src/__tests__/constructor-options.test.js @@ -0,0 +1,65 @@ +// Constructor option-merging tests for SDK and Client. +// +// These deliberately use the real SDK and Client constructors (no jest.mock), +// so they exercise the actual _extractClientOptions / option-merge logic +// rather than Jest doubles, which would make the assertions vacuous. + +import SDK from "../sdk"; +import Client from "../client"; + +describe("SDK and Client constructor option merging", () => { + it("should extract client options from SDK options and pass them to the real Client", () => { + const sdk = new SDK({ + agent: "test-agent", + apiKey: "key", + application: "app", + endpoint: "http://localhost", + environment: "test", + timeout: 5000, + }); + + const client = sdk.getClient(); + expect(client).toBeInstanceOf(Client); + expect(client.getAgent()).toBe("test-agent"); + expect(client.getEnvironment()).toBe("test"); + expect(client.getApplication()).toEqual({ name: "app", version: 0 }); + }); + + it("should accept the SDK application option as an object", () => { + const sdk = new SDK({ + agent: "test", + apiKey: "key", + application: { name: "myapp", version: "1.2.3" }, + endpoint: "http://localhost", + environment: "prod", + }); + + expect(sdk.getClient().getApplication()).toEqual({ name: "myapp", version: "1.2.3" }); + }); + + it("should merge provided Client options with the defaults", () => { + const client = new Client({ + endpoint: "http://test", + agent: "custom-agent", + environment: "prod", + apiKey: "key123", + application: "myapp", + timeout: 10000, + }); + + expect(client.getAgent()).toBe("custom-agent"); + expect(client.getEnvironment()).toBe("prod"); + expect(client.getApplication()).toEqual({ name: "myapp", version: 0 }); + }); + + it("should fall back to the default agent when it is omitted", () => { + const client = new Client({ + endpoint: "http://test", + environment: "prod", + apiKey: "key123", + application: "myapp", + }); + + expect(client.getAgent()).toBe("javascript-client"); + }); +}); diff --git a/src/__tests__/context.test.js b/src/__tests__/context.test.js index fc080e5..a6d1a6c 100644 --- a/src/__tests__/context.test.js +++ b/src/__tests__/context.test.js @@ -166,7 +166,7 @@ describe("Context", () => { config: '{"card.width":"75%"}', }, ], - audience: "{}", + audience: "", customFieldValues: null, }, { @@ -204,7 +204,7 @@ describe("Context", () => { config: '{"submit.color":"green","submit.shape":"square"}', }, ], - audience: "null", + audience: "", customFieldValues: null, }, { @@ -612,12 +612,12 @@ describe("Context", () => { expect(context.isFinalized()).toEqual(false); expect(() => context.data()).toThrow(); - expect(() => context.treatment("test")).toThrow(); - expect(() => context.peek("test")).toThrow(); - expect(() => context.experiments()).toThrow(); - expect(() => context.variableKeys()).toThrow(); - expect(() => context.variableValue("a", 17)).toThrow(); - expect(() => context.peekVariableValue("a", 17)).toThrow(); + expect(() => context.treatment("test")).toThrow("ABsmartly Context is not yet ready."); + expect(() => context.peek("test")).toThrow("ABsmartly Context is not yet ready."); + expect(() => context.experiments()).toThrow("ABsmartly Context is not yet ready."); + expect(() => context.variableKeys()).toThrow("ABsmartly Context is not yet ready."); + expect(() => context.variableValue("a", 17)).toThrow("ABsmartly Context is not yet ready."); + expect(() => context.peekVariableValue("a", 17)).toThrow("ABsmartly Context is not yet ready."); done(); }); @@ -1074,6 +1074,154 @@ describe("Context", () => { }); }); + it("should clear assignment cache for started experiment", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + + expect(context.treatment("exp_test_new")).toEqual(0); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(2); + + provider.getContextData.mockReturnValue(Promise.resolve(refreshContextResponse)); + + context.refresh().then(() => { + expect(context.treatment("exp_test_new")).toEqual(expectedVariants["exp_test_new"]); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(3); + + done(); + }); + }); + + it("should clear assignment cache for stopped experiment", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(2); + + const refreshWithStoppedExperiment = { + ...getContextResponse, + experiments: getContextResponse.experiments.filter((x) => x.name !== "exp_test_abc"), + }; + + provider.getContextData.mockReturnValue(Promise.resolve(refreshWithStoppedExperiment)); + + context.refresh().then(() => { + expect(context.treatment("exp_test_abc")).toEqual(0); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(3); + + done(); + }); + }); + + it("should clear assignment cache when experiment ID changes", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(2); + + const refreshWithChangedId = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_abc") { + return { + ...x, + id: 11, + trafficSeedHi: 54870830, + trafficSeedLo: 398724581, + seedHi: 77498863, + seedLo: 34737352, + }; + } + return x; + }), + }; + + provider.getContextData.mockReturnValue(Promise.resolve(refreshWithChangedId)); + + context.refresh().then(() => { + expect(context.treatment("exp_test_abc")).toEqual(2); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(3); + + done(); + }); + }); + + it("should clear assignment cache when full-on changes", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + + expect(context.treatment("exp_test_abc")).toEqual(expectedVariants["exp_test_abc"]); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(2); + + const refreshWithFullOn = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_abc") { + return { + ...x, + fullOnVariant: 1, + }; + } + return x; + }), + }; + + provider.getContextData.mockReturnValue(Promise.resolve(refreshWithFullOn)); + + context.refresh().then(() => { + expect(context.treatment("exp_test_abc")).toEqual(1); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(3); + + done(); + }); + }); + + it("should clear assignment cache when traffic split changes", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + + expect(context.treatment("exp_test_not_eligible")).toEqual(expectedVariants["exp_test_not_eligible"]); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(2); + + const refreshWithTrafficSplit = { + ...getContextResponse, + experiments: getContextResponse.experiments.map((x) => { + if (x.name === "exp_test_not_eligible") { + return { + ...x, + trafficSplit: [0.0, 1.0], + }; + } + return x; + }), + }; + + provider.getContextData.mockReturnValue(Promise.resolve(refreshWithTrafficSplit)); + + context.refresh().then(() => { + expect(context.treatment("exp_test_not_eligible")).toEqual(2); + expect(context.treatment("not_found")).toEqual(0); + + expect(context.pending()).toEqual(3); + + done(); + }); + }); + it("should throw after finalized() call", (done) => { const context = new Context(sdk, contextOptions, contextParams, getContextResponse); publisher.publish.mockReturnValue(Promise.resolve()); @@ -1372,6 +1520,30 @@ describe("Context", () => { done(); }); + + it("should throw when not ready", (done) => { + const context = new Context(sdk, contextOptions, contextParams, Promise.resolve(getContextResponse)); + expect(context.isReady()).toEqual(false); + + expect(() => context.peek("exp_test_ab")).toThrow("ABsmartly Context is not yet ready."); + + done(); + }); + + it("should throw after finalize", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + publisher.publish.mockReturnValue(Promise.resolve()); + + context.treatment("exp_test_ab"); + + context.finalize().then(() => { + expect(() => context.peek("exp_test_ab")).toThrow("ABsmartly Context is finalized."); + done(); + }); + + expect(context.isFinalizing()).toEqual(true); + expect(() => context.peek("exp_test_ab")).toThrow("ABsmartly Context is finalizing."); + }); }); describe("treatment()", () => { @@ -1815,13 +1987,13 @@ describe("Context", () => { expect(context.pending()).toEqual(1); context.finalize().then(() => { - expect(() => context.treatment("exp_test_ab")).toThrow(); + expect(() => context.treatment("exp_test_ab")).toThrow("ABsmartly Context is finalized."); done(); }); expect(context.isFinalizing()).toEqual(true); - expect(() => context.treatment("exp_test_ab")).toThrow(); + expect(() => context.treatment("exp_test_ab")).toThrow("ABsmartly Context is finalizing."); }); it("should re-evaluate audience expression when attributes change in strict mode", (done) => { @@ -2021,6 +2193,15 @@ describe("Context", () => { done(); }); + it("should throw when not ready", (done) => { + const context = new Context(sdk, contextOptions, contextParams, Promise.resolve(getContextResponse)); + expect(context.isReady()).toEqual(false); + + expect(() => context.treatment("exp_test_ab")).toThrow("ABsmartly Context is not yet ready."); + + done(); + }); + it("should update attrsSeq after checking unchanged audience to avoid repeated evaluation", (done) => { const context = new Context(sdk, contextOptions, contextParams, audienceStrictContextResponse); @@ -3128,13 +3309,13 @@ describe("Context", () => { expect(context.pending()).toEqual(1); context.finalize().then(() => { - expect(() => context.variableValue("button.color", 17)).toThrow(); + expect(() => context.variableValue("button.color", 17)).toThrow("ABsmartly Context is finalized."); done(); }); expect(context.isFinalizing()).toEqual(true); - expect(() => context.variableValue("button.color", 17)).toThrow(); + expect(() => context.variableValue("button.color", 17)).toThrow("ABsmartly Context is finalizing."); }); }); @@ -4330,6 +4511,87 @@ describe("Context", () => { }); }); }); + + it("should clear assignment cache when override changes", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + + context.override("exp_test_ab", 2); + context.treatment("exp_test_ab"); + + expect(context.pending()).toEqual(1); + + context.override("exp_test_ab", 2); + context.treatment("exp_test_ab"); + + expect(context.pending()).toEqual(1); + + context.override("exp_test_ab", 3); + context.treatment("exp_test_ab"); + + expect(context.pending()).toEqual(2); + + publisher.publish.mockReturnValue(Promise.resolve()); + + context.publish().then(() => { + expect(publisher.publish).toHaveBeenCalledWith( + { + publishedAt: 1611141535729, + units: publishUnits, + hashed: true, + sdkVersion: SDK_VERSION, + exposures: [ + { + id: 1, + name: "exp_test_ab", + unit: "session_id", + exposedAt: 1611141535729, + variant: 2, + assigned: false, + eligible: true, + overridden: true, + fullOn: false, + custom: false, + audienceMismatch: false, + ruleOverride: false, + }, + { + id: 1, + name: "exp_test_ab", + unit: "session_id", + exposedAt: 1611141535729, + variant: 3, + assigned: false, + eligible: true, + overridden: true, + fullOn: false, + custom: false, + audienceMismatch: false, + ruleOverride: false, + }, + ], + }, + sdk, + context, + undefined + ); + + done(); + }); + }); + + it("should clear assignment cache when overriding computed assignment", (done) => { + const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + + expect(context.treatment("exp_test_ab")).toEqual(expectedVariants["exp_test_ab"]); + expect(context.pending()).toEqual(1); + + context.override("exp_test_ab", 9); + expect(context.treatment("exp_test_ab")).toEqual(9); + + expect(context.pending()).toEqual(2); + + done(); + }); }); describe("customAssignment()", () => { @@ -4606,27 +4868,28 @@ describe("Context", () => { expect(context.customFieldValue("exp_test_custom_fields", "false_boolean_field")).toEqual(false); }); - it("should console an error when JSON cannot be parsed", () => { - const errorSpy = jest.spyOn(console, "error"); - const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + it("should log an error through eventLogger when JSON cannot be parsed", () => { + const eventLogger = jest.fn(); + const context = new Context(sdk, { ...contextOptions, eventLogger }, contextParams, getContextResponse); expect(context.pending()).toEqual(0); expect(context.customFieldValue("exp_test_abc", "json_invalid")).toEqual(null); - expect(errorSpy).toHaveBeenCalledTimes(1); - expect(errorSpy).toHaveBeenCalledWith( - "Failed to parse JSON custom field value 'json_invalid' for experiment 'exp_test_abc'" - ); + expect(eventLogger).toHaveBeenCalledWith(context, "error", expect.any(Error)); }); - it("should console an error when a field type is invalid", () => { - const errorSpy = jest.spyOn(console, "error"); - const context = new Context(sdk, contextOptions, contextParams, getContextResponse); + it("should log an error through eventLogger when a field type is invalid", () => { + const eventLogger = jest.fn(); + const context = new Context(sdk, { ...contextOptions, eventLogger }, contextParams, getContextResponse); expect(context.pending()).toEqual(0); expect(context.customFieldValue("exp_test_custom_fields", "invalid_type_field")).toEqual(null); - expect(errorSpy).toHaveBeenCalledTimes(1); - expect(errorSpy).toHaveBeenCalledWith( - "Unknown custom field type 'invalid' for experiment 'exp_test_custom_fields' and key 'invalid_type_field' - you may need to upgrade to the latest SDK version" + expect(eventLogger).toHaveBeenCalledWith( + context, + "error", + expect.objectContaining({ + message: + "Unknown custom field type 'invalid' for experiment 'exp_test_custom_fields' and key 'invalid_type_field' - you may need to upgrade to the latest SDK version", + }) ); }); }); @@ -4869,3 +5132,235 @@ describe("Context", () => { }); }); }); + +describe("Context input handling and lifecycle regressions", () => { + const contextOptions = { + publishDelay: -1, + refreshPeriod: 0, + }; + + const contextParams = { + units: { + session_id: "test-session", + }, + }; + + function newMockSDK() { + const sdk = new SDK(); + const publisher = new ContextPublisher(); + const provider = new ContextDataProvider(); + + sdk.getContextDataProvider.mockReturnValue(provider); + sdk.getContextPublisher.mockReturnValue(publisher); + sdk.getClient.mockReturnValue(new Client()); + sdk.getEventLogger.mockReturnValue(SDK.defaultEventLogger); + + return sdk; + } + + describe("ready() error handling and readyError()", () => { + it("should store error via readyError() when context fetch fails", async () => { + const error = new Error("fetch failed"); + const context = new Context(newMockSDK(), contextOptions, contextParams, Promise.reject(error)); + await context.ready(); + + expect(context.isFailed()).toBe(true); + expect(context.readyError()).toBe(error); + }); + + it("should return null for readyError() when no failure", () => { + const context = new Context(newMockSDK(), contextOptions, contextParams, { experiments: [] }); + expect(context.readyError()).toBe(null); + }); + + it("should allow treatment/peek/track calls after failed init without throwing", async () => { + const error = new Error("fetch failed"); + const context = new Context(newMockSDK(), contextOptions, contextParams, Promise.reject(error)); + const result = await context.ready(); + + expect(result).toBe(true); + expect(context.isFailed()).toBe(true); + expect(context.isReady()).toBe(true); + + expect(context.treatment("any_experiment")).toBe(0); + expect(context.peek("any_experiment")).toBe(0); + expect(context.variableValue("any_key", "fallback")).toBe("fallback"); + expect(context.peekVariableValue("any_key", "fallback")).toBe("fallback"); + expect(context.experiments()).toBeUndefined(); + expect(context.variableKeys()).toEqual({}); + + expect(() => context.track("goal_name")).not.toThrow(); + expect(() => context.attribute("attr", "value")).not.toThrow(); + }); + }); + + describe("variable resolution over experiment arrays", () => { + it("should handle unknown variable keys without error", () => { + const context = new Context( + newMockSDK(), + contextOptions, + { + units: { session_id: "e791e240fcd3df7d238cfc285f475e8152fcc0ec" }, + }, + { + experiments: [ + { + id: 1, + name: "exp_test", + iteration: 1, + unitType: "session_id", + seedHi: 3603515, + seedLo: 233373850, + split: [0.5, 0.5], + trafficSeedHi: 449867249, + trafficSeedLo: 455443629, + trafficSplit: [0.0, 1.0], + fullOnVariant: 0, + audience: null, + audienceStrict: false, + variants: [ + { name: "A", config: null }, + { name: "B", config: '{"color":"red"}' }, + ], + customFieldValues: null, + }, + ], + } + ); + + expect(context.variableValue("nonexistent_key", "default")).toBe("default"); + }); + }); + + describe("error logging routed through eventLogger", () => { + it("should not call console.error directly for custom field parse errors", () => { + const eventLogger = jest.fn(); + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + const sdk = newMockSDK(); + sdk.getEventLogger.mockReturnValue(eventLogger); + const context = new Context( + sdk, + { publishDelay: -1, refreshPeriod: 0, eventLogger }, + { units: { session_id: "test" } }, + { + experiments: [ + { + id: 1, + name: "exp", + iteration: 1, + unitType: "session_id", + seedHi: 1, + seedLo: 1, + split: [1], + trafficSeedHi: 1, + trafficSeedLo: 1, + trafficSplit: [0, 1], + fullOnVariant: 0, + audience: null, + audienceStrict: false, + variants: [{ name: "A", config: null }], + customFieldValues: [{ name: "bad_json", value: "{invalid", type: "json" }], + }, + ], + } + ); + + context.customFieldValue("exp", "bad_json"); + expect(errorSpy).not.toHaveBeenCalled(); + expect(eventLogger).toHaveBeenCalledWith(context, "error", expect.any(Error)); + errorSpy.mockRestore(); + }); + + it("should route variant config parse errors through eventLogger", () => { + const eventLogger = jest.fn(); + const sdk = newMockSDK(); + sdk.getEventLogger.mockReturnValue(eventLogger); + + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + const context = new Context( + sdk, + { publishDelay: -1, refreshPeriod: 0, eventLogger }, + { units: { session_id: "test" } }, + { + experiments: [ + { + id: 1, + name: "exp_bad_config", + iteration: 1, + unitType: "session_id", + seedHi: 1, + seedLo: 1, + split: [1], + trafficSeedHi: 1, + trafficSeedLo: 1, + trafficSplit: [0, 1], + fullOnVariant: 0, + audience: null, + audienceStrict: false, + variants: [{ name: "A", config: "{invalid json}" }], + customFieldValues: null, + }, + ], + } + ); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(eventLogger).toHaveBeenCalledWith(context, "error", expect.any(Error)); + errorSpy.mockRestore(); + }); + }); + + describe("finalizing state", () => { + it("should expose isFinalizing/isFinalized as false before finalize", () => { + const sdk = newMockSDK(); + sdk.getEventLogger.mockReturnValue(jest.fn()); + + const context = new Context( + sdk, + { publishDelay: -1, refreshPeriod: 0 }, + { units: { session_id: "test" } }, + { experiments: [] } + ); + + expect(context.isFinalizing()).toBe(false); + expect(context.isFinalized()).toBe(false); + }); + }); + + describe("attribute map caching", () => { + it("should return correct attributes after multiple attribute() calls", () => { + const sdk = newMockSDK(); + sdk.getEventLogger.mockReturnValue(jest.fn()); + + const context = new Context( + sdk, + { publishDelay: -1, refreshPeriod: 0 }, + { units: { session_id: "test" } }, + { experiments: [] } + ); + + context.attribute("age", 25); + context.attribute("country", "US"); + + const attrs = context.getAttributes(); + expect(attrs).toEqual({ age: 25, country: "US" }); + }); + }); + + describe("getOptions() returns a shallow copy", () => { + it("should not allow mutation of internal options", () => { + const sdk = newMockSDK(); + sdk.getEventLogger.mockReturnValue(jest.fn()); + + const originalOptions = { publishDelay: 100, refreshPeriod: 0 }; + const context = new Context(sdk, originalOptions, { units: { session_id: "test" } }, { experiments: [] }); + + const opts = context.getOptions(); + opts.publishDelay = 9999; + + expect(context.getOptions().publishDelay).toBe(100); + }); + }); +}); diff --git a/src/__tests__/fetch-shim.test.js b/src/__tests__/fetch-shim.test.js index d214b7a..542d1a2 100644 --- a/src/__tests__/fetch-shim.test.js +++ b/src/__tests__/fetch-shim.test.js @@ -133,3 +133,12 @@ describe("fetch", () => { }); }); }); + +describe("fetch implementation resolver", () => { + it("should resolve to a function, never undefined", async () => { + const fetchModule = await import("../fetch"); + const fetchImpl = fetchModule.default; + expect(fetchImpl).not.toBeUndefined(); + expect(typeof fetchImpl).toBe("function"); + }); +}); diff --git a/src/__tests__/jsonexpr/operators/eq.test.js b/src/__tests__/jsonexpr/operators/eq.test.js index d7132af..40c9803 100644 --- a/src/__tests__/jsonexpr/operators/eq.test.js +++ b/src/__tests__/jsonexpr/operators/eq.test.js @@ -106,5 +106,9 @@ describe("EqOperator", () => { evaluator.evaluate.mockClear(); evaluator.compare.mockClear(); }); + + it("should return null for empty args", () => { + expect(operator.evaluate(evaluator, [])).toBe(null); + }); }); }); diff --git a/src/__tests__/sdk.test.js b/src/__tests__/sdk.test.js index 3dc75de..7a0ad73 100644 --- a/src/__tests__/sdk.test.js +++ b/src/__tests__/sdk.test.js @@ -476,4 +476,21 @@ describe("SDK", () => { done(); }); }); + + describe("defaultEventLogger", () => { + it("should log full Error object to preserve stack traces", () => { + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + const error = new Error("something failed"); + SDK.defaultEventLogger(null, "error", error); + expect(errorSpy).toHaveBeenCalledWith(error); + errorSpy.mockRestore(); + }); + + it("should log raw data for non-Error values", () => { + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + SDK.defaultEventLogger(null, "error", "plain text error"); + expect(errorSpy).toHaveBeenCalledWith("plain text error"); + errorSpy.mockRestore(); + }); + }); }); diff --git a/src/__tests__/utils.test.js b/src/__tests__/utils.test.js index 39cf8d4..87af81c 100644 --- a/src/__tests__/utils.test.js +++ b/src/__tests__/utils.test.js @@ -235,6 +235,17 @@ describe("hashUnit()", () => { done(); }); + + it("should hash astral/multibyte characters correctly", (done) => { + // Characters outside the BMP are stored as UTF-16 surrogate pairs and must + // encode to 4-byte UTF-8; these canonical hashes are shared across all SDKs. + expect(hashUnit("😀")).toBe("KgLqw51xanDs83V5GFkntg"); + expect(hashUnit("😀😁")).toBe("ZJuDalvUWRJnVtkspj-2bQ"); + expect(hashUnit("世界你好")).toBe("v2CJG7YcjjWncKOSCzF2GA"); + expect(hashUnit("user_世界_123")).toBe("SCgk4OzXlFMvo1UMsP88fA"); + + done(); + }); }); describe("chooseVariant()", () => { diff --git a/src/abort-controller-shim.ts b/src/abort-controller-shim.ts index 1456e47..af18593 100644 --- a/src/abort-controller-shim.ts +++ b/src/abort-controller-shim.ts @@ -5,6 +5,8 @@ export type AbortControllerEvents = { // eslint-disable-next-line no-shadow export class AbortSignal { aborted = false; + reason: unknown = undefined; + onabort?: ((evt: { type: string }) => void) | null; private readonly _events: AbortControllerEvents; constructor() { @@ -34,9 +36,9 @@ export class AbortSignal { } dispatchEvent(evt: { type: string }) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - this[`on${evt.type}`] && this[`on${evt.type}`](evt); + if (evt.type === "abort" && this.onabort) { + this.onabort(evt); + } const listeners = this._events[evt.type]; if (listeners) { for (const listener of listeners) { @@ -54,11 +56,11 @@ export class AbortSignal { export class AbortController { signal = new AbortSignal(); - abort() { + abort(reason?: unknown) { let evt: Event | { type: string; bubbles: boolean; cancelable: boolean }; try { evt = new Event("abort"); - } catch (e) { + } catch (error) { evt = { type: "abort", bubbles: false, @@ -67,6 +69,7 @@ export class AbortController { } this.signal.aborted = true; + this.signal.reason = reason ?? new Error("The operation was aborted."); this.signal.dispatchEvent(evt); } diff --git a/src/client.ts b/src/client.ts index 56d91a2..95ede25 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,9 +4,9 @@ import { AbortController } from "./abort"; // eslint-disable-next-line no-shadow import { AbortError, RetryError, TimeoutError } from "./errors"; -import { AbortSignal as ABsmartlyAbortSignal } from "./abort-controller-shim"; -import { ContextOptions, ContextParams } from "./context"; -import { PublishParams } from "./publisher"; +import { type AbortSignal as ABsmartlyAbortSignal } from "./abort-controller-shim"; +import { type ContextOptions, type ContextParams } from "./context"; +import { type PublishParams } from "./publisher"; export type FetchResponse = { status: number; @@ -29,6 +29,10 @@ export type ClientRequestOptions = { export type ApplicationObject = { name: string; version: number | string }; +const DEFAULT_RETRIES = 5; +const DEFAULT_TIMEOUT_MS = 3000; +const RETRY_DELAY_MS = 50; + export type ClientOptions = { agent?: string; apiKey: string; @@ -52,8 +56,8 @@ export default class Client { const merged: Record = Object.assign( { agent: "javascript-client", - retries: 5, - timeout: 3000, + retries: DEFAULT_RETRIES, + timeout: DEFAULT_TIMEOUT_MS, keepalive: true, }, opts @@ -83,7 +87,7 @@ export default class Client { } this._opts = merged as unknown as NormalizedClientOptions; - this._delay = 50; + this._delay = RETRY_DELAY_MS; } getEnvironment(): string { @@ -143,12 +147,13 @@ export default class Client { request(options: ClientRequestOptions) { let url = `${this._opts.endpoint}${options.path}`; if (options.query) { - const keys = Object.keys(options.query); - if (keys.length > 0) { - const encoded = keys - .map((k) => (options.query ? `${k}=${encodeURIComponent(options.query[k])}` : null)) - .join("&"); - url = `${url}?${encoded}`; + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(options.query)) { + params.append(key, String(value)); + } + const queryString = params.toString(); + if (queryString) { + url = `${url}?${queryString}`; } } @@ -258,7 +263,7 @@ export default class Client { options.signal.addEventListener("abort", abort); } - const timeout = options.timeout || this._opts.timeout || 0; + const timeout = options.timeout ?? this._opts.timeout ?? 0; const timeoutId = timeout > 0 ? setTimeout(() => { @@ -274,7 +279,7 @@ export default class Client { } }; - return tryWith(this._opts.retries ?? 5, this._opts.timeout ?? 3000) + return tryWith(this._opts.retries ?? DEFAULT_RETRIES, timeout) .then((value: string) => { finalCleanUp(); return value; diff --git a/src/context.ts b/src/context.ts index 673f80f..fb09ad8 100644 --- a/src/context.ts +++ b/src/context.ts @@ -2,10 +2,10 @@ import { arrayEqualsShallow, hashUnit, isObject, isPromise } from "./utils"; import { VariantAssigner } from "./assigner"; import { AudienceMatcher } from "./matcher"; import { insertUniqueSorted } from "./algorithm"; -import SDK, { EventLogger, EventName } from "./sdk"; -import { ContextPublisher, PublishParams } from "./publisher"; +import SDK, { type EventLogger, type EventName } from "./sdk"; +import { ContextPublisher, type PublishParams } from "./publisher"; import { ContextDataProvider } from "./provider"; -import { ClientRequestOptions } from "./client"; +import { type ClientRequestOptions } from "./client"; import { SDK_VERSION } from "./version"; type JSONPrimitive = string | number | boolean | null; @@ -144,14 +144,17 @@ export default class Context { private _data: ContextData; private _exposures: Exposure[]; private _failed: boolean; + private _failedError: Error | null; private _finalized: boolean; - private _finalizing: boolean | Promise | null; + private _finalizing: Promise | null; private _goals: Goal[]; private _index: Record; private _indexVariables: Record; private _overrides: Record; private _pending: number; private _attrsSeq: number; + private _attrsMapCache: Record | null; + private _attrsMapCacheSeq: number; private _hashes?: Record; private _promise?: Promise; private _publishTimeout?: ReturnType; @@ -165,6 +168,7 @@ export default class Context { this._opts = options; this._pending = 0; this._failed = false; + this._failedError = null; this._finalized = false; this._attrs = []; this._goals = []; @@ -176,6 +180,8 @@ export default class Context { this._audienceMatcher = new AudienceMatcher(); this._environmentName = null; this._attrsSeq = 0; + this._attrsMapCache = null; + this._attrsMapCacheSeq = -1; if (params.units) { this.units(params.units); @@ -197,6 +203,7 @@ export default class Context { this._init({}); this._failed = true; + this._failedError = error; delete this._promise; this._logError(error); @@ -225,14 +232,16 @@ export default class Context { return this._failed; } + readyError(): Error | null { + return this._failedError; + } + ready() { if (this.isReady()) { return Promise.resolve(true); } - return new Promise((resolve) => { - this._promise?.then(() => resolve(true)).catch((e) => resolve(e)); - }); + return this._promise?.then(() => true) ?? Promise.resolve(true); } pending() { @@ -312,23 +321,20 @@ export default class Context { } getUnits() { - return this._units; + return { ...this._units }; } units(units: Record) { - Object.entries(units).forEach(([unitType, uid]) => { + for (const [unitType, uid] of Object.entries(units)) { this.unit(unitType, uid); - }); + } } getAttribute(attrName: string) { - let result; - - this._attrs.forEach((attr) => { - if (attr.name === attrName) result = attr.value; - }); - - return result; + for (let i = this._attrs.length - 1; i >= 0; i--) { + if (this._attrs[i].name === attrName) return this._attrs[i].value; + } + return undefined; } attribute(attrName: string, value: unknown) { @@ -340,18 +346,16 @@ export default class Context { getAttributes() { const attributes: Record = {}; - this._attrs - .map((a) => [a.name, a.value]) - .forEach(([key, value]) => { - attributes[key as string] = value; - }); + for (const attr of this._attrs) { + attributes[attr.name] = attr.value; + } return attributes; } attributes(attrs: Record) { - Object.entries(attrs).forEach(([attrName, value]) => { + for (const [attrName, value] of Object.entries(attrs)) { this.attribute(attrName, value); - }); + } } peek(experimentName: string) { @@ -399,24 +403,26 @@ export default class Context { const variableExperiments: Record = {}; - Object.entries(this._indexVariables).forEach(([key, values]) => { - values.forEach((value) => { + for (const [key, values] of Object.entries(this._indexVariables)) { + for (const value of values) { if (variableExperiments[key]) variableExperiments[key].push(value.data.name); else variableExperiments[key] = [value.data.name]; - }); - }); + } + } return variableExperiments; } override(experimentName: string, variant: number) { + // override() is allowed after finalize() (parity with the production SDK, + // which sets overrides unconditionally). this._overrides = Object.assign(this._overrides, { [experimentName]: variant }); } overrides(experimentVariants: Record) { - Object.entries(experimentVariants).forEach(([experimentName, variant]) => { + for (const [experimentName, variant] of Object.entries(experimentVariants)) { this.override(experimentName, variant); - }); + } } customAssignment(experimentName: string, variant: number) { @@ -426,16 +432,24 @@ export default class Context { } customAssignments(experimentVariants: Record) { - Object.entries(experimentVariants).forEach(([experimentName, variant]) => { + for (const [experimentName, variant] of Object.entries(experimentVariants)) { this.customAssignment(experimentName, variant); - }); + } + } + + getSDK(): SDK { + return this._sdk; + } + + getOptions(): ContextOptions { + return { ...this._opts }; } private _checkNotFinalized() { if (this.isFinalized()) { - throw new Error("ABSmartly Context is finalized."); + throw new Error("ABsmartly Context is finalized."); } else if (this.isFinalizing()) { - throw new Error("ABSmartly Context is finalizing."); + throw new Error("ABsmartly Context is finalizing."); } } @@ -450,7 +464,7 @@ export default class Context { private _checkReady(expectNotFinalized?: boolean) { if (!this.isReady()) { - throw new Error("ABSmartly Context is not yet ready."); + throw new Error("ABsmartly Context is not yet ready."); } if (expectNotFinalized) { @@ -459,6 +473,9 @@ export default class Context { } private _getAttributesMap(): Record { + if (this._attrsMapCache !== null && this._attrsMapCacheSeq === this._attrsSeq) { + return this._attrsMapCache; + } const attrs: Record = {}; if (this._opts.includeSystemAttributes === true) { const client = this._sdk.getClient(); @@ -472,12 +489,23 @@ export default class Context { attrs["app_version"] = app.version; } } - this._attrs.forEach((attr) => { + for (const attr of this._attrs) { attrs[attr.name] = attr.value; - }); + } + this._attrsMapCache = attrs; + this._attrsMapCacheSeq = this._attrsSeq; return attrs; } + private _evaluateAudience(audience: string): boolean | null { + try { + return this._audienceMatcher.evaluate(audience, this._getAttributesMap()); + } catch (error) { + this._logError(error as Error); + return null; + } + } + private _assign(experimentName: string) { const experimentMatches = (experiment: ExperimentData, assignment: Assignment) => { return ( @@ -515,8 +543,11 @@ export default class Context { } if (!assignment.ruleOverride && experiment.audience && experiment.audience.length > 0) { - const result = this._audienceMatcher.evaluate(experiment.audience, attrs); - const newAudienceMismatch = typeof result === "boolean" ? !result : false; + const result = this._evaluateAudience(experiment.audience); + // Mirror the assignment-time logic: a null result leaves the + // mismatch flag unchanged (false), so the cached assignment + // stays valid rather than being needlessly invalidated. + const newAudienceMismatch = result !== null ? !result : assignment.audienceMismatch; if (newAudienceMismatch !== assignment.audienceMismatch) { return false; @@ -604,9 +635,13 @@ export default class Context { assignment.ruleOverride = true; } else { if (experiment.data.audience && experiment.data.audience.length > 0) { - const result = this._audienceMatcher.evaluate(experiment.data.audience, attrs); + const result = this._evaluateAudience(experiment.data.audience); - if (typeof result === "boolean") { + // Only flag a mismatch when the audience actually evaluated + // to a boolean. A null result (e.g. an audience with no + // usable filter like `{}`) leaves audienceMismatch false, + // matching the collector (ContextAPI: `if (result != null)`). + if (result !== null) { assignment.audienceMismatch = !result; } } @@ -753,14 +788,22 @@ export default class Context { if (field.value === "") return ""; return JSON.parse(field.value); } catch (e) { - console.error(`Failed to parse JSON custom field value '${key}' for experiment '${experimentName}'`); + this._logError( + new Error( + `Failed to parse JSON custom field value '${key}' for experiment '${experimentName}': ${ + (e as Error).message + }` + ) + ); return null; } case "boolean": return field.value === "true"; default: - console.error( - `Unknown custom field type '${field.type}' for experiment '${experimentName}' and key '${key}' - you may need to upgrade to the latest SDK version` + this._logError( + new Error( + `Unknown custom field type '${field.type}' for experiment '${experimentName}' and key '${key}' - you may need to upgrade to the latest SDK version` + ) ); return null; } @@ -795,14 +838,13 @@ export default class Context { return this._customFieldValueType(experimentName, key); } - private _variableValue(key: string, defaultValue: string): string { - for (const i in this._indexVariables[key]) { - const experimentName = this._indexVariables[key][i].data.name; + private _resolveVariableValue(key: string, defaultValue: string, shouldQueueExposure: boolean): string { + for (const experiment of this._indexVariables[key] ?? []) { + const experimentName = experiment.data.name; const assignment = this._assign(experimentName); if (assignment.variables !== undefined) { - if (!assignment.exposed) { + if (shouldQueueExposure && !assignment.exposed) { assignment.exposed = true; - this._queueExposure(experimentName, assignment); } @@ -815,18 +857,12 @@ export default class Context { return defaultValue; } - private _peekVariable(key: string, defaultValue: string): string { - for (const i in this._indexVariables[key]) { - const experimentName = this._indexVariables[key][i].data.name; - const assignment = this._assign(experimentName); - if (assignment.variables !== undefined) { - if (key in assignment.variables && (assignment.assigned || assignment.overridden || assignment.ruleOverride)) { - return assignment.variables[key] as string; - } - } - } + private _variableValue(key: string, defaultValue: string): string { + return this._resolveVariableValue(key, defaultValue, true); + } - return defaultValue; + private _peekVariable(key: string, defaultValue: string): string { + return this._resolveVariableValue(key, defaultValue, false); } private _validateGoal(goalName: string, properties?: Record) { @@ -856,6 +892,7 @@ export default class Context { if (this.isReady()) { if (this._publishTimeout === undefined && this._opts.publishDelay >= 0) { this._publishTimeout = setTimeout(() => { + // _flush already logs publish errors via the callback. this._flush(); }, this._opts.publishDelay); } @@ -943,6 +980,19 @@ export default class Context { request.attributes = allAttributes; } + // Snapshot and reset synchronously before the async publish. + // The data is already copied into `request` via .map(), so clearing + // immediately is safe and allows new events to accumulate during the + // in-flight publish. On failure, we restore the snapshot so the events + // are retried on the next flush cycle. + const pendingCount = this._pending; + const pendingExposures = this._exposures; + const pendingGoals = this._goals; + + this._pending = 0; + this._exposures = []; + this._goals = []; + this._publisher .publish(request, this._sdk, this, requestOptions) .then(() => { @@ -953,6 +1003,10 @@ export default class Context { } }) .catch((e: Error) => { + this._pending += pendingCount; + this._exposures.push(...pendingExposures); + this._goals.push(...pendingGoals); + this._logError(e); if (typeof callback === "function") { @@ -967,14 +1021,20 @@ export default class Context { } } } else { + this._logError( + new Error( + `Discarding ${this._exposures.length} exposures and ${this._goals.length} goals because context failed to initialize` + ) + ); + + this._pending = 0; + this._exposures = []; + this._goals = []; + if (typeof callback === "function") { callback(); } } - - this._pending = 0; - this._exposures = []; - this._goals = []; } } @@ -1038,7 +1098,7 @@ export default class Context { const index: Record = {}; const indexVariables: Record = {}; - (data.experiments || []).forEach((experiment) => { + for (const experiment of data.experiments || []) { const variables: Record[] = []; const entry = { data: experiment, @@ -1047,11 +1107,27 @@ export default class Context { index[experiment.name] = entry; - experiment.variants.forEach((variant, i) => { + for (let i = 0; i < experiment.variants.length; i++) { + const variant = experiment.variants[i]; const config = variant.config; - const parsed = config != null && config.length > 0 ? JSON.parse(config) : {}; + let parsed = {}; + + if (config != null && config.length > 0) { + try { + const value = JSON.parse(config); + if (isObject(value)) { + parsed = value; + } + } catch (error) { + this._logError( + new Error( + `Failed to parse config for experiment '${experiment.name}' variant ${i}: ${(error as Error).message}` + ) + ); + } + } - Object.keys(parsed).forEach((key) => { + for (const key of Object.keys(parsed)) { const value = entry; if (indexVariables[key]) { insertUniqueSorted( @@ -1060,17 +1136,18 @@ export default class Context { (a, b) => (a as Experiment).data.id < (b as Experiment).data.id ); } else indexVariables[key] = [value]; - }); + } variables[i] = parsed; - }); - }); + } + } this._index = index; this._indexVariables = indexVariables; this._assignments = assignments; if (!this._failed && this._opts.refreshPeriod > 0 && !this._refreshInterval) { + // _refresh already logs refresh errors via the callback. this._refreshInterval = setInterval(() => this._refresh(), this._opts.refreshPeriod); } } diff --git a/src/fetch.ts b/src/fetch.ts index 088f22e..018858d 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -1,26 +1,47 @@ import { isLongLivedApp, isWorker } from "./utils"; import fetchShim from "./fetch-shim"; -const exported = isLongLivedApp() - ? window.fetch - ? window.fetch.bind(window) - : fetchShim - : isWorker() - ? self.fetch - ? self.fetch.bind(self) - : fetchShim - : global - ? global.fetch - ? global.fetch.bind(global) - : function (url: string, opts: Record) { - return new Promise((resolve, reject) => { - import("node-fetch") - .then((fetchNode) => { - fetchNode.default(url.replace(/^\/\//g, "https://"), opts).then(resolve).catch(reject); - }) - .catch(reject); - }); - } - : undefined; +function getFetchImplementation() { + if (isLongLivedApp()) { + if (window.fetch) { + return window.fetch.bind(window); + } + return fetchShim; + } + + if (isWorker()) { + if (self.fetch) { + return self.fetch.bind(self); + } + return fetchShim; + } + + const globalObj = typeof globalThis !== "undefined" ? globalThis : typeof global !== "undefined" ? global : undefined; + + if (globalObj !== undefined) { + if ((globalObj as { fetch?: typeof fetch }).fetch) { + return (globalObj as { fetch: typeof fetch }).fetch.bind(globalObj); + } + return function (url: string, opts: Record) { + return new Promise((resolve, reject) => { + import("node-fetch") + .then((fetchNode) => { + fetchNode.default(url.replace(/^\/\//g, "https://"), opts).then(resolve).catch(reject); + }) + .catch(reject); + }); + }; + } + + return undefined; +} + +const impl = getFetchImplementation(); + +const exported = + impl ?? + function () { + throw new Error("No fetch implementation found. Please provide a fetch polyfill for your environment."); + }; export default exported; diff --git a/src/index.ts b/src/index.ts index aa43221..f9fa0ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,5 +6,5 @@ import { ContextPublisher } from "./publisher"; // eslint-disable-next-line no-shadow import { AbortController } from "./abort"; -export { mergeConfig, AbortController, Context, ContextDataProvider, ContextPublisher, SDK }; -export default { mergeConfig, AbortController, Context, ContextDataProvider, ContextPublisher, SDK }; +export { mergeConfig, AbortController, Context, ContextDataProvider, ContextPublisher, SDK, SDK as ABsmartly }; +export default { mergeConfig, AbortController, Context, ContextDataProvider, ContextPublisher, SDK, ABsmartly: SDK }; diff --git a/src/matcher.ts b/src/matcher.ts index eb1c015..47287e1 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -3,15 +3,17 @@ import { JsonExpr } from "./jsonexpr/jsonexpr"; export class AudienceMatcher { evaluate(audienceString: string, vars: Record) { + let audience; try { - const audience = JSON.parse(audienceString); - if (audience && audience.filter) { - if (Array.isArray(audience.filter) || isObject(audience.filter)) { - return this._jsonExpr.evaluateBooleanExpr(audience.filter, vars); - } + audience = JSON.parse(audienceString); + } catch (_error) { + return null; + } + + if (audience && audience.filter) { + if (Array.isArray(audience.filter) || isObject(audience.filter)) { + return this._jsonExpr.evaluateBooleanExpr(audience.filter, vars); } - } catch (e) { - console.error(e); } return null; diff --git a/src/provider.ts b/src/provider.ts index 30dac20..85c14c8 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -1,5 +1,5 @@ import SDK from "./sdk"; -import { ClientRequestOptions } from "./client"; +import { type ClientRequestOptions } from "./client"; export class ContextDataProvider { getContextData(sdk: SDK, requestOptions?: Partial) { diff --git a/src/publisher.ts b/src/publisher.ts index 79c5f14..8d6f890 100644 --- a/src/publisher.ts +++ b/src/publisher.ts @@ -1,6 +1,6 @@ -import Context, { Attribute, Exposure, Goal, Unit } from "./context"; +import Context, { type Attribute, type Exposure, type Goal, type Unit } from "./context"; import SDK from "./sdk"; -import { ClientRequestOptions } from "./client"; +import { type ClientRequestOptions } from "./client"; export type PublishParams = { units: Unit[]; diff --git a/src/sdk.ts b/src/sdk.ts index 32ac4cf..33f7120 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -1,11 +1,21 @@ -import Client, { ClientOptions, ClientRequestOptions } from "./client"; -import Context, { ContextData, ContextOptions, ContextParams, Exposure, Goal } from "./context"; -import { ContextPublisher, PublishParams } from "./publisher"; +import Client, { type ClientOptions, type ClientRequestOptions } from "./client"; +import Context, { + type ContextData, + type ContextOptions, + type ContextParams, + type Exposure, + type Goal, +} from "./context"; +import { ContextPublisher, type PublishParams } from "./publisher"; import { ContextDataProvider } from "./provider"; import { isLongLivedApp } from "./utils"; export type EventLoggerData = Error | Exposure | Goal | ContextData | PublishParams; +const DEFAULT_PUBLISH_DELAY_MS = 100; +const NO_PUBLISH_DELAY = -1; +const NO_REFRESH = 0; + export type EventName = "error" | "ready" | "refresh" | "publish" | "exposure" | "goal" | "finalize"; export type EventLogger = (context: Context, eventName: EventName, data?: EventLoggerData) => void; @@ -29,20 +39,7 @@ export default class SDK { private readonly _client: Client; constructor(options: ClientOptions & SDKOptions) { - const clientOptions = Object.assign( - { - agent: "absmartly-javascript-sdk", - }, - ...Object.entries(options || {}) - .filter( - (x) => - ["application", "agent", "apiKey", "endpoint", "keepalive", "environment", "retries", "timeout"].indexOf( - x[0] - ) !== -1 - ) - .map((x) => ({ [x[0]]: x[1] })) - ); - + const clientOptions = SDK._extractClientOptions(options); options = Object.assign({}, options); this._client = options.client || new Client(clientOptions); @@ -51,6 +48,30 @@ export default class SDK { this._provider = options.provider || new ContextDataProvider(); } + private static _extractClientOptions(options: ClientOptions & SDKOptions): ClientOptions { + const clientOptionKeys: (keyof ClientOptions)[] = [ + "application", + "agent", + "apiKey", + "endpoint", + "keepalive", + "environment", + "retries", + "timeout", + ]; + const extracted: Partial = { + agent: "absmartly-javascript-sdk", + }; + + for (const key of clientOptionKeys) { + if (options?.[key] !== undefined) { + extracted[key] = options[key] as never; + } + } + + return extracted as ClientOptions; + } + getContextData(requestOptions: ClientRequestOptions) { return this._provider.getContextData(this, requestOptions); } @@ -108,29 +129,27 @@ export default class SDK { } private static _contextOptions(options?: Partial): ContextOptions { - return Object.assign( - { - publishDelay: isLongLivedApp() ? 100 : -1, - refreshPeriod: 0, - }, - options || {} - ); + return { + publishDelay: isLongLivedApp() ? DEFAULT_PUBLISH_DELAY_MS : NO_PUBLISH_DELAY, + refreshPeriod: NO_REFRESH, + ...options, + }; } private static _validateParams(params: ContextParams) { - Object.entries(params.units).forEach((entry) => { - const type = typeof entry[1]; + for (const [unitType, uid] of Object.entries(params.units)) { + const type = typeof uid; if (type !== "string" && type !== "number") { throw new Error( - `Unit '${entry[0]}' UID is of unsupported type '${type}'. UID must be one of ['string', 'number']` + `Unit '${unitType}' UID is of unsupported type '${type}'. UID must be one of ['string', 'number']` ); } - if (typeof entry[1] === "string") { - if (entry[1].length === 0) { - throw new Error(`Unit '${entry[0]}' UID length must be >= 1`); + if (typeof uid === "string") { + if (uid.length === 0) { + throw new Error(`Unit '${unitType}' UID length must be >= 1`); } } - }); + } } } diff --git a/src/utils.ts b/src/utils.ts index 529d486..512089f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -149,24 +149,31 @@ export function arrayEqualsShallow(a?: unknown[], b?: unknown[]) { } export function stringToUint8Array(value: string) { - const n = value.length; - const array = new Array(value.length); + if (typeof TextEncoder !== "undefined") { + return new TextEncoder().encode(value); + } - let k = 0; - for (let i = 0; i < n; ++i) { - const c = value.charCodeAt(i); + const utf8: number[] = []; + for (let i = 0; i < value.length; i++) { + let c = value.charCodeAt(i); + if (c >= 0xd800 && c <= 0xdbff && i + 1 < value.length) { + const next = value.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + c = ((c - 0xd800) << 10) + (next - 0xdc00) + 0x10000; + i++; + } + } if (c < 0x80) { - array[k++] = c; + utf8.push(c); } else if (c < 0x800) { - array[k++] = (c >> 6) | 192; - array[k++] = (c & 63) | 128; + utf8.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); + } else if (c < 0x10000) { + utf8.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); } else { - array[k++] = (c >> 12) | 224; - array[k++] = ((c >> 6) & 63) | 128; - array[k++] = (c & 63) | 128; + utf8.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); } } - return Uint8Array.from(array); + return new Uint8Array(utf8); } const Base64URLNoPaddingChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";