Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2f27453
chore: use bundler moduleResolution for tsconfig
blurrah Sep 12, 2025
2425732
feat: add first steps towards adapter for optimizely
blurrah Sep 1, 2025
7a96cbd
feat: add edge-config friendly project config manager and event dispa…
blurrah Sep 2, 2025
351f620
chore: add edge-config package for optimizely
blurrah Sep 2, 2025
d62fc76
chore: update naming for project config manager
blurrah Sep 3, 2025
6f9903e
chore: remove runtime origin
blurrah Sep 3, 2025
0c1e59b
feat: support without edge config and add fetch request handler
blurrah Sep 5, 2025
0e1a9c9
feat: add value getter for optimizely decision
blurrah Sep 5, 2025
d3842ab
chore: clean up comment and type
blurrah Sep 11, 2025
cabaa0d
fix(optimizely): only work with main entrypoint for now
blurrah Sep 12, 2025
2e62300
feat: use universal export to replace XHR with fetch for requests
blurrah Sep 12, 2025
02019f9
refactor: move edge config project manager to index.ts
blurrah Sep 12, 2025
bd1e712
fix: use static project manager and allow usage without sdk key
blurrah Sep 15, 2025
edef4ae
fix: use universal exports and custom request handler where possible
blurrah Sep 16, 2025
e3b0cc0
fix: working decisions after testing
blurrah Sep 16, 2025
a0fb5be
feat: add userContext helper
blurrah Sep 16, 2025
afe6678
chore: update optimizely sdk to latest version
blurrah Sep 16, 2025
4d8a141
fix(sveltekit): do not allow partial record for request event
blurrah Sep 16, 2025
fa52982
feat: allow complete user context from identify function
blurrah Sep 19, 2025
1e0f864
chore: clean up types and old decide type signature
blurrah Sep 19, 2025
2e09aa4
fix: remove attributes from decide function
blurrah Sep 19, 2025
bcd73bd
add changeset
dferber90 Sep 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eighty-dingos-protect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@flags-sdk/optimizely': minor
---

Implement adapter to resolve feature flags
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"publint": "0.2.7",
"ts-jest": "29.1.2",
"turbo": "2.3.3",
"typescript": "^5.3.3"
"typescript": "^5.9.2"
},
"packageManager": "pnpm@8.7.1",
"engines": {
Expand Down
7 changes: 5 additions & 2 deletions packages/adapter-optimizely/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@
"test:watch": "vitest",
"type-check": "tsc --noEmit"
},
"dependencies": {},
"dependencies": {
"@optimizely/optimizely-sdk": "6.1.0",
"@vercel/edge-config": "1.4.0",
"@vercel/functions": "^1.5.2"
},
"devDependencies": {
"@types/node": "20.11.17",
"eslint-config-custom": "workspace:*",
Expand All @@ -48,7 +52,6 @@
"vite": "5.1.1",
"vitest": "1.4.0"
},
"peerDependencies": {},
"publishConfig": {
"access": "public"
}
Expand Down
31 changes: 31 additions & 0 deletions packages/adapter-optimizely/src/edge-runtime-hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { LogEvent } from '@optimizely/optimizely-sdk';

/**
* Web standards friendly event dispatcher for Optimizely
* uses `waitUntil()` to avoid blocking the visitor's page load
*
* This does not send back the status code to the dispatcher as it runs in `waitUntil()`
*/
export async function dispatchEvent(event: LogEvent) {
// Non-POST requests not supported
if (event.httpVerb !== 'POST') {
throw new Error(
'Optimizely Event Dispatcher: Only POST requests are supported',
);
}

const url = new URL(event.url);
const data = JSON.stringify(event.params);

const dispatch = fetch(url, {
method: 'POST',
body: data,
headers: {
'Content-Type': 'application/json',
},
});

import('@vercel/functions').then(({ waitUntil }) => {
waitUntil(dispatch);
});
}
223 changes: 223 additions & 0 deletions packages/adapter-optimizely/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,224 @@
export { getProviderData } from './provider';
import {
Client,
OpaqueConfigManager,
OptimizelyDecision,
UserAttributes,
} from '@optimizely/optimizely-sdk';

import type { Adapter, JsonValue } from 'flags';
import { dispatchEvent } from './edge-runtime-hooks';
import {
createForwardingEventProcessor,
createInstance,
createPollingProjectConfigManager,
createStaticProjectConfigManager,
RequestHandler,
} from '@optimizely/optimizely-sdk/universal';
import { createClient } from '@vercel/edge-config';

let defaultOptimizelyAdapter:
| ReturnType<typeof createOptimizelyAdapter>
| undefined;

/**
* The user context for the Optimizely adapter
*/
export type UserContext = {
userId: string;
attributes?: UserAttributes;
};

type AdapterResponse = {
decide: <T>(
getValue: (decision: OptimizelyDecision) => T,
) => Adapter<T, UserContext>;
initialize: () => Promise<Client>;
};

