diff --git a/openfeature-provider/js/package.json b/openfeature-provider/js/package.json index 744581e..a765af7 100644 --- a/openfeature-provider/js/package.json +++ b/openfeature-provider/js/package.json @@ -31,7 +31,7 @@ "build": "tsdown", "dev": "tsdown --watch", "test": "vitest", - "proto:gen": "rm -rf src/proto && mkdir -p src/proto && protoc --plugin=node_modules/.bin/protoc-gen-ts_proto --ts_proto_opt useOptionals=messages --ts_proto_opt esModuleInterop=true --ts_proto_out src/proto -Iproto api.proto messages.proto" + "proto:gen": "rm -rf src/proto && mkdir -p src/proto && protoc --plugin=node_modules/.bin/protoc-gen-ts_proto --ts_proto_opt useOptionals=messages --ts_proto_opt esModuleInterop=true --ts_proto_out src/proto -Iproto api.proto messages.proto test-only.proto" }, "dependencies": { "@bufbuild/protobuf": "^2.9.0" diff --git a/openfeature-provider/js/proto/test-only.proto b/openfeature-provider/js/proto/test-only.proto new file mode 100644 index 0000000..8c7edd2 --- /dev/null +++ b/openfeature-provider/js/proto/test-only.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +message WriteFlagLogsRequest { + repeated bytes flag_assigned = 1; + + bytes telemetry_data = 2; + + repeated bytes client_resolve_info = 3; + repeated bytes flag_resolve_info = 4; +} diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.e2e.test.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.e2e.test.ts index 0eb92d9..344ab46 100644 --- a/openfeature-provider/js/src/ConfidenceServerProviderLocal.e2e.test.ts +++ b/openfeature-provider/js/src/ConfidenceServerProviderLocal.e2e.test.ts @@ -11,7 +11,7 @@ const { const moduleBytes = readFileSync(__dirname + '/../../../wasm/confidence_resolver.wasm'); const module = new WebAssembly.Module(moduleBytes); -const resolver = await WasmResolver.load(module); +const resolver = new WasmResolver(module); const confidenceProvider = new ConfidenceServerProviderLocal(resolver, { flagClientSecret: 'RxDVTrXvc6op1XxiQ4OaR31dKbJ39aYV', apiClientId: JS_E2E_CONFIDENCE_API_CLIENT_ID, diff --git a/openfeature-provider/js/src/WasmResolver.test.ts b/openfeature-provider/js/src/WasmResolver.test.ts index 3927c63..5ebfa90 100644 --- a/openfeature-provider/js/src/WasmResolver.test.ts +++ b/openfeature-provider/js/src/WasmResolver.test.ts @@ -1,108 +1,149 @@ -import { beforeEach, describe, expect, it, test } from 'vitest'; -import { WasmResolver } from './WasmResolver'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UnsafeWasmResolver, WasmResolver } from './WasmResolver'; import { readFileSync } from 'node:fs'; -import { ResolveReason } from './proto/api'; -import { spawnSync } from 'node:child_process'; -import { error } from 'node:console'; -import { stderr } from 'node:process'; +import { ResolveWithStickyRequest, ResolveReason } from './proto/api'; +import { WriteFlagLogsRequest } from './proto/test-only'; const moduleBytes = readFileSync(__dirname + '/../../../wasm/confidence_resolver.wasm'); const stateBytes = readFileSync(__dirname + '/../../../wasm/resolver_state.pb'); +const module = new WebAssembly.Module(moduleBytes); const CLIENT_SECRET = 'mkjJruAATQWjeY7foFIWfVAcBWnci2YF'; +const RESOLVE_REQUEST:ResolveWithStickyRequest = { + resolveRequest: { + flags: ['flags/tutorial-feature'], + clientSecret: CLIENT_SECRET, + apply: true, + evaluationContext: { + targeting_key: 'tutorial_visitor', + visitor_id: 'tutorial_visitor', + }, + }, + materializationsPerUnit: {}, + failFastOnSticky: false +}; + +const SET_STATE_REQUEST = { state: stateBytes, accountId: 'confidence-test' }; + + let wasmResolver: WasmResolver; -beforeEach(async () => { - wasmResolver = await WasmResolver.load(new WebAssembly.Module(moduleBytes)); -}); - -it('should fail to resolve without state', () => { - expect(() => { - wasmResolver.resolveWithSticky({ - resolveRequest: { flags: [], clientSecret: 'xyz', apply: false }, - materializationsPerUnit: {}, - failFastOnSticky: false - }); - }).toThrowError('Resolver state not set'); -}); -describe('with state', () => { +describe('basic operation', () => { + beforeEach(() => { - wasmResolver.setResolverState({ state: stateBytes, accountId: 'confidence-test' }); + wasmResolver = new WasmResolver(module); + }); + + it('should fail to resolve without state', () => { + expect(() => { + wasmResolver.resolveWithSticky(RESOLVE_REQUEST); + }).toThrowError('Resolver state not set'); }); + + describe('with state', () => { + beforeEach(() => { + wasmResolver.setResolverState(SET_STATE_REQUEST); + }); + + it('should resolve flags', () => { + const resp = wasmResolver.resolveWithSticky(RESOLVE_REQUEST); - it('should resolve flags', () => { - try { - const resp = wasmResolver.resolveWithSticky({ - resolveRequest: { - flags: ['flags/tutorial-feature'], - clientSecret: CLIENT_SECRET, - apply: true, - evaluationContext: { - targeting_key: 'tutorial_visitor', - visitor_id: 'tutorial_visitor', - }, - }, - materializationsPerUnit: {}, - failFastOnSticky: false + expect(resp).toMatchObject({ + success: { + response: { + resolvedFlags: [ + { + reason: ResolveReason.RESOLVE_REASON_MATCH, + }, + ], + } + } }); + }); + + describe('flushLogs', () => { + + it('should be empty before any resolve', () => { + const logs = wasmResolver.flushLogs(); + expect(logs.length).toBe(0); + }) + + it('should contain logs after a resolve', () => { + wasmResolver.resolveWithSticky(RESOLVE_REQUEST); + + const decoded = WriteFlagLogsRequest.decode(wasmResolver.flushLogs()); + + expect(decoded.flagAssigned.length).toBe(1) + expect(decoded.clientResolveInfo.length).toBe(1); + expect(decoded.flagResolveInfo.length).toBe(1); + }) + }) + }); +}) - expect(resp.success).toBeDefined(); - expect(resp.success?.response).toMatchObject({ - resolvedFlags: [ - { - reason: ResolveReason.RESOLVE_REASON_MATCH, - }, - ], - }); - } catch (e) { - console.log('yo', e); - } +describe('panic handling', () => { + + const resolveWithStickySpy = vi.spyOn(UnsafeWasmResolver.prototype, 'resolveWithSticky'); + const setResolverStateSpy = vi.spyOn(UnsafeWasmResolver.prototype, 'setResolverState'); + + const throwUnreachable = () => { + throw new WebAssembly.RuntimeError('unreachable'); + } + + beforeEach(() => { + vi.resetAllMocks(); + wasmResolver = new WasmResolver(module); }); - describe('flushLogs', () => { - it('should be empty before any resolve', () => { - const logs = wasmResolver.flushLogs(); - expect(logs.length).toBe(0); - }) + it('throws and reloads the instance on panic', () => { + wasmResolver.setResolverState(SET_STATE_REQUEST) + resolveWithStickySpy.mockImplementationOnce(throwUnreachable); - it('should contain logs after a resolve', () => { - wasmResolver.resolveWithSticky({ - resolveRequest: { - flags: ['flags/tutorial-feature'], - clientSecret: CLIENT_SECRET, - apply: true, - evaluationContext: { - targeting_key: 'tutorial_visitor', - visitor_id: 'tutorial_visitor', - }, - }, - materializationsPerUnit: {}, - failFastOnSticky: false - }); + + expect(() => { + wasmResolver.resolveWithSticky(RESOLVE_REQUEST) + }).to.throw('unreachable'); - const decoded = decodeBuffer(wasmResolver.flushLogs()); + // now it should succeed since the instance is reloaded + expect(() => { + wasmResolver.resolveWithSticky(RESOLVE_REQUEST) + }).to.not.throw(); - expect(decoded).contains('flag_assigned'); - expect(decoded).contains('client_resolve_info'); - expect(decoded).contains('flag_resolve_info'); - }) }) -}); + it('can handle panic in setResolverState', () => { + setResolverStateSpy.mockImplementation(throwUnreachable); + + expect(() => { + wasmResolver.setResolverState(SET_STATE_REQUEST) + }).to.throw('unreachable'); + + expect(() => { + wasmResolver.resolveWithSticky(RESOLVE_REQUEST) + }).to.throw('state not set'); + + }) + + it('tries to extracts logs from panicked instance', () => { + wasmResolver.setResolverState(SET_STATE_REQUEST) + + // create some logs + wasmResolver.resolveWithSticky(RESOLVE_REQUEST); + + resolveWithStickySpy.mockImplementationOnce(throwUnreachable); + + expect(() => { + wasmResolver.resolveWithSticky(RESOLVE_REQUEST) + }).to.throw('unreachable'); + + const logs = wasmResolver.flushLogs(); + + expect(logs.length).toBeGreaterThan(0); + + }); + + +}) -function decodeBuffer(input:Uint8Array):string { - const res = spawnSync('protoc',[ - `-I${__dirname}/../../../confidence-resolver/protos`, - `--decode=confidence.flags.resolver.v1.WriteFlagLogsRequest`, - `confidence/flags/resolver/v1/internal_api.proto` - ], { input, encoding: 'utf8' }); - if(res.error) { - throw res.error; - } - if(res.status !== 0) { - throw new Error(res.stderr) - } - return res.stdout; -} \ No newline at end of file diff --git a/openfeature-provider/js/src/WasmResolver.ts b/openfeature-provider/js/src/WasmResolver.ts index 680ab25..b42208a 100644 --- a/openfeature-provider/js/src/WasmResolver.ts +++ b/openfeature-provider/js/src/WasmResolver.ts @@ -3,18 +3,38 @@ import { Request, Response, Void } from './proto/messages'; import { Timestamp } from './proto/google/protobuf/timestamp'; import { ResolveWithStickyRequest, ResolveWithStickyResponse, SetResolverStateRequest } from './proto/api'; import { LocalResolver } from './LocalResolver'; +import { getLogger } from './logger' -type Codec = { +const logger = getLogger('wasm-resolver'); + +export type Codec = { encode(message: T): BinaryWriter; decode(input: Uint8Array): T; }; -export class WasmResolver implements LocalResolver { - private exports: any; - private imports: any; +const EXPORT_FN_NAMES = ['wasm_msg_alloc', 'wasm_msg_free', 'wasm_msg_guest_resolve_with_sticky', 'wasm_msg_guest_set_resolver_state', 'wasm_msg_guest_flush_logs'] as const; +type EXPORT_FN_NAMES = (typeof EXPORT_FN_NAMES)[number]; + +type ResolverExports = { memory: WebAssembly.Memory } & { + [K in EXPORT_FN_NAMES]: Function +} + +function verifyExports(exports:WebAssembly.Exports): asserts exports is ResolverExports { + for(const fnName of EXPORT_FN_NAMES) { + if(typeof exports[fnName] !== 'function') { + throw new Error(`Expected Function export "${fnName}" found ${exports[fnName]}`); + } + } + if(!(exports.memory instanceof WebAssembly.Memory)) { + throw new Error(`Expected WebAssembly.Memory export "memory", found ${exports.memory}`) + } +} - private constructor() { - this.imports = { +export class UnsafeWasmResolver implements LocalResolver { + private exports: ResolverExports; + + constructor(module:WebAssembly.Module) { + const imports = { wasm_msg: { wasm_msg_host_current_time: () => { const epochMillisecond = Date.now(); @@ -25,6 +45,9 @@ export class WasmResolver implements LocalResolver { }, }, }; + const { exports } = new WebAssembly.Instance(module, imports); + verifyExports(exports); + this.exports = exports; } resolveWithSticky(request: ResolveWithStickyRequest): ResolveWithStickyResponse { @@ -86,10 +109,75 @@ export class WasmResolver implements LocalResolver { this.exports.wasm_msg_free(ptr); } - static async load(module: WebAssembly.Module): Promise { - const wasmResolver = new WasmResolver(); - const instance = await WebAssembly.instantiate(module, wasmResolver.imports); - wasmResolver.exports = instance.exports; - return wasmResolver; - } } + +export type DelegateFactory = (module:WebAssembly.Module) => LocalResolver + +export const DEFAULT_DELEGATE_FACTORY:DelegateFactory = module => new UnsafeWasmResolver(module); +export class WasmResolver implements LocalResolver { + + private delegate:LocalResolver; + private currentState?: { state: Uint8Array, accountId:string } + private bufferedLogs: Uint8Array[] = [] + + constructor(private readonly module:WebAssembly.Module, private delegateFactory = DEFAULT_DELEGATE_FACTORY) { + this.delegate = delegateFactory(module); + } + + private reloadInstance(error:unknown) { + logger.error('Failure calling into wasm:', error); + try { + this.bufferedLogs.push(this.delegate.flushLogs()); + } catch(_) { + logger.error('Failed to flushLogs on error'); + } + + this.delegate = this.delegateFactory(this.module); + if(this.currentState) { + this.delegate.setResolverState(this.currentState); + } + } + + resolveWithSticky(request: ResolveWithStickyRequest): ResolveWithStickyResponse { + try { + return this.delegate.resolveWithSticky(request); + } catch(error:unknown) { + if(error instanceof WebAssembly.RuntimeError) { + this.reloadInstance(error); + } + throw error; + } + } + + setResolverState(request: SetResolverStateRequest): void { + this.currentState = request; + try { + this.delegate.setResolverState(request); + } catch(error:unknown) { + if(error instanceof WebAssembly.RuntimeError) { + this.reloadInstance(error); + } + throw error; + } + } + + flushLogs(): Uint8Array { + try { + this.bufferedLogs.push(this.delegate.flushLogs()); + const len = this.bufferedLogs.reduce((sum, chunk) => sum + chunk.length, 0); + const buffer = new Uint8Array(len); + let offset = 0; + for(const chunk of this.bufferedLogs) { + buffer.set(chunk, offset); + offset += chunk.length; + } + this.bufferedLogs.length = 0; + return buffer; + } catch(error:unknown) { + if(error instanceof WebAssembly.RuntimeError) { + this.reloadInstance(error); + } + throw error; + } + } +} \ No newline at end of file diff --git a/openfeature-provider/js/src/index.browser.ts b/openfeature-provider/js/src/index.browser.ts index f1deb14..6387f7a 100644 --- a/openfeature-provider/js/src/index.browser.ts +++ b/openfeature-provider/js/src/index.browser.ts @@ -4,7 +4,7 @@ import { WasmResolver } from './WasmResolver'; const wasmUrl = new URL('confidence_resolver.wasm', import.meta.url); const module = await WebAssembly.compileStreaming(fetch(wasmUrl)); -const resolver = await WasmResolver.load(module); +const resolver = new WasmResolver(module); export function createConfidenceServerProvider(options:ProviderOptions):ConfidenceServerProviderLocal { return new ConfidenceServerProviderLocal(resolver, options) diff --git a/openfeature-provider/js/src/index.node.ts b/openfeature-provider/js/src/index.node.ts index 947c6b0..356ae16 100644 --- a/openfeature-provider/js/src/index.node.ts +++ b/openfeature-provider/js/src/index.node.ts @@ -6,7 +6,7 @@ const wasmPath = require.resolve('./confidence_resolver.wasm'); const buffer = await fs.readFile(wasmPath); const module = await WebAssembly.compile(buffer as BufferSource); -const resolver = await WasmResolver.load(module); +const resolver = new WasmResolver(module); export function createConfidenceServerProvider(options:ProviderOptions):ConfidenceServerProviderLocal { return new ConfidenceServerProviderLocal(resolver, options) diff --git a/openfeature-provider/js/src/logger.ts b/openfeature-provider/js/src/logger.ts index 14824af..ac521c7 100644 --- a/openfeature-provider/js/src/logger.ts +++ b/openfeature-provider/js/src/logger.ts @@ -59,6 +59,7 @@ class LoggerImpl implements Logger { } export const logger = new LoggerImpl('cnfd'); +export const getLogger = logger.getLogger.bind(logger); async function loadDebug():Promise {