Skip to content

Instantly share code, notes, and snippets.

@patrickdbakke
Created June 14, 2024 16:14
Show Gist options
  • Save patrickdbakke/c0469e67dfda0a5cf3ed3de4129a7d01 to your computer and use it in GitHub Desktop.
Save patrickdbakke/c0469e67dfda0a5cf3ed3de4129a7d01 to your computer and use it in GitHub Desktop.
LaunchDarkly WebClient
/**
* 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