diff --git a/Dockerfile b/Dockerfile index 5875977..fa01613 100644 --- a/Dockerfile +++ b/Dockerfile @@ -407,6 +407,8 @@ FROM openfeature-provider-js-base AS openfeature-provider-js.test # Copy confidence-resolver protos (needed by some tests for proto parsing) COPY confidence-resolver/protos ../../../confidence-resolver/protos COPY wasm/resolver_state.pb ../../../wasm/resolver_state.pb +COPY openfeature-provider/js/prettier.config.cjs ./ +COPY openfeature-provider/js/.prettierignore ./ RUN make test diff --git a/openfeature-provider/js/.prettierignore b/openfeature-provider/js/.prettierignore new file mode 100644 index 0000000..2b6f55c --- /dev/null +++ b/openfeature-provider/js/.prettierignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +src/proto/ +.idea +coverage/* +*.log +api/* +CHANGELOG.md +README.md diff --git a/openfeature-provider/js/Makefile b/openfeature-provider/js/Makefile index 4162d16..2a46023 100644 --- a/openfeature-provider/js/Makefile +++ b/openfeature-provider/js/Makefile @@ -42,6 +42,7 @@ install: $(INSTALL_STAMP) $(GEN_TS) build: $(BUILD_STAMP) test: $(WASM_ARTIFACT) $(INSTALL_STAMP) $(GEN_TS) + yarn format:check yarn test --run --exclude='**/*.e2e.test.ts' test-e2e: $(WASM_ARTIFACT) $(INSTALL_STAMP) $(GEN_TS) diff --git a/openfeature-provider/js/README.md b/openfeature-provider/js/README.md index 030a78d..97c0042 100644 --- a/openfeature-provider/js/README.md +++ b/openfeature-provider/js/README.md @@ -182,3 +182,11 @@ The package exports a browser ESM build that compiles the WASM via streaming and ## License See the root `LICENSE`. + +## Formatting + +Code is formatted using prettier, you can format all files by running + +```sh +yarn format +``` diff --git a/openfeature-provider/js/package.json b/openfeature-provider/js/package.json index 167d751..944732e 100644 --- a/openfeature-provider/js/package.json +++ b/openfeature-provider/js/package.json @@ -32,6 +32,8 @@ "scripts": { "build": "tsdown", "dev": "tsdown --watch", + "format": "prettier --config prettier.config.cjs -w .", + "format:check": "prettier --config prettier.config.cjs -c .", "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 test-only.proto" }, @@ -41,11 +43,13 @@ "devDependencies": { "@openfeature/core": "^1.9.0", "@openfeature/server-sdk": "^1.19.0", + "@spotify/prettier-config": "^15.0.0", "@types/debug": "^4", "@types/node": "^24.0.1", "@vitest/coverage-v8": "^3.2.4", "debug": "^4.4.3", "dotenv": "^17.2.2", + "prettier": "^2.8.8", "rolldown": "1.0.0-beta.38", "ts-proto": "^2.7.3", "tsdown": "latest", diff --git a/openfeature-provider/js/prettier.config.cjs b/openfeature-provider/js/prettier.config.cjs new file mode 100644 index 0000000..2598162 --- /dev/null +++ b/openfeature-provider/js/prettier.config.cjs @@ -0,0 +1,8 @@ +const baseConfig = require('@spotify/prettier-config'); + +module.exports = { + ...baseConfig, + tabWidth: 2, + useTabs: false, + printWidth: 120, +}; diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.e2e.test.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.e2e.test.ts index 8f362bf..3e62144 100644 --- a/openfeature-provider/js/src/ConfidenceServerProviderLocal.e2e.test.ts +++ b/openfeature-provider/js/src/ConfidenceServerProviderLocal.e2e.test.ts @@ -4,10 +4,10 @@ import { ConfidenceServerProviderLocal } from './ConfidenceServerProviderLocal'; import { readFileSync } from 'node:fs'; import { WasmResolver } from './WasmResolver'; -const { - JS_E2E_CONFIDENCE_API_CLIENT_ID, - JS_E2E_CONFIDENCE_API_CLIENT_SECRET, -} = requireEnv('JS_E2E_CONFIDENCE_API_CLIENT_ID', 'JS_E2E_CONFIDENCE_API_CLIENT_SECRET'); +const { JS_E2E_CONFIDENCE_API_CLIENT_ID, JS_E2E_CONFIDENCE_API_CLIENT_SECRET } = requireEnv( + 'JS_E2E_CONFIDENCE_API_CLIENT_ID', + 'JS_E2E_CONFIDENCE_API_CLIENT_SECRET', +); const moduleBytes = readFileSync(__dirname + '/../../../wasm/confidence_resolver.wasm'); const module = new WebAssembly.Module(moduleBytes); @@ -15,12 +15,11 @@ const resolver = new WasmResolver(module); const confidenceProvider = new ConfidenceServerProviderLocal(resolver, { flagClientSecret: 'RxDVTrXvc6op1XxiQ4OaR31dKbJ39aYV', apiClientId: JS_E2E_CONFIDENCE_API_CLIENT_ID, - apiClientSecret: JS_E2E_CONFIDENCE_API_CLIENT_SECRET + apiClientSecret: JS_E2E_CONFIDENCE_API_CLIENT_SECRET, }); describe('ConfidenceServerProvider E2E tests', () => { - beforeAll( async () => { - + beforeAll(async () => { await OpenFeature.setProviderAndWait(confidenceProvider); OpenFeature.setContext({ targetingKey: 'test-a', // control @@ -28,7 +27,7 @@ describe('ConfidenceServerProvider E2E tests', () => { }); }); - afterAll(() => OpenFeature.close()) + afterAll(() => OpenFeature.close()); it('should resolve a boolean e2e', async () => { const client = OpenFeature.getClient(); @@ -88,24 +87,26 @@ describe('ConfidenceServerProvider E2E tests', () => { it('should resolve a flag with a sticky resolve', async () => { const client = OpenFeature.getClient(); - const result = await client.getNumberDetails('web-sdk-e2e-flag.double', -1, { targetingKey: 'test-a', sticky: true }); - + const result = await client.getNumberDetails('web-sdk-e2e-flag.double', -1, { + targetingKey: 'test-a', + sticky: true, + }); + // The flag has a running experiment with a sticky assignment. The intake is paused but we should still get the sticky assignment. // If this test breaks it could mean that the experiment was removed or that the bigtable materialization was cleaned out. expect(result.value).toBe(99.99); expect(result.variant).toBe('flags/web-sdk-e2e-flag/variants/sticky'); expect(result.reason).toBe('MATCH'); - }); }); -function requireEnv(...names:N): Record { +function requireEnv(...names: N): Record { return names.reduce((acc, name) => { const value = process.env[name]; - if(!value) throw new Error(`Missing environment variable ${name}`) + if (!value) throw new Error(`Missing environment variable ${name}`); return { ...acc, - [name]: value + [name]: value, }; - }, {}) as Record; -} \ No newline at end of file + }, {}) as Record; +} diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts index 0c52945..280370c 100644 --- a/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts +++ b/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts @@ -1,17 +1,20 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, MockedObject, test, vi } from 'vitest'; import { LocalResolver } from './LocalResolver'; -import { ConfidenceServerProviderLocal, DEFAULT_FLUSH_INTERVAL, DEFAULT_STATE_INTERVAL } from './ConfidenceServerProviderLocal'; +import { + ConfidenceServerProviderLocal, + DEFAULT_FLUSH_INTERVAL, + DEFAULT_STATE_INTERVAL, +} from './ConfidenceServerProviderLocal'; import { abortableSleep, TimeUnit, timeoutSignal } from './util'; import { advanceTimersUntil, NetworkMock } from './test-helpers'; - -const mockedWasmResolver:MockedObject = { +const mockedWasmResolver: MockedObject = { resolveWithSticky: vi.fn(), setResolverState: vi.fn(), - flushLogs: vi.fn().mockReturnValue(new Uint8Array(100)) -} + flushLogs: vi.fn().mockReturnValue(new Uint8Array(100)), +}; -let provider:ConfidenceServerProviderLocal; +let provider: ConfidenceServerProviderLocal; let net: NetworkMock; vi.useFakeTimers(); @@ -22,25 +25,20 @@ beforeEach(() => { vi.setSystemTime(0); net = new NetworkMock(); provider = new ConfidenceServerProviderLocal(mockedWasmResolver, { - flagClientSecret:'flagClientSecret', + flagClientSecret: 'flagClientSecret', apiClientId: 'apiClientId', apiClientSecret: 'apiClientSecret', - fetch: net.fetch + fetch: net.fetch, }); -}) - -afterEach(() => { -}) +}); +afterEach(() => {}); describe('idealized conditions', () => { it('makes some requests', async () => { + await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined()); - await advanceTimersUntil( - expect(provider.initialize()).resolves.toBeUndefined() - ) - - await vi.advanceTimersByTimeAsync(TimeUnit.HOUR + TimeUnit.SECOND) + await vi.advanceTimersByTimeAsync(TimeUnit.HOUR + TimeUnit.SECOND); // the token ttl is one hour, since it renews at 80% of ttl, it will be fetched twice expect(net.iam.token.calls).toBe(2); @@ -50,36 +48,25 @@ describe('idealized conditions', () => { // flush is called every 10s so 360 times in an hour expect(net.resolver.flagLogs.calls).toBe(360); - await advanceTimersUntil( - expect(provider.onClose()).resolves.toBeUndefined() - ); - + await advanceTimersUntil(expect(provider.onClose()).resolves.toBeUndefined()); // close does a final flush expect(net.resolver.flagLogs.calls).toBe(361); - - }) -}) + }); +}); describe('no network', () => { - beforeEach(() => { net.error = 'No network'; }); it('initialize throws after timeout', async () => { - - await advanceTimersUntil( - expect(provider.initialize()).rejects.toThrow() - ); + await advanceTimersUntil(expect(provider.initialize()).rejects.toThrow()); expect(provider.status).toBe('ERROR'); expect(Date.now()).toBe(DEFAULT_STATE_INTERVAL); - - }) - - -}) + }); +}); // --------------------------------------------------------------------------- // Stubbed tests for broader request/middleware behavior (implement later) @@ -87,10 +74,7 @@ describe('no network', () => { describe('auth token handling', () => { it('renews token at 80% of TTL', async () => { - - await advanceTimersUntil( - expect(provider.initialize()).resolves.toBeUndefined() - ); + await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined()); // Immediately after initialize starts, token should be fetched once expect(net.iam.token.calls).toBe(1); @@ -102,17 +86,16 @@ describe('auth token handling', () => { // Cross the 80% boundary, renewal should trigger await vi.advanceTimersByTimeAsync(2 * TimeUnit.SECOND); expect(net.iam.token.calls).toBe(2); - }); it('retries token fetch on transient errors', async () => { // Make the IAM token endpoint transiently fail net.iam.token.status = 503; // Recover after 5s - setTimeout(() => { net.iam.token.status = 200 }, 15_000); + setTimeout(() => { + net.iam.token.status = 200; + }, 15_000); - await advanceTimersUntil( - expect(provider.initialize()).resolves.toBeUndefined() - ); + await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined()); // We should have retried at least once expect(net.iam.token.calls).toBeGreaterThan(1); @@ -120,12 +103,9 @@ describe('auth token handling', () => { }); it('refreshes token on 401 and retries once', async () => { // First authed request returns 401 - net.resolver.stateUri.status = req => req.headers.get('authorization') === 'Bearer token1' ? - 401 : 200; + net.resolver.stateUri.status = req => (req.headers.get('authorization') === 'Bearer token1' ? 401 : 200); - await advanceTimersUntil( - expect(provider.initialize()).resolves.toBeUndefined() - ); + await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined()); // Token should have been fetched initially and then renewed after 401 expect(net.iam.token.calls).toBe(2); @@ -136,9 +116,7 @@ describe('auth token handling', () => { // Make IAM token fetch permanently fail (network-level) net.iam.token.status = 'No network'; - await advanceTimersUntil( - expect(provider.initialize()).rejects.toThrow() - ); + await advanceTimersUntil(expect(provider.initialize()).rejects.toThrow()); // We should have attempted token fetch multiple times due to retry expect(net.iam.token.calls).toBeGreaterThanOrEqual(1); @@ -152,16 +130,12 @@ describe('auth token handling', () => { describe('state update scheduling', () => { it('fetches resolverStateUri on initialize', async () => { - await advanceTimersUntil( - expect(provider.initialize()).resolves.toBeUndefined() - ); + await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined()); expect(net.resolver.stateUri.calls).toBe(1); expect(net.gcs.stateBucket.calls).toBe(1); }); it('polls state at fixed interval', async () => { - await advanceTimersUntil( - expect(provider.initialize()).resolves.toBeUndefined() - ); + await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined()); expect(net.resolver.stateUri.calls).toBe(1); expect(net.gcs.stateBucket.calls).toBe(1); @@ -174,64 +148,57 @@ describe('state update scheduling', () => { expect(net.gcs.stateBucket.calls).toBe(3); }); it('honors If-None-Match and handles 304 Not Modified', async () => { - let eTag = 'v1' + let eTag = 'v1'; const payload = new Uint8Array(100); net.gcs.stateBucket.handler = req => { const ifNoneMatch = req.headers.get('If-None-Match'); - if(ifNoneMatch === eTag) { - return new Response(null, { status: 304 }) + if (ifNoneMatch === eTag) { + return new Response(null, { status: 304 }); } - return new Response(payload, { headers: { eTag }}) - } + return new Response(payload, { headers: { eTag } }); + }; - await advanceTimersUntil( - provider.updateState() - ); + await advanceTimersUntil(provider.updateState()); expect(mockedWasmResolver.setResolverState).toHaveBeenCalledTimes(1); - await advanceTimersUntil( - provider.updateState() - ); + await advanceTimersUntil(provider.updateState()); expect(mockedWasmResolver.setResolverState).toHaveBeenCalledTimes(1); - eTag = 'v2' - await advanceTimersUntil( - provider.updateState() - ); + eTag = 'v2'; + await advanceTimersUntil(provider.updateState()); expect(mockedWasmResolver.setResolverState).toHaveBeenCalledTimes(2); - }); it('retries resolverStateUri on 5xx/network errors with fast backoff', async () => { net.resolver.stateUri.status = 503; - setTimeout(() => { net.resolver.stateUri.status = 200 }, 1500); + setTimeout(() => { + net.resolver.stateUri.status = 200; + }, 1500); - await advanceTimersUntil( - provider.updateState() - ); + await advanceTimersUntil(provider.updateState()); expect(net.resolver.stateUri.calls).toBeGreaterThan(1); expect(mockedWasmResolver.setResolverState).toHaveBeenCalledTimes(1); }); it('retries GCS state download with backoff and stall-timeout', async () => { let chunkDelay = 600; - net.gcs.stateBucket.handler = (req) => { + net.gcs.stateBucket.handler = req => { const body = new ReadableStream({ async start(controller) { - for(let i = 0; i < 10; i++) { + for (let i = 0; i < 10; i++) { await abortableSleep(chunkDelay, req.signal); controller.enqueue(new Uint8Array(100)); } controller.close(); - } + }, }); return new Response(body); }; // Decrease chunkDelay after 2.5s so next retry succeeds - setTimeout(() => { chunkDelay = 100; }, 2500); + setTimeout(() => { + chunkDelay = 100; + }, 2500); - await advanceTimersUntil( - provider.updateState() - ); + await advanceTimersUntil(provider.updateState()); expect(net.gcs.stateBucket.calls).toBeGreaterThan(1); expect(mockedWasmResolver.setResolverState).toHaveBeenCalledTimes(1); }); @@ -239,9 +206,7 @@ describe('state update scheduling', () => { describe('flush behavior', () => { it('flushes periodically at the configured interval', async () => { - await advanceTimersUntil( - expect(provider.initialize()).resolves.toBeUndefined() - ); + await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined()); const start = net.resolver.flagLogs.calls; @@ -252,47 +217,35 @@ describe('flush behavior', () => { expect(net.resolver.flagLogs.calls).toBe(start + 2); }); it('retries flagLogs writes up to 3 attempts', async () => { - await advanceTimersUntil( - expect(provider.initialize()).resolves.toBeUndefined() - ); + await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined()); // Make writes fail transiently, then succeed net.resolver.flagLogs.status = 503; const start = net.resolver.flagLogs.calls; - await advanceTimersUntil( - provider.flush() - ); + await advanceTimersUntil(provider.flush()); const attempts = net.resolver.flagLogs.calls - start; expect(attempts).toBe(3); expect(Date.now()).toBe(1500); }); it('does one final flush on close', async () => { - await advanceTimersUntil( - expect(provider.initialize()).resolves.toBeUndefined() - ); + await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined()); const start = net.resolver.flagLogs.calls; - await advanceTimersUntil( - expect(provider.onClose()).resolves.toBeUndefined() - ); + await advanceTimersUntil(expect(provider.onClose()).resolves.toBeUndefined()); expect(net.resolver.flagLogs.calls).toBe(start + 1); }); it('skips flush if there are no logs to send', async () => { - await advanceTimersUntil( - expect(provider.initialize()).resolves.toBeUndefined() - ); + await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined()); const start = net.resolver.flagLogs.calls; // Make resolver return no logs mockedWasmResolver.flushLogs.mockReturnValueOnce(new Uint8Array(0)); - await advanceTimersUntil( - provider.flush() - ); + await advanceTimersUntil(provider.flush()); expect(net.resolver.flagLogs.calls).toBe(start); }); @@ -306,9 +259,12 @@ describe('router and middleware composition', () => { net.resolver.stateUri.handler = req => { const auth = req.headers.get('authorization'); if (auth && auth.startsWith('Bearer ')) sawAuthOnFlags = true; - return new Response(JSON.stringify({ signedUri: 'https://storage.googleapis.com/stateBucket', account: '' }), { - headers: { 'Content-Type': 'application/json' } - }); + return new Response( + JSON.stringify({ signedUri: 'https://storage.googleapis.com/stateBucket', account: '' }), + { + headers: { 'Content-Type': 'application/json' }, + }, + ); }; net.gcs.stateBucket.handler = req => { const auth = req.headers.get('authorization'); @@ -316,9 +272,7 @@ describe('router and middleware composition', () => { return new Response(new Uint8Array(100)); }; - await advanceTimersUntil( - provider.updateState() - ); + await advanceTimersUntil(provider.updateState()); expect(sawAuthOnFlags).toBe(true); expect(sawNoAuthOnGcs).toBe(true); @@ -326,7 +280,7 @@ describe('router and middleware composition', () => { it('routes storage to retry + stall-timeout', async () => { // Ensure small per-chunk delay (< 500ms) does not trigger stall abort - net.gcs.stateBucket.handler = async (req) => { + net.gcs.stateBucket.handler = async req => { const body = new ReadableStream({ async start(controller) { for (let i = 0; i < 3; i++) { @@ -334,15 +288,13 @@ describe('router and middleware composition', () => { controller.enqueue(new Uint8Array(100)); } controller.close(); - } + }, }); return new Response(body); }; const start = net.gcs.stateBucket.calls; - await advanceTimersUntil( - provider.updateState() - ); + await advanceTimersUntil(provider.updateState()); expect(net.gcs.stateBucket.calls - start).toBe(1); }); @@ -358,16 +310,14 @@ describe('timeouts and aborts', () => { net.resolver.stateUri.status = 'No network'; const shortTimeoutProvider = new ConfidenceServerProviderLocal(mockedWasmResolver, { - flagClientSecret:'flagClientSecret', + flagClientSecret: 'flagClientSecret', apiClientId: 'apiClientId', apiClientSecret: 'apiClientSecret', initializeTimeout: 1000, fetch: net.fetch, }); - await advanceTimersUntil( - expect(shortTimeoutProvider.initialize()).rejects.toThrow() - ); + await advanceTimersUntil(expect(shortTimeoutProvider.initialize()).rejects.toThrow()); expect(Date.now()).toBe(1000); expect(shortTimeoutProvider.status).toBe('ERROR'); @@ -381,9 +331,7 @@ describe('timeouts and aborts', () => { // Abort provider immediately const close = provider.onClose(); - await advanceTimersUntil( - expect(init).rejects.toThrow() - ); + await advanceTimersUntil(expect(init).rejects.toThrow()); await advanceTimersUntil(close); expect(provider.status).toBe('ERROR'); await vi.runAllTimersAsync(); @@ -393,9 +341,7 @@ describe('timeouts and aborts', () => { // Abort before dispatch by using server pre-latency and a short timeout signal net.resolver.latency = 1_000; // 500ms pre-dispatch const signal = timeoutSignal(100); - await advanceTimersUntil( - expect(provider.updateState(signal)).rejects.toThrow() - ); + await advanceTimersUntil(expect(provider.updateState(signal)).rejects.toThrow()); // aborted before endpoint was invoked expect(net.resolver.stateUri.calls).toBe(0); }); @@ -404,9 +350,7 @@ describe('timeouts and aborts', () => { net.resolver.stateUri.latency = 0; net.resolver.stateUri.latency = 200; const signal = timeoutSignal(100); - await advanceTimersUntil( - expect(provider.updateState(signal)).rejects.toThrow() - ); + await advanceTimersUntil(expect(provider.updateState(signal)).rejects.toThrow()); // endpoint was invoked once expect(net.resolver.stateUri.calls).toBe(1); }); @@ -415,18 +359,16 @@ describe('timeouts and aborts', () => { describe('network error modes', () => { it('treats HTTP 5xx as Response (no throw) and retries appropriately', async () => { net.resolver.stateUri.status = 503; - setTimeout(() => { net.resolver.stateUri.status = 200 }, 1500); - await advanceTimersUntil( - provider.updateState() - ); + setTimeout(() => { + net.resolver.stateUri.status = 200; + }, 1500); + await advanceTimersUntil(provider.updateState()); expect(net.resolver.stateUri.calls).toBeGreaterThan(1); }); it('treats DNS/connect/TLS failures as throws and retries appropriately', async () => { net.resolver.flagLogs.status = 'No network'; - await advanceTimersUntil( - expect(provider.flush()).rejects.toThrow() - ); + await advanceTimersUntil(expect(provider.flush()).rejects.toThrow()); expect(net.resolver.flagLogs.calls).toBeGreaterThan(1); }); }); @@ -435,29 +377,29 @@ describe('remote resolver fallback for sticky assignments', () => { const RESOLVE_REASON_MATCH = 1; it('resolves locally when WASM has all materialization data', async () => { - await advanceTimersUntil( - expect(provider.initialize()).resolves.toBeUndefined() - ); + await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined()); // WASM resolver succeeds with local data mockedWasmResolver.resolveWithSticky.mockReturnValue({ success: { response: { - resolvedFlags: [{ - flag: 'test-flag', - variant: 'variant-a', - value: { enabled: true }, - reason: RESOLVE_REASON_MATCH - }], + resolvedFlags: [ + { + flag: 'test-flag', + variant: 'variant-a', + value: { enabled: true }, + reason: RESOLVE_REASON_MATCH, + }, + ], resolveToken: new Uint8Array(), - resolveId: 'resolve-123' + resolveId: 'resolve-123', }, - updates: [] - } + updates: [], + }, }); const result = await provider.resolveBooleanEvaluation('test-flag.enabled', false, { - targetingKey: 'user-123' + targetingKey: 'user-123', }); expect(result.value).toBe(true); @@ -467,10 +409,10 @@ describe('remote resolver fallback for sticky assignments', () => { expect(mockedWasmResolver.resolveWithSticky).toHaveBeenCalledWith({ resolveRequest: expect.objectContaining({ flags: ['flags/test-flag'], - clientSecret: 'flagClientSecret' + clientSecret: 'flagClientSecret', }), materializationsPerUnit: {}, - failFastOnSticky: true + failFastOnSticky: true, }); // No remote call needed @@ -478,40 +420,45 @@ describe('remote resolver fallback for sticky assignments', () => { }); it('falls back to remote resolver when WASM reports missing materializations', async () => { - await advanceTimersUntil( - expect(provider.initialize()).resolves.toBeUndefined() - ); + await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined()); // WASM resolver reports missing materialization mockedWasmResolver.resolveWithSticky.mockReturnValue({ missingMaterializations: { - items: [ - { unit: 'user-456', rule: 'rule-1', readMaterialization: 'mat-v1' } - ] - } + items: [{ unit: 'user-456', rule: 'rule-1', readMaterialization: 'mat-v1' }], + }, }); // Configure remote resolver response net.resolver.flagsResolve.handler = () => { - return new Response(JSON.stringify({ - resolvedFlags: [{ - flag: 'flags/my-flag', - variant: 'flags/my-flag/variants/control', - value: { color: 'blue', size: 10 }, - reason: 'RESOLVE_REASON_MATCH' - }], - resolveToken: '', - resolveId: 'remote-resolve-456' - }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }); + return new Response( + JSON.stringify({ + resolvedFlags: [ + { + flag: 'flags/my-flag', + variant: 'flags/my-flag/variants/control', + value: { color: 'blue', size: 10 }, + reason: 'RESOLVE_REASON_MATCH', + }, + ], + resolveToken: '', + resolveId: 'remote-resolve-456', + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); }; - const result = await provider.resolveObjectEvaluation('my-flag', { color: 'red' }, { - targetingKey: 'user-456', - country: 'SE' - }); + const result = await provider.resolveObjectEvaluation( + 'my-flag', + { color: 'red' }, + { + targetingKey: 'user-456', + country: 'SE', + }, + ); expect(result.value).toEqual({ color: 'blue', size: 10 }); expect(result.variant).toBe('flags/my-flag/variants/control'); @@ -525,29 +472,31 @@ describe('remote resolver fallback for sticky assignments', () => { }); it('retries remote resolve on transient errors', async () => { - await advanceTimersUntil( - expect(provider.initialize()).resolves.toBeUndefined() - ); + await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined()); mockedWasmResolver.resolveWithSticky.mockReturnValue({ missingMaterializations: { - items: [{ unit: 'user-1', rule: 'rule-1', readMaterialization: 'mat-1' }] - } + items: [{ unit: 'user-1', rule: 'rule-1', readMaterialization: 'mat-1' }], + }, }); // First two calls fail, third succeeds net.resolver.flagsResolve.status = 503; setTimeout(() => { net.resolver.flagsResolve.status = 200; - net.resolver.flagsResolve.handler = () => new Response(JSON.stringify({ - resolvedFlags: [{ flag: 'test-flag', variant: 'v1', value: { ok: true }, reason: 'RESOLVE_REASON_MATCH' }], - resolveToken: '', - resolveId: 'resolved' - }), { status: 200 }); + net.resolver.flagsResolve.handler = () => + new Response( + JSON.stringify({ + resolvedFlags: [{ flag: 'test-flag', variant: 'v1', value: { ok: true }, reason: 'RESOLVE_REASON_MATCH' }], + resolveToken: '', + resolveId: 'resolved', + }), + { status: 200 }, + ); }, 300); const result = await advanceTimersUntil( - provider.resolveBooleanEvaluation('test-flag.ok', false, { targetingKey: 'user-1' }) + provider.resolveBooleanEvaluation('test-flag.ok', false, { targetingKey: 'user-1' }), ); expect(result.value).toBe(true); @@ -555,4 +504,4 @@ describe('remote resolver fallback for sticky assignments', () => { expect(net.resolver.flagsResolve.calls).toBeGreaterThan(1); expect(net.resolver.flagsResolve.calls).toBeLessThanOrEqual(3); }); -}); \ No newline at end of file +}); diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts index d2a6361..daf96ed 100644 --- a/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts +++ b/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts @@ -8,27 +8,31 @@ import type { ResolutionDetails, ResolutionReason, } from '@openfeature/server-sdk'; +import { ResolveReason } from './proto/api'; +import { ResolveFlagsRequest, ResolveFlagsResponse, ResolveWithStickyRequest } from './proto/api'; import { - ResolveReason -} from './proto/api'; -import { - ResolveFlagsRequest, - ResolveFlagsResponse, - ResolveWithStickyRequest, -} from './proto/api'; -import { Fetch, FetchMiddleware, withAuth, withLogging, withResponse, withRetry, withRouter, withStallTimeout, withTimeout } from './fetch'; + Fetch, + FetchMiddleware, + withAuth, + withLogging, + withResponse, + withRetry, + withRouter, + withStallTimeout, + withTimeout, +} from './fetch'; import { scheduleWithFixedInterval, timeoutSignal, TimeUnit } from './util'; import { AccessToken, LocalResolver, ResolveStateUri } from './LocalResolver'; export const DEFAULT_STATE_INTERVAL = 30_000; export const DEFAULT_FLUSH_INTERVAL = 10_000; export interface ProviderOptions { - flagClientSecret:string, - apiClientId:string, - apiClientSecret:string, - initializeTimeout?:number, - flushInterval?:number, - fetch?: typeof fetch, + flagClientSecret: string; + apiClientId: string; + apiClientSecret: string; + initializeTimeout?: number; + flushInterval?: number; + fetch?: typeof fetch; } /** @@ -44,85 +48,90 @@ export class ConfidenceServerProviderLocal implements Provider { status = 'NOT_READY' as ProviderStatus; private readonly main = new AbortController(); - private readonly fetch:Fetch; - private readonly flushInterval:number; - private stateEtag:string | null = null; - + private readonly fetch: Fetch; + private readonly flushInterval: number; + private stateEtag: string | null = null; // TODO Maybe pass in a resolver factory, so that we can initialize it in initialize and transition to fatal if not. - constructor(private resolver:LocalResolver, private options:ProviderOptions) { + constructor(private resolver: LocalResolver, private options: ProviderOptions) { // TODO better error handling // TODO validate options this.flushInterval = options.flushInterval ?? DEFAULT_FLUSH_INTERVAL; const withConfidenceAuth = withAuth(async () => { const { accessToken, expiresIn } = await this.fetchToken(); - return [accessToken, new Date(Date.now() + 1000*expiresIn)] + return [accessToken, new Date(Date.now() + 1000 * expiresIn)]; }, this.main.signal); const withFastRetry = FetchMiddleware.compose( withRetry({ maxAttempts: Infinity, baseInterval: 300, - maxInterval: 5*TimeUnit.SECOND + maxInterval: 5 * TimeUnit.SECOND, }), - withTimeout(5*TimeUnit.SECOND) + withTimeout(5 * TimeUnit.SECOND), ); - this.fetch = Fetch.create([ - withRouter({ - 'https://iam.confidence.dev/v1/oauth/token': [ - withFastRetry - ], - 'https://storage.googleapis.com/*':[ - withRetry({ - maxAttempts: Infinity, - baseInterval: 500, - maxInterval: DEFAULT_STATE_INTERVAL, - }), - withStallTimeout(500) - ], - 'https://resolver.confidence.dev/*':[ - withConfidenceAuth, - withRouter({ - '*/v1/resolverState:resolverStateUri':[ - withFastRetry, - ], - '*/v1/flags:resolve':[ - withRetry({ - maxAttempts: 3, - baseInterval: 100, - }), - withTimeout(3*TimeUnit.SECOND) // TODO make configurable - ], - '*/v1/flagLogs:write':[ - withRetry({ - maxAttempts: 3, - baseInterval: 500, - }), - withTimeout(5*TimeUnit.SECOND) - ] - }), - ], - // non-configured requests - '*': [withResponse((url) => { throw new Error(`Unknown route ${url}`)})] - }), - withLogging() - ], options.fetch ?? fetch); + this.fetch = Fetch.create( + [ + withRouter({ + 'https://iam.confidence.dev/v1/oauth/token': [withFastRetry], + 'https://storage.googleapis.com/*': [ + withRetry({ + maxAttempts: Infinity, + baseInterval: 500, + maxInterval: DEFAULT_STATE_INTERVAL, + }), + withStallTimeout(500), + ], + 'https://resolver.confidence.dev/*': [ + withConfidenceAuth, + withRouter({ + '*/v1/resolverState:resolverStateUri': [withFastRetry], + '*/v1/flags:resolve': [ + withRetry({ + maxAttempts: 3, + baseInterval: 100, + }), + withTimeout(3 * TimeUnit.SECOND), // TODO make configurable + ], + '*/v1/flagLogs:write': [ + withRetry({ + maxAttempts: 3, + baseInterval: 500, + }), + withTimeout(5 * TimeUnit.SECOND), + ], + }), + ], + // non-configured requests + '*': [ + withResponse(url => { + throw new Error(`Unknown route ${url}`); + }), + ], + }), + withLogging(), + ], + options.fetch ?? fetch, + ); } async initialize(context?: EvaluationContext): Promise { // TODO validate options and switch to fatal. const signal = this.main.signal; - const initialUpdateSignal = AbortSignal.any([signal, timeoutSignal(this.options.initializeTimeout ?? DEFAULT_STATE_INTERVAL)]); + const initialUpdateSignal = AbortSignal.any([ + signal, + timeoutSignal(this.options.initializeTimeout ?? DEFAULT_STATE_INTERVAL), + ]); try { // TODO set schedulers irrespective of failure - // TODO if 403 here, + // TODO if 403 here, await this.updateState(initialUpdateSignal); scheduleWithFixedInterval(signal => this.flush(signal), this.flushInterval, { maxConcurrent: 3, signal }); // TODO Better with fixed delay so we don't do a double fetch when we're behind. Alt, skip if in progress scheduleWithFixedInterval(signal => this.updateState(signal), DEFAULT_STATE_INTERVAL, { signal }); this.status = 'READY' as ProviderStatus; - } catch(e:unknown) { + } catch (e: unknown) { this.status = 'ERROR' as ProviderStatus; // TODO should we swallow this? throw e; @@ -145,10 +154,10 @@ export class ConfidenceServerProviderLocal implements Provider { flags: [`flags/${flagName}`], evaluationContext: ConfidenceServerProviderLocal.convertEvaluationContext(context), apply: true, - clientSecret: this.options.flagClientSecret + clientSecret: this.options.flagClientSecret, }, materializationsPerUnit: {}, - failFastOnSticky: true // Always fail fast - use remote resolver for sticky assignments + failFastOnSticky: true, // Always fail fast - use remote resolver for sticky assignments }; const response = await this.resolveWithStickyInternal(stickyRequest); @@ -160,9 +169,7 @@ export class ConfidenceServerProviderLocal implements Provider { * * @private */ - private async resolveWithStickyInternal( - request: ResolveWithStickyRequest - ): Promise { + private async resolveWithStickyInternal(request: ResolveWithStickyRequest): Promise { const response = this.resolver.resolveWithSticky(request); if (response.success && response.success.response) { @@ -180,7 +187,7 @@ export class ConfidenceServerProviderLocal implements Provider { private async remoteResolve(request: ResolveFlagsRequest): Promise { const url = 'https://resolver.confidence.dev/v1/flags:resolve'; - + const resp = await this.fetch(url, { method: 'POST', headers: { @@ -200,17 +207,12 @@ export class ConfidenceServerProviderLocal implements Provider { /** * Extract and validate the value from a resolved flag. */ - private extractValue( - flag: any, - flagName: string, - path: string[], - defaultValue: T - ): ResolutionDetails { + private extractValue(flag: any, flagName: string, path: string[], defaultValue: T): ResolutionDetails { if (!flag) { return { value: defaultValue, reason: 'ERROR', - errorCode: 'FLAG_NOT_FOUND' as ErrorCode + errorCode: 'FLAG_NOT_FOUND' as ErrorCode, }; } @@ -227,7 +229,7 @@ export class ConfidenceServerProviderLocal implements Provider { return { value: defaultValue, reason: 'ERROR', - errorCode: 'TYPE_MISMATCH' as ErrorCode + errorCode: 'TYPE_MISMATCH' as ErrorCode, }; } value = value[step]; @@ -237,43 +239,43 @@ export class ConfidenceServerProviderLocal implements Provider { return { value: defaultValue, reason: 'ERROR', - errorCode: 'TYPE_MISMATCH' as ErrorCode + errorCode: 'TYPE_MISMATCH' as ErrorCode, }; } return { value, reason: 'MATCH', - variant: flag.variant + variant: flag.variant, }; } - async updateState(signal?:AbortSignal):Promise { + async updateState(signal?: AbortSignal): Promise { const { signedUri, account } = await this.fetchResolveStateUri(signal); - const headers = new Headers() - if(this.stateEtag) { + const headers = new Headers(); + if (this.stateEtag) { headers.set('If-None-Match', this.stateEtag); } const resp = await this.fetch(signedUri, { headers, signal }); - if(resp.status === 304) { + if (resp.status === 304) { // not changed return; } - if(!resp.ok) { + if (!resp.ok) { throw new Error(`Failed to fetch state: ${resp.status} ${resp.statusText}`); } this.stateEtag = resp.headers.get('etag'); const state = new Uint8Array(await resp.arrayBuffer()); this.resolver.setResolverState({ accountId: account, - state - }) + state, + }); } // TODO should this return success/failure, or even throw? - async flush(signal?:AbortSignal):Promise { + async flush(signal?: AbortSignal): Promise { const writeFlagLogRequest = this.resolver.flushLogs(); - if(writeFlagLogRequest.length == 0) { + if (writeFlagLogRequest.length == 0) { // nothing to send return; } @@ -283,38 +285,38 @@ export class ConfidenceServerProviderLocal implements Provider { headers: { 'Content-Type': 'application/x-protobuf', }, - body: writeFlagLogRequest as Uint8Array + body: writeFlagLogRequest as Uint8Array, }); } - private async fetchResolveStateUri(signal?: AbortSignal):Promise { + private async fetchResolveStateUri(signal?: AbortSignal): Promise { const resp = await this.fetch('https://resolver.confidence.dev/v1/resolverState:resolverStateUri', { signal }); - if(!resp.ok) { + if (!resp.ok) { throw new Error('Failed to get resolve state url'); } return resp.json(); } - private async fetchToken():Promise { + private async fetchToken(): Promise { const resp = await this.fetch('https://iam.confidence.dev/v1/oauth/token', { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, body: JSON.stringify({ clientId: this.options.apiClientId, clientSecret: this.options.apiClientSecret, - grantType: 'client_credentials' - }) - }) - if(!resp.ok) { + grantType: 'client_credentials', + }), + }); + if (!resp.ok) { throw new Error('Failed to fetch access token'); } return resp.json(); } - private static convertReason(reason:ResolveReason):ResolutionReason { - switch(reason) { + private static convertReason(reason: ResolveReason): ResolutionReason { + switch (reason) { case ResolveReason.RESOLVE_REASON_ERROR: return 'ERROR'; case ResolveReason.RESOLVE_REASON_FLAG_ARCHIVED: @@ -328,14 +330,17 @@ export class ConfidenceServerProviderLocal implements Provider { case ResolveReason.RESOLVE_REASON_NO_TREATMENT_MATCH: return 'NO_TREATMENT_MATCH'; default: - return 'UNSPECIFIED' + return 'UNSPECIFIED'; } } - private static convertEvaluationContext({ targetingKey:targeting_key, ...rest}:EvaluationContext): { [key: string]: any } { + private static convertEvaluationContext({ targetingKey: targeting_key, ...rest }: EvaluationContext): { + [key: string]: any; + } { return { - targeting_key, ...rest - } + targeting_key, + ...rest, + }; } /** Resolves with an evaluation of a Boolean flag */ @@ -372,24 +377,23 @@ export class ConfidenceServerProviderLocal implements Provider { } } -function hasKey(obj:object, key:K): obj is { [P in K]: unknown } { +function hasKey(obj: object, key: K): obj is { [P in K]: unknown } { return key in obj; } -function isAssignableTo(value:unknown, schema:T): value is T { - if(typeof schema !== typeof value) return false; - if(typeof value === 'object' && typeof schema === 'object') { - if(schema === null) return value === null; - if(Array.isArray(schema)) { - if(!Array.isArray(value)) return false; - if(schema.length == 0) return true; +function isAssignableTo(value: unknown, schema: T): value is T { + if (typeof schema !== typeof value) return false; + if (typeof value === 'object' && typeof schema === 'object') { + if (schema === null) return value === null; + if (Array.isArray(schema)) { + if (!Array.isArray(value)) return false; + if (schema.length == 0) return true; return value.every(item => isAssignableTo(item, schema[0])); } - for(const [key, schemaValue] of Object.entries(schema)) { - if(!hasKey(value!, key)) return false; - if(!isAssignableTo(value[key], schemaValue)) return false; + for (const [key, schemaValue] of Object.entries(schema)) { + if (!hasKey(value!, key)) return false; + if (!isAssignableTo(value[key], schemaValue)) return false; } } return true; } - diff --git a/openfeature-provider/js/src/LocalResolver.ts b/openfeature-provider/js/src/LocalResolver.ts index a056d6f..8de165a 100644 --- a/openfeature-provider/js/src/LocalResolver.ts +++ b/openfeature-provider/js/src/LocalResolver.ts @@ -1,22 +1,18 @@ -import type { - ResolveWithStickyRequest, - ResolveWithStickyResponse, - SetResolverStateRequest -} from "./proto/api" +import type { ResolveWithStickyRequest, ResolveWithStickyResponse, SetResolverStateRequest } from './proto/api'; export interface LocalResolver { - resolveWithSticky(request: ResolveWithStickyRequest): ResolveWithStickyResponse - setResolverState(request: SetResolverStateRequest):void - flushLogs():Uint8Array + resolveWithSticky(request: ResolveWithStickyRequest): ResolveWithStickyResponse; + setResolverState(request: SetResolverStateRequest): void; + flushLogs(): Uint8Array; } export interface AccessToken { - accessToken: string, + accessToken: string; /// lifetime seconds - expiresIn: number + expiresIn: number; } export interface ResolveStateUri { - signedUri:string, - account: string, + signedUri: string; + account: string; } diff --git a/openfeature-provider/js/src/WasmResolver.test.ts b/openfeature-provider/js/src/WasmResolver.test.ts index 5ebfa90..facb77b 100644 --- a/openfeature-provider/js/src/WasmResolver.test.ts +++ b/openfeature-provider/js/src/WasmResolver.test.ts @@ -10,7 +10,7 @@ const stateBytes = readFileSync(__dirname + '/../../../wasm/resolver_state.pb'); const module = new WebAssembly.Module(moduleBytes); const CLIENT_SECRET = 'mkjJruAATQWjeY7foFIWfVAcBWnci2YF'; -const RESOLVE_REQUEST:ResolveWithStickyRequest = { +const RESOLVE_REQUEST: ResolveWithStickyRequest = { resolveRequest: { flags: ['flags/tutorial-feature'], clientSecret: CLIENT_SECRET, @@ -21,31 +21,29 @@ const RESOLVE_REQUEST:ResolveWithStickyRequest = { }, }, materializationsPerUnit: {}, - failFastOnSticky: false + failFastOnSticky: false, }; const SET_STATE_REQUEST = { state: stateBytes, accountId: 'confidence-test' }; - let wasmResolver: WasmResolver; describe('basic operation', () => { - beforeEach(() => { 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); @@ -57,77 +55,71 @@ describe('basic operation', () => { 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.flagAssigned.length).toBe(1); expect(decoded.clientResolveInfo.length).toBe(1); expect(decoded.flagResolveInfo.length).toBe(1); - }) - }) + }); + }); }); -}) +}); 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); }); - it('throws and reloads the instance on panic', () => { - wasmResolver.setResolverState(SET_STATE_REQUEST) + wasmResolver.setResolverState(SET_STATE_REQUEST); resolveWithStickySpy.mockImplementationOnce(throwUnreachable); - expect(() => { - wasmResolver.resolveWithSticky(RESOLVE_REQUEST) + wasmResolver.resolveWithSticky(RESOLVE_REQUEST); }).to.throw('unreachable'); - // now it should succeed since the instance is reloaded + // now it should succeed since the instance is reloaded expect(() => { - wasmResolver.resolveWithSticky(RESOLVE_REQUEST) + wasmResolver.resolveWithSticky(RESOLVE_REQUEST); }).to.not.throw(); - - }) + }); it('can handle panic in setResolverState', () => { setResolverStateSpy.mockImplementation(throwUnreachable); expect(() => { - wasmResolver.setResolverState(SET_STATE_REQUEST) + wasmResolver.setResolverState(SET_STATE_REQUEST); }).to.throw('unreachable'); expect(() => { - wasmResolver.resolveWithSticky(RESOLVE_REQUEST) + wasmResolver.resolveWithSticky(RESOLVE_REQUEST); }).to.throw('state not set'); - - }) - + }); + it('tries to extracts logs from panicked instance', () => { - wasmResolver.setResolverState(SET_STATE_REQUEST) + wasmResolver.setResolverState(SET_STATE_REQUEST); // create some logs wasmResolver.resolveWithSticky(RESOLVE_REQUEST); @@ -135,15 +127,11 @@ describe('panic handling', () => { resolveWithStickySpy.mockImplementationOnce(throwUnreachable); expect(() => { - wasmResolver.resolveWithSticky(RESOLVE_REQUEST) + wasmResolver.resolveWithSticky(RESOLVE_REQUEST); }).to.throw('unreachable'); const logs = wasmResolver.flushLogs(); expect(logs.length).toBeGreaterThan(0); - }); - - -}) - +}); diff --git a/openfeature-provider/js/src/WasmResolver.ts b/openfeature-provider/js/src/WasmResolver.ts index b42208a..ef801ee 100644 --- a/openfeature-provider/js/src/WasmResolver.ts +++ b/openfeature-provider/js/src/WasmResolver.ts @@ -3,7 +3,7 @@ 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' +import { getLogger } from './logger'; const logger = getLogger('wasm-resolver'); @@ -12,28 +12,34 @@ export type Codec = { decode(input: Uint8Array): T; }; -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; +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 -} +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') { +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}`) + if (!(exports.memory instanceof WebAssembly.Memory)) { + throw new Error(`Expected WebAssembly.Memory export "memory", found ${exports.memory}`); } } export class UnsafeWasmResolver implements LocalResolver { private exports: ResolverExports; - constructor(module:WebAssembly.Module) { + constructor(module: WebAssembly.Module) { const imports = { wasm_msg: { wasm_msg_host_current_time: () => { @@ -62,10 +68,10 @@ export class UnsafeWasmResolver implements LocalResolver { this.consumeResponse(resPtr, Void); } - flushLogs():Uint8Array { + flushLogs(): Uint8Array { const resPtr = this.exports.wasm_msg_guest_flush_logs(0); - const {data, error} = this.consume(resPtr, Response); - if(error) { + const { data, error } = this.consume(resPtr, Response); + if (error) { throw new Error(error); } return data!; @@ -108,32 +114,30 @@ export class UnsafeWasmResolver implements LocalResolver { private free(ptr: number) { this.exports.wasm_msg_free(ptr); } - } -export type DelegateFactory = (module:WebAssembly.Module) => LocalResolver +export type DelegateFactory = (module: WebAssembly.Module) => LocalResolver; -export const DEFAULT_DELEGATE_FACTORY:DelegateFactory = module => new UnsafeWasmResolver(module); +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[] = []; - private delegate:LocalResolver; - private currentState?: { state: Uint8Array, accountId:string } - private bufferedLogs: Uint8Array[] = [] - - constructor(private readonly module:WebAssembly.Module, private delegateFactory = DEFAULT_DELEGATE_FACTORY) { + constructor(private readonly module: WebAssembly.Module, private delegateFactory = DEFAULT_DELEGATE_FACTORY) { this.delegate = delegateFactory(module); } - private reloadInstance(error:unknown) { + private reloadInstance(error: unknown) { logger.error('Failure calling into wasm:', error); try { this.bufferedLogs.push(this.delegate.flushLogs()); - } catch(_) { + } catch (_) { logger.error('Failed to flushLogs on error'); } this.delegate = this.delegateFactory(this.module); - if(this.currentState) { + if (this.currentState) { this.delegate.setResolverState(this.currentState); } } @@ -141,8 +145,8 @@ export class WasmResolver implements LocalResolver { resolveWithSticky(request: ResolveWithStickyRequest): ResolveWithStickyResponse { try { return this.delegate.resolveWithSticky(request); - } catch(error:unknown) { - if(error instanceof WebAssembly.RuntimeError) { + } catch (error: unknown) { + if (error instanceof WebAssembly.RuntimeError) { this.reloadInstance(error); } throw error; @@ -153,8 +157,8 @@ export class WasmResolver implements LocalResolver { this.currentState = request; try { this.delegate.setResolverState(request); - } catch(error:unknown) { - if(error instanceof WebAssembly.RuntimeError) { + } catch (error: unknown) { + if (error instanceof WebAssembly.RuntimeError) { this.reloadInstance(error); } throw error; @@ -167,17 +171,17 @@ export class WasmResolver implements LocalResolver { 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) { + 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) { + } 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/defines.d.ts b/openfeature-provider/js/src/defines.d.ts index 3094a59..2ff07b0 100644 --- a/openfeature-provider/js/src/defines.d.ts +++ b/openfeature-provider/js/src/defines.d.ts @@ -4,10 +4,10 @@ declare global { /** * If blocks guarded by __ASSERT__ will be folded away in builds. */ - const __ASSERT__:boolean; + const __ASSERT__: boolean; /** * True when running tests, false otherwise. */ - const __TEST__:boolean; -} \ No newline at end of file + const __TEST__: boolean; +} diff --git a/openfeature-provider/js/src/env.ts b/openfeature-provider/js/src/env.ts index b981be1..92286bc 100644 --- a/openfeature-provider/js/src/env.ts +++ b/openfeature-provider/js/src/env.ts @@ -1,3 +1 @@ - - -export const isDev = process?.env?.NODE_ENV !== 'production'; \ No newline at end of file +export const isDev = process?.env?.NODE_ENV !== 'production'; diff --git a/openfeature-provider/js/src/fetch.test.ts b/openfeature-provider/js/src/fetch.test.ts index 68ae743..fb60950 100644 --- a/openfeature-provider/js/src/fetch.test.ts +++ b/openfeature-provider/js/src/fetch.test.ts @@ -2,96 +2,97 @@ import { describe, it, expect, vi, beforeEach, afterEach, MockedFunction } from import { Fetch, FetchMiddleware, withAuth, withRetry, withTimeout, withRouter } from './fetch'; // Helpers -function mockedSink(status:number) : MockedFunction -function mockedSink(error:string) : MockedFunction -function mockedSink(impl:Fetch) : MockedFunction -function mockedSink(cfg:Fetch | number | string) : MockedFunction -{ - let impl:Fetch; - if(typeof cfg === 'number') { +function mockedSink(status: number): MockedFunction; +function mockedSink(error: string): MockedFunction; +function mockedSink(impl: Fetch): MockedFunction; +function mockedSink(cfg: Fetch | number | string): MockedFunction { + let impl: Fetch; + if (typeof cfg === 'number') { impl = async () => new Response(null, { status: cfg }); - } - else if(typeof cfg === 'string') { - impl = () => { throw new Error(cfg) } - } - else { + } else if (typeof cfg === 'string') { + impl = () => { + throw new Error(cfg); + }; + } else { impl = cfg; } - return vi.fn(impl) + return vi.fn(impl); } -function textStream(text:string): ReadableStream { +function textStream(text: string): ReadableStream { const encoder = new TextEncoder(); return new ReadableStream({ start(controller) { controller.enqueue(encoder.encode(text)); controller.close(); - } + }, }); } -async function bodyText(body:BodyInit | null | undefined): Promise { - if(body == null) return ''; +async function bodyText(body: BodyInit | null | undefined): Promise { + if (body == null) return ''; const res = new Response(body); return await res.text(); } describe('fetch middlewares', () => { - describe('Fetch.create composition', () => { it('wraps middlewares right-to-left', async () => { - const order:string[] = []; - const m1:FetchMiddleware = next => async (url, init) => { + const order: string[] = []; + const m1: FetchMiddleware = next => async (url, init) => { order.push('m1-pre'); const r = await next(url, init); order.push('m1-post'); return r; - } - const m2:FetchMiddleware = next => async (url, init) => { + }; + const m2: FetchMiddleware = next => async (url, init) => { order.push('m2-pre'); const r = await next(url, init); order.push('m2-post'); return r; - } + }; const sink = mockedSink(200); const f = Fetch.create([m1, m2], sink); const resp = await f('http://example.test'); expect(resp.status).toBe(200); - expect(order).toEqual(['m1-pre','m2-pre','m2-post','m1-post']); + expect(order).toEqual(['m1-pre', 'm2-pre', 'm2-post', 'm1-post']); }); }); - + describe('withTimeout', () => { it('aborts the request after timeout (or skips if unsupported)', async () => { - const sink = mockedSink((_url, init) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener('abort', () => reject(init.signal?.reason)); - })); + const sink = mockedSink( + (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason)); + }), + ); const f = Fetch.create([withTimeout(15)], sink); const p = f('http://example.test'); - const guard = new Promise((_r, reject) => setTimeout(() => reject(new Error('timeout waiting for abort')), 500)); + const guard = new Promise((_r, reject) => + setTimeout(() => reject(new Error('timeout waiting for abort')), 500), + ); await expect(Promise.race([p as Promise, guard])).rejects.toBeDefined(); }); }); - + describe('withRetry', () => { it('retries on 5xx and eventually succeeds', async () => { - const calls:number[] = []; + const calls: number[] = []; const sink = mockedSink(async () => { calls.push(Date.now()); - return calls.length === 1 - ? new Response(null, { status: 500 }) - : new Response(null, { status: 200 }); + return calls.length === 1 ? new Response(null, { status: 500 }) : new Response(null, { status: 200 }); }); const f = Fetch.create([withRetry({ baseInterval: 1, jitter: 0 })], sink); const resp = await f('http://retry.test'); expect(resp.status).toBe(200); }); - + it('uses Retry-After header when provided (HTTP-date small delta)', async () => { let attempt = 0; const sink = mockedSink(async () => { attempt++; - if(attempt === 1) { + if (attempt === 1) { const soon = new Date(Date.now() + 20).toUTCString(); return new Response(null, { status: 503, headers: { 'Retry-After': soon } as any }); } @@ -101,31 +102,27 @@ describe('fetch middlewares', () => { const resp = await f('http://retry-after.test'); expect(resp.status).toBe(200); }); - + it('replays a ReadableStream body across retries', async () => { - const seen:string[] = []; + const seen: string[] = []; let attempt = 0; const sink = mockedSink(async (_url, init) => { seen.push(await bodyText(init?.body ?? null)); attempt++; - return attempt === 1 - ? new Response(null, { status: 500 }) - : new Response(null, { status: 200 }); + return attempt === 1 ? new Response(null, { status: 500 }) : new Response(null, { status: 200 }); }); const f = Fetch.create([withRetry({ baseInterval: 1, jitter: 0 })], sink); const body = textStream('hello'); const resp = await f('http://body.test', { method: 'POST', body }); expect(resp.status).toBe(200); - expect(seen).toEqual(['hello','hello']); + expect(seen).toEqual(['hello', 'hello']); }); - + it('aborts during backoff if signal aborted', async () => { let attempt = 0; const sink = mockedSink(async () => { attempt++; - return attempt === 1 - ? new Response(null, { status: 500 }) - : new Response(null, { status: 200 }); + return attempt === 1 ? new Response(null, { status: 500 }) : new Response(null, { status: 200 }); }); const f = Fetch.create([withRetry({ baseInterval: 10, jitter: 0 })], sink); const c = new AbortController(); @@ -135,112 +132,126 @@ describe('fetch middlewares', () => { await expect(p).rejects.toBeDefined(); }); }); - + describe('withAuth', () => { it('adds Authorization header and retries once on 401 with renewed token', async () => { - const calls:{ auth?:string }[] = []; + const calls: { auth?: string }[] = []; let nextStatus = 401; const sink = mockedSink(async (_url, init) => { - calls.push({ auth: (init?.headers instanceof Headers) ? init.headers.get('Authorization') ?? undefined : new Headers(init?.headers as any).get('Authorization') ?? undefined }); + calls.push({ + auth: + init?.headers instanceof Headers + ? init.headers.get('Authorization') ?? undefined + : new Headers(init?.headers as any).get('Authorization') ?? undefined, + }); const r = new Response(null, { status: nextStatus }); nextStatus = 200; return r; }); - + let tokenGen = 0; const tokenProvider = async () => { tokenGen += 1; return [`t${tokenGen}`, new Date(Date.now() + 60_000)] as [string, Date]; - } - + }; + const f = Fetch.create([withAuth(tokenProvider)], sink); const resp = await f('http://auth.test'); expect(resp.status).toBe(200); - expect(calls.map(c => c.auth)).toEqual(['Bearer t1','Bearer t2']); + expect(calls.map(c => c.auth)).toEqual(['Bearer t1', 'Bearer t2']); }); }); - + describe('withRouter', () => { it('matches simple trailing * (prefix match)', async () => { const sink = mockedSink(200); - const routed = Fetch.create([ - withRouter({ - 'https://api.example.com/v1/items/*': [next => next], - }) - ], sink); + const routed = Fetch.create( + [ + withRouter({ + 'https://api.example.com/v1/items/*': [next => next], + }), + ], + sink, + ); const ok = await routed('https://api.example.com/v1/items/123'); expect(ok.status).toBe(200); }); - + it('matches across segments via anchored regex', async () => { const sink = mockedSink(200); - const routed = Fetch.create([ - withRouter({ - '^https://api\\.example\\.com/.*/metrics$': [next => next], - }) - ], sink); + const routed = Fetch.create( + [ + withRouter({ + '^https://api\\.example\\.com/.*/metrics$': [next => next], + }), + ], + sink, + ); const ok = await routed('https://api.example.com/a/b/metrics'); expect(ok.status).toBe(200); }); - + it('matches exactly one segment via anchored regex', async () => { const sink = mockedSink(200); - const routed = Fetch.create([ - withRouter({ - '^https://api\\.example\\.com/v1/[^/]+/metrics$': [next => next], - }) - ], sink); + const routed = Fetch.create( + [ + withRouter({ + '^https://api\\.example\\.com/v1/[^/]+/metrics$': [next => next], + }), + ], + sink, + ); const ok = await routed('https://api.example.com/v1/users/metrics'); expect(ok.status).toBe(200); }); - + it('matches zero or more segments via anchored regex', async () => { const sink = mockedSink(200); - const routed = Fetch.create([ - withRouter({ - '^https://api\\.example\\.com(?:/[^/]+)*/metrics/[^/]+$': [next => next], - }) - ], sink); + const routed = Fetch.create( + [ + withRouter({ + '^https://api\\.example\\.com(?:/[^/]+)*/metrics/[^/]+$': [next => next], + }), + ], + sink, + ); const ok1 = await routed('https://api.example.com/metrics/x'); const ok2 = await routed('https://api.example.com/a/b/metrics/x'); expect(ok1.status).toBe(200); expect(ok2.status).toBe(200); }); - + it('supports leading single * (suffix match) and regex alternative', async () => { const sink = mockedSink(200); - const routed = Fetch.create([ - withRouter({ - '*/health': [next => next], - '^https://[^/]+/v1/[^/]+$': [next => next], - }) - ], sink); + const routed = Fetch.create( + [ + withRouter({ + '*/health': [next => next], + '^https://[^/]+/v1/[^/]+$': [next => next], + }), + ], + sink, + ); const ok1 = await routed('https://service/foo/health'); const ok2 = await routed('https://x.example/v1/y'); expect(ok1.status).toBe(200); expect(ok2.status).toBe(200); }); - + it('supports catch-all *', async () => { const sink = mockedSink(200); - const routed = Fetch.create([ - withRouter({ '*': [next => next] }) - ], sink); + const routed = Fetch.create([withRouter({ '*': [next => next] })], sink); const res = await routed('https://anything.example/path'); expect(res.status).toBe(200); }); - + it('falls through to sink on no match', async () => { - const route = mockedSink('err') + const route = mockedSink('err'); const sink = mockedSink(200); - const routed = Fetch.create([ - withRouter({ 'https://api.example.com/v1/*': [ next => route] }) - ], sink); + const routed = Fetch.create([withRouter({ 'https://api.example.com/v1/*': [next => route] })], sink); const res = await routed('https://other.example.com/v1/a'); expect(sink).toHaveBeenCalledTimes(1); expect(route).not.toBeCalled(); }); }); - - }); diff --git a/openfeature-provider/js/src/fetch.ts b/openfeature-provider/js/src/fetch.ts index 7b3acc1..3681cb9 100644 --- a/openfeature-provider/js/src/fetch.ts +++ b/openfeature-provider/js/src/fetch.ts @@ -1,282 +1,292 @@ -import { logger as rootLogger, Logger } from "./logger"; -import { portableSetTimeout, abortableSleep, abortablePromise } from "./util"; +import { logger as rootLogger, Logger } from './logger'; +import { portableSetTimeout, abortableSleep, abortablePromise } from './util'; const logger = rootLogger.getLogger('fetch'); -export type Fetch = (url:string, init?: RequestInit) => Promise; +export type Fetch = (url: string, init?: RequestInit) => Promise; export namespace Fetch { - - export function create(middleware:FetchMiddleware[], sink:Fetch = fetch):Fetch { + export function create(middleware: FetchMiddleware[], sink: Fetch = fetch): Fetch { return middleware.reduceRight((next, middleware) => middleware(next), sink); } } -export type FetchMiddleware = (next:Fetch) => Fetch; +export type FetchMiddleware = (next: Fetch) => Fetch; export namespace FetchMiddleware { - - export function compose(outer:FetchMiddleware, inner:FetchMiddleware):FetchMiddleware { - return next => outer(inner(next)) + export function compose(outer: FetchMiddleware, inner: FetchMiddleware): FetchMiddleware { + return next => outer(inner(next)); } } -export function withTimeout(timeoutMs:number) : FetchMiddleware { - return next => (url, init = {}) => { - const signal = timeoutSignal(timeoutMs, init.signal); - return next(url, { ...init, signal }); - } +export function withTimeout(timeoutMs: number): FetchMiddleware { + return next => + (url, init = {}) => { + const signal = timeoutSignal(timeoutMs, init.signal); + return next(url, { ...init, signal }); + }; } -export function withStallTimeout(stallTimeoutMs:number) : FetchMiddleware { - return next => async (url, init = {}) => { - const ac = new AbortController(); - const signal = init.signal ? AbortSignal.any([ac.signal, init.signal]) : ac.signal; - const abort = () => { - ac.abort(new Error(`The operation timed out after not receiving data for ${stallTimeoutMs}ms`)); - } - let timeoutId = portableSetTimeout(abort, stallTimeoutMs); - - - const resp = await next(url, { ...init, signal }); - clearTimeout(timeoutId); - if(resp.body) { - const chunks:Uint8Array[] = []; - timeoutId = portableSetTimeout(abort, stallTimeoutMs); +export function withStallTimeout(stallTimeoutMs: number): FetchMiddleware { + return next => + async (url, init = {}) => { + const ac = new AbortController(); + const signal = init.signal ? AbortSignal.any([ac.signal, init.signal]) : ac.signal; + const abort = () => { + ac.abort(new Error(`The operation timed out after not receiving data for ${stallTimeoutMs}ms`)); + }; + let timeoutId = portableSetTimeout(abort, stallTimeoutMs); - for await(const chunk of resp.body) { - chunks.push(chunk); - clearTimeout(timeoutId); + const resp = await next(url, { ...init, signal }); + clearTimeout(timeoutId); + if (resp.body) { + const chunks: Uint8Array[] = []; timeoutId = portableSetTimeout(abort, stallTimeoutMs); - } - return new Response(new Blob(chunks), { - status: resp.status, - statusText: resp.statusText, - headers: resp.headers, - }); - } - return resp; - } + for await (const chunk of resp.body) { + chunks.push(chunk); + clearTimeout(timeoutId); + timeoutId = portableSetTimeout(abort, stallTimeoutMs); + } + + return new Response(new Blob(chunks), { + status: resp.status, + statusText: resp.statusText, + headers: resp.headers, + }); + } + return resp; + }; } -export function withRetry(opts?:{ +export function withRetry(opts?: { maxAttempts?: number; baseInterval?: number; maxInterval?: number; - abortAtInterval?: boolean, - backoff?: number; - jitter?: number; -}) : FetchMiddleware { - const maxAttempts = opts?.maxAttempts ?? 6; - const baseInterval = opts?.baseInterval ?? 250; - const maxInterval = opts?.maxInterval ?? 30_000; - const backoff = opts?.backoff ?? 2; - const jitter = opts?.jitter ?? (__TEST__ ? 0 : 0.1); - - return next => async (url, { body, signal, ...init} = {}) => { - const cloneBody = await bodyRepeater(body); - let attempts = 0; - let deadline = 0; + abortAtInterval?: boolean; + backoff?: number; + jitter?: number; +}): FetchMiddleware { + const maxAttempts = opts?.maxAttempts ?? 6; + const baseInterval = opts?.baseInterval ?? 250; + const maxInterval = opts?.maxInterval ?? 30_000; + const backoff = opts?.backoff ?? 2; + const jitter = opts?.jitter ?? (__TEST__ ? 0 : 0.1); - const calculateDeadline = ():number => { - const jitterFactor = 1 + 2 * Math.random() * jitter - jitter; - const delay = jitterFactor * Math.min(maxInterval, baseInterval * Math.pow(backoff, attempts)); - return Date.now() + delay; - } - const onSuccess = async (resp:Response) => { - const { status, statusText } = resp; - if(status !== 408 && status !== 429 && status < 500 || attempts >= maxAttempts) { - return resp; - } - logger.debug('withRetry %s failed attempt %d with %d %s', url, attempts - 1, status, statusText); - const serverDelay = parseRetryAfter(resp.headers.get('Retry-After'), baseInterval, maxInterval); + return next => + async (url, { body, signal, ...init } = {}) => { + const cloneBody = await bodyRepeater(body); + let attempts = 0; + let deadline = 0; + + const calculateDeadline = (): number => { + const jitterFactor = 1 + 2 * Math.random() * jitter - jitter; + const delay = jitterFactor * Math.min(maxInterval, baseInterval * Math.pow(backoff, attempts)); + return Date.now() + delay; + }; + const onSuccess = async (resp: Response) => { + const { status, statusText } = resp; + if ((status !== 408 && status !== 429 && status < 500) || attempts >= maxAttempts) { + return resp; + } + logger.debug('withRetry %s failed attempt %d with %d %s', url, attempts - 1, status, statusText); + const serverDelay = parseRetryAfter(resp.headers.get('Retry-After'), baseInterval, maxInterval); + + await abortableSleep(serverDelay ?? deadline - Date.now(), signal); + return doTry(); + }; + const onError = async (error: unknown) => { + logger.debug('withRetry %s failed attempt %d with %s', url, attempts - 1, error); + if (signal?.aborted || attempts >= maxAttempts) { + throw error; + } + await abortableSleep(deadline - Date.now(), signal); + return doTry(); + }; + const doTry = (): Promise => { + let attemptSignal = signal; + deadline = calculateDeadline(); + attempts++; + if (opts?.abortAtInterval) { + attemptSignal = timeoutSignal(deadline - Date.now(), signal); + } + return next(url, { body: cloneBody(), signal: attemptSignal, ...init }).then(onSuccess, onError); + }; - await abortableSleep(serverDelay ?? (deadline - Date.now()), signal); - return doTry(); - } - const onError = async (error:unknown) => { - logger.debug('withRetry %s failed attempt %d with %s', url, attempts - 1, error); - if(signal?.aborted || attempts >= maxAttempts) { - throw error; - } - await abortableSleep(deadline - Date.now(), signal); return doTry(); - } - const doTry = ():Promise => { - let attemptSignal = signal; - deadline = calculateDeadline(); - attempts++; - if(opts?.abortAtInterval) { - attemptSignal = timeoutSignal(deadline - Date.now(), signal); - } - return next(url, { body: cloneBody(), signal:attemptSignal, ...init}).then(onSuccess, onError) }; - - return doTry(); - } } -export function withAuth(tokenProvider: () => Promise<[token:string, expiry?:Date]>, signal?:AbortSignal): FetchMiddleware { - +export function withAuth( + tokenProvider: () => Promise<[token: string, expiry?: Date]>, + signal?: AbortSignal, +): FetchMiddleware { let renewTimeout = 0; - let current:Promise | null = null; + let current: Promise | null = null; signal?.addEventListener('abort', () => { clearTimeout(renewTimeout); - }) - + }); + const renewToken = () => { - logger.debug("withAuth renewing token"); + logger.debug('withAuth renewing token'); clearTimeout(renewTimeout); - current = tokenProvider().then(([token, expiry]) => { - logger.debug("withAuth renew success %s", expiry && (expiry.valueOf() - Date.now())); - if(expiry) { - const ttl = expiry.valueOf() - Date.now(); - renewTimeout = portableSetTimeout(renewToken, 0.8*ttl); - } - return token; - }).catch(e => { - current = null; - throw e; - }); - } + current = tokenProvider() + .then(([token, expiry]) => { + logger.debug('withAuth renew success %s', expiry && expiry.valueOf() - Date.now()); + if (expiry) { + const ttl = expiry.valueOf() - Date.now(); + renewTimeout = portableSetTimeout(renewToken, 0.8 * ttl); + } + return token; + }) + .catch(e => { + current = null; + throw e; + }); + }; - const fetchWithToken = async (fetch:Fetch, url:string, init:RequestInit) => { + const fetchWithToken = async (fetch: Fetch, url: string, init: RequestInit) => { const token = await abortablePromise(current!, init.signal); const headers = new Headers(init.headers); headers.set('Authorization', `Bearer ${token}`); - return fetch(url, { ...init, headers }) - } + return fetch(url, { ...init, headers }); + }; - return next => async (url, init = {}) => { - const bodyClone = await bodyRepeater(init.body); - if(!current) { - renewToken(); - } - const currentBeforeFetch = current; - let resp = await fetchWithToken(next, url, { ...init, body: bodyClone() }); - if(resp.status === 401) { - // there might be a race of multiple simultaneous 401 - if(current === currentBeforeFetch) { + return next => + async (url, init = {}) => { + const bodyClone = await bodyRepeater(init.body); + if (!current) { renewToken(); } - // do one quick retry on 401 - resp = await fetchWithToken(next, url, { ...init, body: bodyClone() }); - } - return resp; - } + const currentBeforeFetch = current; + let resp = await fetchWithToken(next, url, { ...init, body: bodyClone() }); + if (resp.status === 401) { + // there might be a race of multiple simultaneous 401 + if (current === currentBeforeFetch) { + renewToken(); + } + // do one quick retry on 401 + resp = await fetchWithToken(next, url, { ...init, body: bodyClone() }); + } + return resp; + }; } -export function withRouter(routes:Record):FetchMiddleware { +export function withRouter(routes: Record): FetchMiddleware { // Simplified DSL over full URL string: // - Anchored regex when pattern starts with ^ and ends with $ // - '*' alone => match all // - Leading single '*' => suffix match // - Trailing single '*' => prefix match // - Otherwise exact match (any other '*' usage is unsupported) - const hasOnlyOneStar = (s:string) => (s.split('*').length - 1) === 1; - const compile = (pattern:string):((url:string)=>boolean) => { - if(pattern.length >= 2 && pattern[0] === '^' && pattern[pattern.length-1] === '$') { + const hasOnlyOneStar = (s: string) => s.split('*').length - 1 === 1; + const compile = (pattern: string): ((url: string) => boolean) => { + if (pattern.length >= 2 && pattern[0] === '^' && pattern[pattern.length - 1] === '$') { const rx = new RegExp(pattern); return url => rx.test(url); } - if(pattern === '*') { + if (pattern === '*') { return _ => true; } - if(pattern.includes('|')) { + if (pattern.includes('|')) { const predicates = pattern.split('|').map(compile); - return url => predicates.some(pred => pred(url)) + return url => predicates.some(pred => pred(url)); } - if(pattern.startsWith('*') && hasOnlyOneStar(pattern)) { + if (pattern.startsWith('*') && hasOnlyOneStar(pattern)) { const suffix = pattern.slice(1); return url => url.endsWith(suffix); } - if(pattern.endsWith('*') && hasOnlyOneStar(pattern)) { + if (pattern.endsWith('*') && hasOnlyOneStar(pattern)) { const prefix = pattern.slice(0, -1); return url => url.startsWith(prefix); } - if(pattern.includes('*')) { - throw new Error(`withRouter unsupported pattern "${pattern}". Only single leading or trailing * (or * alone) supported.`) + if (pattern.includes('*')) { + throw new Error( + `withRouter unsupported pattern "${pattern}". Only single leading or trailing * (or * alone) supported.`, + ); } return url => url === pattern; }; - const preCompiled = Object.entries(routes) - .map(([pattern, middlewares]):[(url:string)=>boolean, FetchMiddleware[]] => { + const preCompiled = Object.entries(routes).map( + ([pattern, middlewares]): [(url: string) => boolean, FetchMiddleware[]] => { const predicate = compile(pattern); return [predicate, middlewares]; - }) - - return next => { + }, + ); - const table = preCompiled - .map(([predicate, middlewares]):[(url:string)=>boolean, Fetch] => { - const fetch = Fetch.create(middlewares, next); - return [predicate, fetch]; - }); + return next => { + const table = preCompiled.map(([predicate, middlewares]): [(url: string) => boolean, Fetch] => { + const fetch = Fetch.create(middlewares, next); + return [predicate, fetch]; + }); return async (url, init = {}) => { const match = table.find(([pred]) => pred(url)); - if(!match) { + if (!match) { logger.info('withRouter no route matched %s, falling through', url); return next(url, init); - } + } return match[1](url, init); - } - } + }; + }; } -export function withResponse(factory:(url:string, init?:RequestInit) => Promise): FetchMiddleware { +export function withResponse(factory: (url: string, init?: RequestInit) => Promise): FetchMiddleware { return _next => factory; } const fetchLogger = logger; -export function withLogging(logger:Logger = fetchLogger):FetchMiddleware { +export function withLogging(logger: Logger = fetchLogger): FetchMiddleware { return next => async (url, init) => { const start = Date.now(); const resp = await next(url, init); const duration = Date.now() - start; logger.info('%s %s (%i) %dms', (init?.method ?? 'get').toUpperCase(), url.split('?', 1)[0], resp.status, duration); return resp; - } + }; } -async function bodyRepeater(body:T): Promise<() => T> { - if(body instanceof ReadableStream) { - // TODO this case could be made a little more efficient by body.tee, +async function bodyRepeater(body: T): Promise<() => T> { + if (body instanceof ReadableStream) { + // TODO this case could be made a little more efficient by body.tee, // but we don't use ReadableStreams, so low prio const blob = await new Response(body).blob(); - return () => blob.stream() as T + return () => blob.stream() as T; } return () => body; } -function parseRetryAfter(retryAfterValue:string | null, min = 0, max = Number.MAX_SAFE_INTEGER):number | undefined { - if(retryAfterValue) { - let delay = Number(retryAfterValue)*1000; - if(Number.isNaN(delay)) { +function parseRetryAfter(retryAfterValue: string | null, min = 0, max = Number.MAX_SAFE_INTEGER): number | undefined { + if (retryAfterValue) { + let delay = Number(retryAfterValue) * 1000; + if (Number.isNaN(delay)) { delay = Date.parse(retryAfterValue) - Date.now(); } - if(Number.isFinite(delay) && delay > 0) { + if (Number.isFinite(delay) && delay > 0) { return Math.max(min, Math.min(delay, max)); } } return undefined; } -async function consumeReadableStream(stream:ReadableStream, onData:(chunk:Uint8Array) => void, onEnd?:(err?:unknown) => void):Promise { +async function consumeReadableStream( + stream: ReadableStream, + onData: (chunk: Uint8Array) => void, + onEnd?: (err?: unknown) => void, +): Promise { try { - for await(const chunk of stream) { + for await (const chunk of stream) { onData(chunk); } onEnd?.(); - } catch(e:unknown) { + } catch (e: unknown) { onEnd?.(e); } } -function timeoutSignal(delay:number, signal?:AbortSignal | null):AbortSignal { +function timeoutSignal(delay: number, signal?: AbortSignal | null): AbortSignal { const ac = new AbortController(); portableSetTimeout(() => ac.abort(new Error(`Operation timed out after ${delay}ms`)), delay); return signal ? AbortSignal.any([signal, ac.signal]) : ac.signal; -} \ 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 6387f7a..71c4b49 100644 --- a/openfeature-provider/js/src/index.browser.ts +++ b/openfeature-provider/js/src/index.browser.ts @@ -6,6 +6,6 @@ const wasmUrl = new URL('confidence_resolver.wasm', import.meta.url); const module = await WebAssembly.compileStreaming(fetch(wasmUrl)); const resolver = new WasmResolver(module); -export function createConfidenceServerProvider(options:ProviderOptions):ConfidenceServerProviderLocal { - return new ConfidenceServerProviderLocal(resolver, options) +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 356ae16..11054b8 100644 --- a/openfeature-provider/js/src/index.node.ts +++ b/openfeature-provider/js/src/index.node.ts @@ -1,4 +1,4 @@ -import fs from 'node:fs/promises' +import fs from 'node:fs/promises'; import { ConfidenceServerProviderLocal, ProviderOptions } from './ConfidenceServerProviderLocal'; import { WasmResolver } from './WasmResolver'; @@ -8,6 +8,6 @@ const buffer = await fs.readFile(wasmPath); const module = await WebAssembly.compile(buffer as BufferSource); const resolver = new WasmResolver(module); -export function createConfidenceServerProvider(options:ProviderOptions):ConfidenceServerProviderLocal { - return new ConfidenceServerProviderLocal(resolver, options) +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 ac521c7..26ac00e 100644 --- a/openfeature-provider/js/src/logger.ts +++ b/openfeature-provider/js/src/logger.ts @@ -1,44 +1,43 @@ const NOOP_LOG_FN = () => {}; -export type LogFn = (msg:string, ...rest:any[]) => void; +export type LogFn = (msg: string, ...rest: any[]) => void; -type Debug = (typeof import('debug'))['default']; +type Debug = typeof import('debug')['default']; const debugBackend = loadDebug(); export interface Logger { - debug(msg:string, ...args:any[]):void; - info(msg:string, ...args:any[]):void; - warn(msg:string, ...args:any[]):void; - error(msg:string, ...args:any[]):void; - - readonly name:string; + debug(msg: string, ...args: any[]): void; + info(msg: string, ...args: any[]): void; + warn(msg: string, ...args: any[]): void; + error(msg: string, ...args: any[]): void; - getLogger(name:string):Logger; + readonly name: string; + + getLogger(name: string): Logger; } class LoggerImpl implements Logger { private readonly childLoggers = new Map(); - debug: LogFn = NOOP_LOG_FN; - info: LogFn = NOOP_LOG_FN; - warn: LogFn = NOOP_LOG_FN; - error: LogFn = NOOP_LOG_FN; - + debug: LogFn = NOOP_LOG_FN; + info: LogFn = NOOP_LOG_FN; + warn: LogFn = NOOP_LOG_FN; + error: LogFn = NOOP_LOG_FN; - constructor(readonly name:string) { - this.configure(); + constructor(readonly name: string) { + this.configure(); } async configure() { // TODO we should queue messages logged before configure is done const debug = await debugBackend; - if(!debug) return; - const debugFn = this.debug = debug(this.name + ":debug") - const infoFn = this.info = debug(this.name + ":info") - const warnFn = this.warn = debug(this.name + ":warn") - const errorFn = this.error = debug(this.name + ":error"); + if (!debug) return; + const debugFn = (this.debug = debug(this.name + ':debug')); + const infoFn = (this.info = debug(this.name + ':info')); + const warnFn = (this.warn = debug(this.name + ':warn')); + const errorFn = (this.error = debug(this.name + ':error')); - switch(true) { + switch (true) { case debugFn.enabled: infoFn.enabled = true; case infoFn.enabled: @@ -50,8 +49,8 @@ class LoggerImpl implements Logger { getLogger(name: string): Logger { let child = this.childLoggers.get(name); - if(!child) { - child = new LoggerImpl(this.name + ":" + name); + if (!child) { + child = new LoggerImpl(this.name + ':' + name); this.childLoggers.set(name, child); } return child; @@ -61,13 +60,11 @@ class LoggerImpl implements Logger { export const logger = new LoggerImpl('cnfd'); export const getLogger = logger.getLogger.bind(logger); - -async function loadDebug():Promise { +async function loadDebug(): Promise { try { - const { default:debug } = await import('debug'); + const { default: debug } = await import('debug'); return debug; - } - catch(e) { + } catch (e) { // debug not available return null; } diff --git a/openfeature-provider/js/src/test-helpers.ts b/openfeature-provider/js/src/test-helpers.ts index 5b2d272..ff9132c 100644 --- a/openfeature-provider/js/src/test-helpers.ts +++ b/openfeature-provider/js/src/test-helpers.ts @@ -1,70 +1,67 @@ -import { vi } from "vitest"; -import { AccessToken, ResolveStateUri } from "./LocalResolver"; -import { abortableSleep, isObject, TimeUnit } from "./util"; -import { ReadableStream as NodeReadableStream } from 'node:stream/web' -import { ResolveFlagsResponse } from "./proto/api"; +import { vi } from 'vitest'; +import { AccessToken, ResolveStateUri } from './LocalResolver'; +import { abortableSleep, isObject, TimeUnit } from './util'; +import { ReadableStream as NodeReadableStream } from 'node:stream/web'; +import { ResolveFlagsResponse } from './proto/api'; -type PayloadFactory = (req:Request) => BodyInit | null +type PayloadFactory = (req: Request) => BodyInit | null; type ByteStream = ReadableStream>; - type RequestRecord = { - startTime: number, - url: string, - method: string, - status: number | string, -} + startTime: number; + url: string; + method: string; + status: number | string; +}; -type HandlerFn = (req:Request) => Response | Promise +type HandlerFn = (req: Request) => Response | Promise; -type StatusProvider = (req:Request) => number | string +type StatusProvider = (req: Request) => number | string; class RequestHandler { - readonly requests:RequestRecord[] = []; - latency:number = 0; - bandwidth:number = Infinity - error?: string; - + readonly requests: RequestRecord[] = []; + latency: number = 0; + bandwidth: number = Infinity; + error?: string; + constructor(public handler: HandlerFn) {} - get calls():number { + get calls(): number { return this.requests.length; } async handle(req: Request): Promise { - const startTime = Date.now() - let status:number | string = 'UNKNOWN' + const startTime = Date.now(); + let status: number | string = 'UNKNOWN'; try { - await abortableSleep(this.latency/2, req.signal); - if(this.error) { + await abortableSleep(this.latency / 2, req.signal); + if (this.error) { throw Object.assign(new Error(this.error), { name: this.error }); } - if(req.body && Number.isFinite(this.bandwidth)) { - req = new Request(req, { body: throttleStream(req.body, this.latency, req.signal)}); + if (req.body && Number.isFinite(this.bandwidth)) { + req = new Request(req, { body: throttleStream(req.body, this.latency, req.signal) }); } let resp = await this.handler(req); status = resp.status; - await abortableSleep(this.latency/2, req.signal); - if(resp.body && Number.isFinite(this.bandwidth)) { + await abortableSleep(this.latency / 2, req.signal); + if (resp.body && Number.isFinite(this.bandwidth)) { resp = new Response(throttleStream(resp.body, this.bandwidth, req.signal), { status: resp.status, statusText: resp.statusText, headers: resp.headers, - }) + }); } return resp; - } - catch(err) { + } catch (err) { status = stringifyErrorType(err); throw err; - } - finally { + } finally { this.requests.push({ startTime, url: req.url, method: req.method, - status - }) + status, + }); } } clear(): void { @@ -73,16 +70,12 @@ class RequestHandler { } class RequestDispatcher extends RequestHandler { - - constructor( - private readonly keyFn:(req:Request) => string, - private readonly dispatchMap:Record, - ) { + constructor(private readonly keyFn: (req: Request) => string, private readonly dispatchMap: Record) { super(req => { const key = this.keyFn(req); const handler = this.dispatchMap[key]; return handler ? handler.handle(req) : new Response(null, { status: 404 }); - }) + }); } clear(): void { @@ -91,72 +84,72 @@ class RequestDispatcher extends RequestHandler { } } class EndpointMock extends RequestHandler { - status: number | string | StatusProvider = 200; constructor(private payloadFactory: PayloadFactory = () => null) { super(req => { let status = this.status; - if(typeof status === 'function') { + if (typeof status === 'function') { status = status(req); } - if(typeof status === 'string') { + if (typeof status === 'string') { throw new Error(status); } - if(status === 200) { - return new Response(this.payloadFactory(req)) + if (status === 200) { + return new Response(this.payloadFactory(req)); } - return new Response(null, { status }) - }) + return new Response(null, { status }); + }); } - } class ServerMock extends RequestDispatcher { - - constructor(private endpoints:Record) { - super(req => new URL(req.url).pathname, endpoints) + constructor(private endpoints: Record) { + super(req => new URL(req.url).pathname, endpoints); } - } class IamServerMock extends ServerMock { - readonly token: EndpointMock; constructor() { - let nextToken = 1 - const tokenEndpoint = new EndpointMock(() => JSON.stringify({ - accessToken: `token${nextToken++}`, - expiresIn: 60*60 - } satisfies AccessToken)); - super({ '/v1/oauth/token': tokenEndpoint }) + let nextToken = 1; + const tokenEndpoint = new EndpointMock(() => + JSON.stringify({ + accessToken: `token${nextToken++}`, + expiresIn: 60 * 60, + } satisfies AccessToken), + ); + super({ '/v1/oauth/token': tokenEndpoint }); this.token = tokenEndpoint; } } class ResolverServerMock extends ServerMock { - - readonly flagLogs: EndpointMock - readonly flagsResolve: EndpointMock + readonly flagLogs: EndpointMock; + readonly flagsResolve: EndpointMock; readonly stateUri: EndpointMock; constructor() { const flagLogs = new EndpointMock(); - const flagsResolve = new EndpointMock(() => JSON.stringify({ - resolvedFlags: [], - resolveToken: new Uint8Array(), - resolveId: 'resolve-default' - } satisfies ResolveFlagsResponse)); - const stateUri = new EndpointMock(() => JSON.stringify({ - signedUri: 'https://storage.googleapis.com/stateBucket', - account: '' - } satisfies ResolveStateUri)); + const flagsResolve = new EndpointMock(() => + JSON.stringify({ + resolvedFlags: [], + resolveToken: new Uint8Array(), + resolveId: 'resolve-default', + } satisfies ResolveFlagsResponse), + ); + const stateUri = new EndpointMock(() => + JSON.stringify({ + signedUri: 'https://storage.googleapis.com/stateBucket', + account: '', + } satisfies ResolveStateUri), + ); super({ '/v1/flagLogs:write': flagLogs, '/v1/flags:resolve': flagsResolve, '/v1/resolverState:resolverStateUri': stateUri, - }) + }); this.flagLogs = flagLogs; this.flagsResolve = flagsResolve; this.stateUri = stateUri; @@ -164,19 +157,17 @@ class ResolverServerMock extends ServerMock { } class GcsServerMock extends ServerMock { - - readonly stateBucket:EndpointMock; + readonly stateBucket: EndpointMock; constructor() { - const stateBucket = new EndpointMock(() => new ArrayBuffer(100)) + const stateBucket = new EndpointMock(() => new ArrayBuffer(100)); super({ - '/stateBucket': stateBucket - }) + '/stateBucket': stateBucket, + }); this.stateBucket = stateBucket; } } export class NetworkMock extends RequestDispatcher { - readonly iam: IamServerMock; readonly resolver: ResolverServerMock; readonly gcs: GcsServerMock; @@ -186,102 +177,104 @@ export class NetworkMock extends RequestDispatcher { const resolver = new ResolverServerMock(); const gcs = new GcsServerMock(); - super( - req => new URL(req.url).hostname, - { - 'iam.confidence.dev': iam, - 'resolver.confidence.dev': resolver, - 'storage.googleapis.com': gcs, - }); + super(req => new URL(req.url).hostname, { + 'iam.confidence.dev': iam, + 'resolver.confidence.dev': resolver, + 'storage.googleapis.com': gcs, + }); this.iam = iam; this.resolver = resolver; this.gcs = gcs; } - readonly fetch:typeof fetch = (input, init) => this.handle(new Request(input, init)) + readonly fetch: typeof fetch = (input, init) => this.handle(new Request(input, init)); } -function throttleStream(stream:ByteStream, bandwidth:number, signal?:AbortSignal):ByteStream { - const iter = (async function*() { - for await(const chunk of stream) { - await abortableSleep(chunk.length/bandwidth*1000, signal); +function throttleStream(stream: ByteStream, bandwidth: number, signal?: AbortSignal): ByteStream { + const iter = (async function* () { + for await (const chunk of stream) { + await abortableSleep((chunk.length / bandwidth) * 1000, signal); yield chunk; } })(); return NodeReadableStream.from(iter) as ByteStream; } -function stringifyErrorType(err:unknown):string { - if(isObject(err) && 'name' in err && typeof err.name === 'string') { +function stringifyErrorType(err: unknown): string { + if (isObject(err) && 'name' in err && typeof err.name === 'string') { return err.name; } return String(err); } - -if(vi.isFakeTimers()) { +if (vi.isFakeTimers()) { throw new Error('FakeTimers should not be on when test-helpers.ts is loaded!'); } const realSetImmediate = setImmediate; - -export async function advanceTimersUntil(predicate:() => boolean):Promise -export async function advanceTimersUntil(opt: { timeout?: number }, predicate:() => boolean):Promise -export async function advanceTimersUntil(promise:Promise):Promise -export async function advanceTimersUntil(opt: { timeout: number }, promise:Promise):Promise -export async function advanceTimersUntil(...args:any[]):Promise { - if(!vi.isFakeTimers()) { +export async function advanceTimersUntil(predicate: () => boolean): Promise; +export async function advanceTimersUntil(opt: { timeout?: number }, predicate: () => boolean): Promise; +export async function advanceTimersUntil(promise: Promise): Promise; +export async function advanceTimersUntil(opt: { timeout: number }, promise: Promise): Promise; +export async function advanceTimersUntil(...args: any[]): Promise { + if (!vi.isFakeTimers()) { throw new Error('FakeTimers are not enabled'); } - const opt: { timeout?: number } = args.length == 2 ? args.shift() : {}; - + const opt: { timeout?: number } = args.length == 2 ? args.shift() : {}; + let predicate: () => boolean; let ret = undefined; - if(typeof args[0] === 'function') { + if (typeof args[0] === 'function') { predicate = args[0]; } else { let done = false; ret = args[0]; - ret.finally(() => { done = true; }) + ret.finally(() => { + done = true; + }); predicate = () => done; } - - if(opt.timeout) { + + if (opt.timeout) { let timedOut = false; - const timeout = setTimeout(() => { timedOut = true; }, opt.timeout); + const timeout = setTimeout(() => { + timedOut = true; + }, opt.timeout); const origPred = predicate; predicate = () => { - if(timedOut) { + if (timedOut) { throw new Error('advanceTimersUntil: Timed out'); } try { - if(origPred()) { + if (origPred()) { clearTimeout(timeout); return true; } return false; - } - catch(err) { + } catch (err) { clearTimeout(timeout); throw err; } - } + }; } - await new Promise(resolve => { realSetImmediate(resolve); }); + await new Promise(resolve => { + realSetImmediate(resolve); + }); - while(!predicate()) { + while (!predicate()) { // some code, notably NodeJS WHATWG streams and fetch impl. might schedule immediate calls // that isn't mocked by fake timers, so we advance that first. - if(process.getActiveResourcesInfo().includes('Immediate')) { - await new Promise(resolve => { realSetImmediate(resolve); }); + if (process.getActiveResourcesInfo().includes('Immediate')) { + await new Promise(resolve => { + realSetImmediate(resolve); + }); continue; } - if(vi.getTimerCount() === 0) { - throw new Error('advanceTimersUntil: Condition not met and no timers left to advance') + if (vi.getTimerCount() === 0) { + throw new Error('advanceTimersUntil: Condition not met and no timers left to advance'); } await vi.advanceTimersToNextTimerAsync(); } return ret; } - diff --git a/openfeature-provider/js/src/util.ts b/openfeature-provider/js/src/util.ts index 4bbe036..ee1c65c 100644 --- a/openfeature-provider/js/src/util.ts +++ b/openfeature-provider/js/src/util.ts @@ -1,63 +1,67 @@ -import {logger} from "./logger"; +import { logger } from './logger'; export const enum TimeUnit { - SECOND = 1000, - MINUTE = 1000*60, - HOUR = 1000*60*60, - DAY = 1000*60*60*24, + SECOND = 1000, + MINUTE = 1000 * 60, + HOUR = 1000 * 60 * 60, + DAY = 1000 * 60 * 60 * 24, } -export function scheduleWithFixedDelay(operation:(signal?:AbortSignal) => unknown, delayMs:number):() => void { +export function scheduleWithFixedDelay(operation: (signal?: AbortSignal) => unknown, delayMs: number): () => void { const ac = new AbortController(); let nextRunTimeoutId = 0; - + const run = async () => { try { await operation(ac.signal); - } catch(e:unknown) { + } catch (e: unknown) { logger.warn('scheduleWithFixedDelay failure:', e); } - nextRunTimeoutId = portableSetTimeout(run, delayMs); - } + nextRunTimeoutId = portableSetTimeout(run, delayMs); + }; nextRunTimeoutId = portableSetTimeout(run, delayMs); return () => { clearTimeout(nextRunTimeoutId); ac.abort(); - } + }; } -export function scheduleWithFixedInterval(operation:(signal?:AbortSignal) => unknown, intervalMs:number, opt: { maxConcurrent?:number, signal?:AbortSignal } = {}):() => void { +export function scheduleWithFixedInterval( + operation: (signal?: AbortSignal) => unknown, + intervalMs: number, + opt: { maxConcurrent?: number; signal?: AbortSignal } = {}, +): () => void { const maxConcurrent = opt.maxConcurrent ?? 1; const ac = new AbortController(); let nextRunTimeoutId = 0; let lastRunTime = 0; let concurrent = 0; - if(__ASSERT__) { - if(!Number.isInteger(maxConcurrent) || maxConcurrent < 1) { + if (__ASSERT__) { + if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1) { throw new Error(`maxConcurrent must be an integer greater than zero, was ${maxConcurrent}`); } } - + const run = async () => { lastRunTime = Date.now(); nextRunTimeoutId = portableSetTimeout(run, intervalMs); - if(concurrent >= maxConcurrent) { + if (concurrent >= maxConcurrent) { return; } concurrent++; try { await operation(ac.signal); - } catch(e:unknown) { + } catch (e: unknown) { logger.warn('scheduleWithFixedInterval failure:', e); } concurrent--; const timeSinceLast = Date.now() - lastRunTime; - if(timeSinceLast > intervalMs && nextRunTimeoutId != 0) { + if (timeSinceLast > intervalMs && nextRunTimeoutId != 0) { clearTimeout(nextRunTimeoutId); run(); } - } + }; nextRunTimeoutId = portableSetTimeout(run, intervalMs); @@ -70,66 +74,82 @@ export function scheduleWithFixedInterval(operation:(signal?:AbortSignal) => unk return stop; } -export function timeoutSignal(milliseconds:number):AbortSignal { +export function timeoutSignal(milliseconds: number): AbortSignal { const ac = new AbortController(); - portableSetTimeout(() => { - ac.abort(new Error('Timeout')); - // ac.abort('Timeout'); - }, milliseconds, { unref: false }) + portableSetTimeout( + () => { + ac.abort(new Error('Timeout')); + // ac.abort('Timeout'); + }, + milliseconds, + { unref: false }, + ); return ac.signal; } -export function portableSetTimeout(callback:() => void, milliseconds:number, opt:{ unref?:boolean } = {}):number { - const timeout:unknown = setTimeout(callback, milliseconds); - if(opt.unref && typeof timeout === 'object' && timeout !== null && 'unref' in timeout && typeof timeout.unref === 'function') { +export function portableSetTimeout(callback: () => void, milliseconds: number, opt: { unref?: boolean } = {}): number { + const timeout: unknown = setTimeout(callback, milliseconds); + if ( + opt.unref && + typeof timeout === 'object' && + timeout !== null && + 'unref' in timeout && + typeof timeout.unref === 'function' + ) { timeout.unref(); } return Number(timeout); } -export function abortableSleep(milliseconds:number, signal?:AbortSignal | null):Promise { - if(signal?.aborted) { - return Promise.reject(signal.reason) +export function abortableSleep(milliseconds: number, signal?: AbortSignal | null): Promise { + if (signal?.aborted) { + return Promise.reject(signal.reason); } - if(milliseconds <= 0) { - return Promise.resolve() + if (milliseconds <= 0) { + return Promise.resolve(); } - if(milliseconds === Infinity) { + if (milliseconds === Infinity) { return signal ? promiseSignal(signal) : new Promise(() => {}); } return new Promise((resolve, reject) => { - let timeout:number; + let timeout: number; const onTimeout = () => { cleanup(); resolve(); - } + }; const onAbort = () => { cleanup(); reject(signal?.reason); - } + }; const cleanup = () => { clearTimeout(timeout); signal?.removeEventListener('abort', onAbort); - } + }; signal?.addEventListener('abort', onAbort); timeout = portableSetTimeout(onTimeout, milliseconds); - }) + }); } -export function promiseSignal(signal:AbortSignal):Promise { - if(signal.aborted) { +export function promiseSignal(signal: AbortSignal): Promise { + if (signal.aborted) { return Promise.reject(signal.reason); } - return new Promise((_,reject) => { - signal.addEventListener('abort', () => { reject(signal.reason) }, { once: true }); - }) + return new Promise((_, reject) => { + signal.addEventListener( + 'abort', + () => { + reject(signal.reason); + }, + { once: true }, + ); + }); } -export function abortablePromise(promise:Promise, signal?: AbortSignal | null | undefined):Promise { +export function abortablePromise(promise: Promise, signal?: AbortSignal | null | undefined): Promise { return signal ? Promise.race([promise, promiseSignal(signal)]) : promise; } -export function isObject(value:unknown):value is {} { +export function isObject(value: unknown): value is {} { return typeof value === 'object' && value !== null; } diff --git a/openfeature-provider/js/tsconfig.json b/openfeature-provider/js/tsconfig.json index c76221d..711d938 100644 --- a/openfeature-provider/js/tsconfig.json +++ b/openfeature-provider/js/tsconfig.json @@ -8,5 +8,5 @@ "skipLibCheck": true, "types": ["node"] }, - "include": ["src/**/*"], + "include": ["src/**/*"] } diff --git a/openfeature-provider/js/tsdown.config.ts b/openfeature-provider/js/tsdown.config.ts index a019aba..c443f36 100644 --- a/openfeature-provider/js/tsdown.config.ts +++ b/openfeature-provider/js/tsdown.config.ts @@ -1,5 +1,4 @@ -import { defineConfig } from 'tsdown' - +import { defineConfig } from 'tsdown'; const base = defineConfig({ minify: 'dce-only', @@ -18,13 +17,16 @@ const base = defineConfig({ // }, }); -export default defineConfig([{ - entry: './src/index.node.ts', - platform: 'node', - copy: ['../../wasm/confidence_resolver.wasm'], - ...base -},{ - entry: './src/index.browser.ts', - platform: 'browser', - ...base -}]) +export default defineConfig([ + { + entry: './src/index.node.ts', + platform: 'node', + copy: ['../../wasm/confidence_resolver.wasm'], + ...base, + }, + { + entry: './src/index.browser.ts', + platform: 'browser', + ...base, + }, +]); diff --git a/openfeature-provider/js/vitest.config.ts b/openfeature-provider/js/vitest.config.ts index 99d5dc1..52dbf3f 100644 --- a/openfeature-provider/js/vitest.config.ts +++ b/openfeature-provider/js/vitest.config.ts @@ -1,12 +1,11 @@ -import { defineConfig } from 'vitest/config' +import { defineConfig } from 'vitest/config'; import { config, parse } from 'dotenv'; import { existsSync, readFileSync } from 'fs'; - export default defineConfig({ define: { - __TEST__:'true', - __ASSERT__:'true', + __TEST__: 'true', + __ASSERT__: 'true', }, test: { environment: 'node', @@ -15,20 +14,20 @@ export default defineConfig({ silent: false, watch: false, env: { - ...readEnv('.env.test') - } - } -}) + ...readEnv('.env.test'), + }, + }, +}); -function readEnv(file):Record { +function readEnv(file): Record { try { const buf = readFileSync(file); return parse(buf); - } catch(e) { - if(e.code === 'ENOENT') { + } catch (e) { + if (e.code === 'ENOENT') { console.log('could not find', file); return {}; } throw e; } -} \ No newline at end of file +} diff --git a/openfeature-provider/js/yarn.lock b/openfeature-provider/js/yarn.lock index a2e2177..b1dbfed 100644 --- a/openfeature-provider/js/yarn.lock +++ b/openfeature-provider/js/yarn.lock @@ -691,11 +691,13 @@ __metadata: "@bufbuild/protobuf": "npm:^2.9.0" "@openfeature/core": "npm:^1.9.0" "@openfeature/server-sdk": "npm:^1.19.0" + "@spotify/prettier-config": "npm:^15.0.0" "@types/debug": "npm:^4" "@types/node": "npm:^24.0.1" "@vitest/coverage-v8": "npm:^3.2.4" debug: "npm:^4.4.3" dotenv: "npm:^17.2.2" + prettier: "npm:^2.8.8" rolldown: "npm:1.0.0-beta.38" ts-proto: "npm:^2.7.3" tsdown: "npm:latest" @@ -708,6 +710,15 @@ __metadata: languageName: unknown linkType: soft +"@spotify/prettier-config@npm:^15.0.0": + version: 15.0.0 + resolution: "@spotify/prettier-config@npm:15.0.0" + peerDependencies: + prettier: 2.x + checksum: 10c0/d7033f9f3ab255b490084f72928d4df086f6a51bb6f88b0d3678a5a63e46fc58ad025a0a709f96d8ae91b82093d991d54ea6a48b7d3f2c7f3f747c1853b76532 + languageName: node + linkType: hard + "@tybys/wasm-util@npm:^0.10.1": version: 0.10.1 resolution: "@tybys/wasm-util@npm:0.10.1" @@ -1845,6 +1856,15 @@ __metadata: languageName: node linkType: hard +"prettier@npm:^2.8.8": + version: 2.8.8 + resolution: "prettier@npm:2.8.8" + bin: + prettier: bin-prettier.js + checksum: 10c0/463ea8f9a0946cd5b828d8cf27bd8b567345cf02f56562d5ecde198b91f47a76b7ac9eae0facd247ace70e927143af6135e8cf411986b8cb8478784a4d6d724a + languageName: node + linkType: hard + "proc-log@npm:^5.0.0": version: 5.0.0 resolution: "proc-log@npm:5.0.0"