/**
* The node instance has a hardcoded XHR request handler that will break in edge runtime,
* so we need to use a custom request handler that uses fetch.
*/
const requestHandler: RequestHandler = {
makeRequest(requestUrl, headers, method, data) {
const abortController = new AbortController();

const responsePromise = fetch(requestUrl, {
headers: headers as Record<string, string>,
method,
body: data,
signal: abortController.signal,
});
return {
abort: () => abortController.abort(),
responsePromise: responsePromise.then(async (response) => {
const headersObj: Record<string, string> = {};
response.headers.forEach((value, key) => {
headersObj[key] = value;
});
return {
statusCode: response.status,
body: (await response.text()) ?? '',
headers: headersObj,
};
}),
};
},
};

export function createOptimizelyAdapter({
sdkKey,
edgeConfig,
}: {
sdkKey?: string;
edgeConfig?: {
connectionString: string;
itemKey: string;
};
}): AdapterResponse {
let optimizelyInstance: Client | undefined;

const initializeOptimizely = async () => {
let projectConfigManager: OpaqueConfigManager | undefined;
if (edgeConfig) {
const edgeConfigClient = createClient(edgeConfig.connectionString);
const datafile = await edgeConfigClient.get<JsonValue>(
edgeConfig.itemKey,
);

if (!datafile) {
throw new Error(
'Optimizely Adapter: Could not get datafile from edge config',
);
}

projectConfigManager = createStaticProjectConfigManager({
datafile: JSON.stringify(datafile),
});
}

if (!projectConfigManager && sdkKey) {
projectConfigManager = createPollingProjectConfigManager({
sdkKey: sdkKey,
updateInterval: 10000,
requestHandler,
});
}

if (!projectConfigManager) {
throw new Error(
'Optimizely Adapter: Could not create project config manager, either edgeConfig or sdkKey must be provided',
);
}

try {
optimizelyInstance = createInstance({
clientEngine: 'javascript-sdk/flags-sdk',
projectConfigManager,
// @ts-expect-error - dispatchEvent runs in `waitUntil` so it's not going to return a response
eventProcessor: createForwardingEventProcessor({ dispatchEvent }),
requestHandler,
});
} catch (error) {
throw new Error(
`Optimizely Adapter: Error creating optimizely instance, ${
error instanceof Error ? error.message : error
}`,
);
}

// This resolves instantly when using the edge config, the timeout is just for fetching the datafile from the polling project config manager
await optimizelyInstance.onReady({ timeout: 500 });
};

let _initializePromise: Promise<void> | undefined;
const initialize = async () => {
if (!_initializePromise) {
_initializePromise = initializeOptimizely();
}
await _initializePromise;
if (!optimizelyInstance) {
throw new Error(
'Optimizely Adapter: Optimizely instance not initialized',
);
}
return optimizelyInstance;
};

function decide<T>(
getValue: (decision: OptimizelyDecision) => T,
): Adapter<T, UserContext> {
return {
decide: async ({ key, entities }) => {
await initialize();
if (!optimizelyInstance) {
throw new Error(
'Optimizely Adapter: Optimizely instance not initialized',
);
}
if (!entities || !entities.userId) {
throw new Error('Optimizely Adapter: User ID not provided');
}
const context = optimizelyInstance.createUserContext(
entities?.userId,
entities?.attributes,
);
return getValue(context.decide(key));
},
};
}

return {
decide,
initialize,
};
}

function getOrCreateDefaultOptimizelyAdapter(): AdapterResponse {
const sdkKey = process.env.OPTIMIZELY_SDK_KEY;
const edgeConfigConnectionString = process.env.EDGE_CONFIG_CONNECTION_STRING;
const edgeConfigItemKey = process.env.OPTIMIZELY_DATAFILE_ITEM_KEY;

if (!defaultOptimizelyAdapter) {
if (edgeConfigConnectionString && edgeConfigItemKey) {
defaultOptimizelyAdapter = createOptimizelyAdapter({
sdkKey,
edgeConfig: {
connectionString: edgeConfigConnectionString,
itemKey: edgeConfigItemKey,
},
});
} else {
defaultOptimizelyAdapter = createOptimizelyAdapter({
sdkKey,
});
}
}
return defaultOptimizelyAdapter;
}

/**
* The default Optimizely adapter.
*
* This is a convenience object that pre-initializes the Optimizely SDK and provides
* the adapter functions for the Feature Flags.
*
* This is the recommended way to use the Optimizely adapter.
*
* ```ts
* // flags.ts
* import { flag } from 'flags/next';
* import { optimizelyAdapter } from '@flags-sdk/optimizely';
*
* const flag = flag({
* key: 'my-flag',
* defaultValue: false,
* adapter: optimizelyAdapter.decide((decision) => decision.enabled),
* });
* ```
*/
export const optimizelyAdapter: AdapterResponse = {
decide: (...args) => getOrCreateDefaultOptimizelyAdapter().decide(...args),
initialize: () => getOrCreateDefaultOptimizelyAdapter().initialize(),
};
2 changes: 1 addition & 1 deletion packages/flags/src/sveltekit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ export function createHandle({
}

async function handleWellKnownFlagsRoute(
event: RequestEvent<Partial<Record<string, string>>, string | null>,
event: RequestEvent<Record<string, string>, string | null>,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is due to me updating all deps by messing around with PNPM versions, undefined is no longer allowed in Sveltekit minor updates

secret: string,
flags: Record<string, Flag<any>>,
) {
Expand Down
Loading
Loading