Created
June 14, 2024 16:14
-
-
Save patrickdbakke/c0469e67dfda0a5cf3ed3de4129a7d01 to your computer and use it in GitHub Desktop.
LaunchDarkly WebClient
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* At time of writing, launchdarkly does not officially support an implementation compatible with manifest v3 | |
* the official package - https://docs.launchdarkly.com/sdk/client-side/javascript - | |
* is incompatible with chrome manifest v3 in two ways: | |
* - it uses localStorage | |
* - it uses XMLHTTPRequest() | |
* both of these are incompatible with manfiest v3 / web worker contexts | |
* | |
* they are currently rewriting their core sdk, with the first library implementation built for `react-native` | |
* their team steered us towards borrowing from that react-native implementation | |
* https://github.com/launchdarkly/js-client-sdk/issues/253#issuecomment-2135597188 | |
* | |
* alternatively, there's also public fork, maintained here: | |
* https://github.com/launchdarkly/js-client-sdk/issues/253#issuecomment-1236616911 | |
* unfortunately, that fork is several years dated. | |
* that fork does not support several modern launchdarkly apis, including `multi` context types | |
* | |
* ------- | |
* | |
* in this file, we: | |
* - build a custom launchDarkly `Platform` (think "strategy" pattern), | |
* - this Platform is compatible with both web and web worker contexts | |
* - this implementation mimics the react-native implementation from here | |
* - https://github.com/launchdarkly/js-core/blob/a001db10568110d056a1b6c4f9000b94365a08bc/packages/sdk/react-native/src/platform/index.ts | |
* - albeit, we put everything into a single file for simplicity, assuming this will someday be officially supported | |
* - create several helpers for that `Platform` (crypto, event source, encoding, requests) | |
* - create a `LaunchDarklyWebCLient` using that `Platform` | |
*/ | |
import { sha256 } from 'js-sha256' | |
import { fromByteArray } from 'base64-js' | |
import UUID from 'superhuman-node-uuid' | |
import { LDClientImpl, BasicLogger, base64UrlEncode } from '@launchdarkly/js-client-sdk-common' | |
import type { Hasher } from 'js-sha256' | |
import type { | |
AutoEnvAttributes, | |
Crypto, | |
Encoding, | |
EventSourceInitDict, | |
EventSource as LDEventSource, | |
Hasher as LDHasher, | |
Hmac, | |
Info, | |
LDLogger, | |
LDOptions, | |
Platform, | |
PlatformData, | |
Requests, | |
Response, | |
SdkData, | |
Storage, | |
LDContext | |
} from '@launchdarkly/js-client-sdk-common' | |
class PlatformHasher implements LDHasher { | |
private _hasher: Hasher | |
constructor(algorithm: 'sha256', hmacKey?: string) { | |
switch (algorithm) { | |
case 'sha256': | |
this._hasher = hmacKey ? sha256.hmac.create(hmacKey) : sha256.create() | |
break | |
default: | |
throw new Error(`Unsupported hash algorithm: ${algorithm}. Only sha256 is supported.`) | |
} | |
} | |
digest(encoding: 'base64' | 'hex'): string { | |
switch (encoding) { | |
case 'base64': | |
return fromByteArray(new Uint8Array(this._hasher.arrayBuffer())) | |
case 'hex': | |
return this._hasher.hex() | |
default: | |
throw new Error(`unsupported output encoding: ${encoding}. Only base64 and hex are supported.`) | |
} | |
} | |
update(data: string): this { | |
this._hasher.update(data) | |
return this | |
} | |
} | |
class PlatformCrypto implements Crypto { | |
createHash(algorithm: 'sha256'): PlatformHasher { | |
return new PlatformHasher(algorithm) | |
} | |
createHmac(algorithm: 'sha256', key: string): Hmac { | |
return new PlatformHasher(algorithm, key) | |
} | |
randomUUID(): string { | |
return UUID.v4() | |
} | |
} | |
const localStorage = globalThis.localStorage | |
const AsyncStorage = { | |
getItem: async (key: string) => { | |
return new Promise<string | null>(resolve => { | |
if (chrome.storage?.local) { | |
chrome.storage.local.get({ [key]: null }, results => { | |
resolve(results[key]) | |
}) | |
} else if (localStorage) { | |
resolve(localStorage.getItem(key)) | |
} | |
}) | |
}, | |
setItem: async (key: string, value: string) => { | |
return new Promise<void>(resolve => { | |
if (chrome.storage?.local) { | |
chrome.storage.local.set({ [key]: value }, () => { | |
resolve() | |
}) | |
} else if (localStorage) { | |
resolve(localStorage.setItem(key, value)) | |
} | |
}) | |
}, | |
removeItem: async (key: string) => { | |
return new Promise<void>(resolve => { | |
if (chrome.storage?.local) { | |
chrome.storage.local.remove(key, () => { | |
resolve() | |
}) | |
} else if (globalThis.localStorage) { | |
resolve(localStorage.removeItem(key)) | |
} | |
}) | |
} | |
} | |
export class PlatformRequests implements Requests { | |
// @ts-expect-error unused logger parameter | |
constructor(private readonly _logger: LDLogger) {} | |
createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): LDEventSource { | |
// @ts-expect-error coersion fine | |
return new EventSource(url, eventSourceInitDict) | |
} | |
fetch(url: string, options: RequestInit & { headers?: Record<string, string> } = {}): Promise<Response> { | |
options.headers = options.headers || {} | |
options.headers.Authorization = process.env.LAUNCHDARKLY_CLIENT_ID | |
options.mode = 'cors' | |
return fetch(url, options) | |
} | |
} | |
class PlatformEncoding implements Encoding { | |
btoa(data: string): string { | |
return btoa(data) | |
} | |
} | |
class PlatformInfo implements Info { | |
constructor(private readonly _logger: LDLogger) {} | |
platformData(): PlatformData { | |
const data: PlatformData = { | |
name: 'Superhuman LaunchDarkly' | |
} | |
this._logger.debug(`platformData: ${JSON.stringify(data, null, 2)}`) | |
return data | |
} | |
sdkData(): SdkData { | |
const data: SdkData = { | |
name: 'Superhuman', | |
version: process.env.VERSION | |
} | |
this._logger.debug(`sdkData: ${JSON.stringify(data, null, 2)}`) | |
return data | |
} | |
} | |
class PlatformStorage implements Storage { | |
constructor(private readonly _logger: LDLogger) {} | |
async clear(key: string): Promise<void> { | |
await AsyncStorage.removeItem(key) | |
} | |
async get(key: string): Promise<string | null> { | |
try { | |
const value = await AsyncStorage.getItem(key) | |
return value ?? null | |
} catch (error) { | |
this._logger.debug(`Error getting AsyncStorage key: ${key}, error: ${error}`) | |
return null | |
} | |
} | |
async set(key: string, value: string): Promise<void> { | |
try { | |
await AsyncStorage.setItem(key, value) | |
} catch (error) { | |
this._logger.debug(`Error saving AsyncStorage key: ${key}, value: ${value}, error: ${error}`) | |
} | |
} | |
} | |
const createPlatform = (logger: LDLogger): Platform => ({ | |
crypto: new PlatformCrypto(), | |
info: new PlatformInfo(logger), | |
requests: new PlatformRequests(logger), | |
encoding: new PlatformEncoding(), | |
storage: new PlatformStorage(logger) | |
}) | |
export default class LaunchDarklyWebCLient extends LDClientImpl { | |
constructor(sdkKey: string, autoEnvAttributes: AutoEnvAttributes, options: LDOptions = {}) { | |
const logger = new BasicLogger({ | |
level: 'warn', | |
destination: console.log | |
}) | |
super(sdkKey, autoEnvAttributes, createPlatform(logger), { ...options, logger }, {}) | |
} | |
override createStreamUriPath(context: LDContext) { | |
return `/eval/${process.env.LAUNCHDARKLY_CLIENT_ID}/${base64UrlEncode( | |
JSON.stringify(context), | |
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion | |
this.platform.encoding! | |
)}` | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment