diff --git a/bun.lock b/bun.lock index 88d1780..51999ab 100644 --- a/bun.lock +++ b/bun.lock @@ -39,6 +39,16 @@ "tsc-esm-fix": "^2.20.26", }, }, + "packages/flipper-cloud": { + "name": "@flippercloud/flipper-cloud", + "version": "0.0.1", + "dependencies": { + "@flippercloud/flipper": "workspace:*", + }, + "devDependencies": { + "tsc-esm-fix": "^2.20.26", + }, + }, "packages/flipper-redis": { "name": "@flippercloud/flipper-redis", "version": "0.0.1", @@ -207,6 +217,8 @@ "@flippercloud/flipper-cache": ["@flippercloud/flipper-cache@workspace:packages/flipper-cache"], + "@flippercloud/flipper-cloud": ["@flippercloud/flipper-cloud@workspace:packages/flipper-cloud"], + "@flippercloud/flipper-redis": ["@flippercloud/flipper-redis@workspace:packages/flipper-redis"], "@flippercloud/flipper-sequelize": ["@flippercloud/flipper-sequelize@workspace:packages/flipper-sequelize"], diff --git a/docs/adapters/README.md b/docs/adapters/README.md index 8586840..bfef321 100644 --- a/docs/adapters/README.md +++ b/docs/adapters/README.md @@ -5,6 +5,7 @@ Flipper TypeScript ships with an in-memory adapter out of the box and provides o ## Available Guides - [Cache](./cache.md) +- [Cloud](./cloud.md) - [Redis](./redis.md) - [Sequelize](./sequelize.md) diff --git a/docs/adapters/cloud.md b/docs/adapters/cloud.md new file mode 100644 index 0000000..974cb2e --- /dev/null +++ b/docs/adapters/cloud.md @@ -0,0 +1,267 @@ +# Cloud Adapter + +The Flipper Cloud adapter provides seamless synchronization between a local adapter and Flipper Cloud. All reads go to the local adapter for low latency, while writes are dual-written to both local and cloud. A background poller keeps the local adapter in sync with cloud changes. + +## Installation + +```bash +bun add @flippercloud/flipper-cloud +``` + +> **Prerequisites:** Node.js ≥ 18, Bun ≥ 1.3.2, and a Flipper Cloud account with an environment token. + +> **Note:** The Cloud adapter requires a local adapter for fast reads. By default it uses an in-memory adapter, but you can provide Redis, Sequelize, or any other adapter for distributed systems. + +## Quick Start + +```typescript +import { FlipperCloud } from '@flippercloud/flipper-cloud' + +const flipper = await FlipperCloud({ + token: process.env.FLIPPER_CLOUD_TOKEN!, +}) + +await flipper.enable('new-feature') +const isEnabled = await flipper.isEnabled('new-feature') +console.log(isEnabled) // true +``` + +## Configuration Options + +### Custom URL + +Override the Flipper Cloud URL (useful for development): + +```typescript +const flipper = await FlipperCloud({ + token: process.env.FLIPPER_CLOUD_TOKEN!, + url: 'http://localhost:5000/adapter', +}) +``` + +### Custom Local Adapter + +Use Redis or another adapter for the local cache: + +```typescript +import { RedisAdapter } from '@flippercloud/flipper-redis' +import Redis from 'ioredis' + +const redis = new Redis() +const localAdapter = new RedisAdapter({ client: redis }) + +const flipper = await FlipperCloud({ + token: process.env.FLIPPER_CLOUD_TOKEN!, + localAdapter, +}) +``` + +This is essential for distributed systems where multiple application servers need to share the same local cache. + +### Sync Interval + +Control how often the background poller syncs with cloud (default: 10 seconds): + +```typescript +const flipper = await FlipperCloud({ + token: process.env.FLIPPER_CLOUD_TOKEN!, + syncInterval: 30000, // 30 seconds +}) +``` + +Longer intervals reduce network traffic but increase staleness. Shorter intervals keep data fresh but increase load. + +### HTTP Timeout + +Configure timeouts for HTTP requests to Flipper Cloud (default: 5000ms): + +```typescript +const flipper = await FlipperCloud({ + token: process.env.FLIPPER_CLOUD_TOKEN!, + timeout: 10000, // 10 seconds +}) +``` + +## Architecture + +The Cloud adapter uses three layers: + +1. **HttpAdapter** - Communicates with Flipper Cloud API via HTTP +2. **DualWrite** - Writes to both local and cloud, reads from local only +3. **Poller** - Background sync that fetches latest state from cloud + +``` +┌─────────────┐ +│ Flipper │ +└──────┬──────┘ + │ + ▼ +┌─────────────────┐ +│ DualWrite │ +└────┬────────┬───┘ + │ │ + │ └──────────────┐ + │ │ + ▼ ▼ +┌──────────┐ ┌────────────┐ +│ Local │◄─────────┤ Poller │ +│ Adapter │ sync └─────┬──────┘ +└──────────┘ │ + ▼ + ┌─────────────┐ + │HttpAdapter │ + │(Cloud API) │ + └─────────────┘ +``` + +**Read path:** Flipper → DualWrite → Local Adapter (fast) +**Write path:** Flipper → DualWrite → Local Adapter + HttpAdapter (dual write) +**Sync path:** Poller → HttpAdapter → Local Adapter (background) + +## Advanced Usage + +### Manual Sync + +Force an immediate sync with cloud: + +```typescript +await flipper.adapter.sync() +``` + +This is useful after important changes or for testing. + +### Read-only Mode + +Create a read-only cloud adapter for worker processes: + +```typescript +const flipper = await FlipperCloud({ + token: process.env.FLIPPER_CLOUD_TOKEN!, + readOnly: true, +}) + +// Reads work +const enabled = await flipper.isEnabled('feature') + +// Writes throw +await flipper.enable('feature') // throws ReadOnlyError! +``` + +Read-only mode still syncs from cloud but prevents writes. + +### Stop Polling + +Stop the background sync (useful during shutdown): + +```typescript +flipper.adapter.stopPolling() +``` + +The poller will clean up its interval timer and stop syncing. + +## Best Practices + +### Use a Distributed Local Adapter + +For multi-server deployments, use Redis or another distributed adapter as your local cache: + +```typescript +import { RedisAdapter } from '@flippercloud/flipper-redis' +import Redis from 'ioredis' + +const redis = new Redis(process.env.REDIS_URL!) +const localAdapter = new RedisAdapter({ client: redis }) + +const flipper = await FlipperCloud({ + token: process.env.FLIPPER_CLOUD_TOKEN!, + localAdapter, + syncInterval: 10000, // 10 seconds +}) +``` + +This ensures all servers see the same feature flag state and reduces latency. + +### Handle Initial Sync + +The `FlipperCloud` function waits for the first sync before returning: + +```typescript +try { + const flipper = await FlipperCloud({ + token: process.env.FLIPPER_CLOUD_TOKEN!, + timeout: 10000, + }) + console.log('Cloud adapter ready!') +} catch (error) { + console.error('Failed to sync with cloud:', error) + // Fall back to local-only mode or exit +} +``` + +If the initial sync fails, the promise rejects. Consider your fallback strategy. + +### Monitor Sync Health + +The poller emits sync events that you can monitor: + +```typescript +const flipper = await FlipperCloud({ + token: process.env.FLIPPER_CLOUD_TOKEN!, +}) + +// Listen for sync events (if instrumentation is configured) +// Implementation depends on your instrumenter setup +``` + +### Graceful Shutdown + +Stop polling when your app shuts down: + +```typescript +process.on('SIGTERM', () => { + flipper.adapter.stopPolling() + process.exit(0) +}) +``` + +## Troubleshooting + +### Sync Failures + +If the poller fails to sync, it will retry on the next interval. Check: + +1. Network connectivity to Flipper Cloud +2. Token validity +3. HTTP timeout settings + +### Stale Data + +If feature flags seem out of date: + +1. Verify `syncInterval` isn't too long +2. Check that the poller is running +3. Force a manual sync: `await flipper.adapter.sync()` + +### High Latency + +All reads should be sub-millisecond since they hit the local adapter. If you see slow reads: + +1. Check local adapter performance (Redis, Sequelize, etc.) +2. Ensure you're not accidentally reading from cloud directly + +## Ruby Interoperability + +The Cloud adapter is fully compatible with the Ruby Flipper Cloud adapter. Both use: + +- Same HTTP API contract +- Same dual-write pattern +- Same polling mechanism +- Same ETag-based caching + +You can mix Ruby and TypeScript services pointing to the same Flipper Cloud environment. + +## See Also + +- [Redis Adapter](./redis.md) - Recommended local adapter for distributed systems +- [Sequelize Adapter](./sequelize.md) - SQL-backed local adapter +- [Cache Adapter](./cache.md) - Layered caching strategies diff --git a/packages/flipper-cloud/CHANGELOG.md b/packages/flipper-cloud/CHANGELOG.md new file mode 100644 index 0000000..2588abd --- /dev/null +++ b/packages/flipper-cloud/CHANGELOG.md @@ -0,0 +1,40 @@ +# @flippercloud/flipper-cloud + +## 0.0.1 + +Initial release of Flipper Cloud adapter for TypeScript/JavaScript. + +### Features + +- **HttpAdapter** - Direct HTTP communication with Flipper Cloud API + - Full CRUD operations for features and gates + - ETag-based caching for efficient `getAll` operations + - Configurable timeouts and custom headers + - Read-only mode support + - Error handling with detailed messages + +- **Poller** - Background synchronization mechanism + - Configurable polling interval (minimum 10 seconds) + - Automatic jitter to prevent thundering herd + - Graceful error handling and recovery + - Manual sync support + +- **Cloud Integration** - High-level `FlipperCloud` function + - Automatic setup with dual-write pattern + - Local adapter for low-latency reads (Memory, Redis, Sequelize, etc.) + - Background sync keeps local cache up-to-date + - Initial sync before starting poller + - Read-only mode for worker processes + +- **Ruby Interoperability** - Compatible with Ruby Flipper Cloud adapter + - Same HTTP API contract + - Same ETag caching behavior + - Same dual-write pattern + +### Documentation + +- Complete adapter guide in `docs/adapters/cloud.md` +- Quick start examples +- Configuration options reference +- Architecture diagrams +- Best practices for production deployments diff --git a/packages/flipper-cloud/LICENSE b/packages/flipper-cloud/LICENSE new file mode 100644 index 0000000..04628bc --- /dev/null +++ b/packages/flipper-cloud/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Flipper Cloud + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/flipper-cloud/README.md b/packages/flipper-cloud/README.md new file mode 100644 index 0000000..c26f041 --- /dev/null +++ b/packages/flipper-cloud/README.md @@ -0,0 +1,34 @@ +# @flippercloud/flipper-cloud + +Official Flipper Cloud adapter for TypeScript/JavaScript. This adapter provides seamless synchronization between your local adapter and Flipper Cloud, with low-latency reads and automatic background sync. + +## Installation + +```bash +bun add @flippercloud/flipper-cloud +``` + +**Note:** You'll also need a local adapter for fast reads. We recommend the memory adapter (built-in) for simple cases, or Redis/Sequelize for distributed systems. + +## Quick usage + +```typescript +import Flipper from '@flippercloud/flipper' +import { FlipperCloud } from '@flippercloud/flipper-cloud' + +const flipper = await FlipperCloud({ + token: 'your-cloud-token', +}) + +await flipper.enable('new-feature') +const isEnabled = await flipper.isEnabled('new-feature') +console.log(isEnabled) // true +``` + +## Docs + +Looking for configuration options, local adapters, sync strategies, and best practices? See the full guide: [Cloud adapter guide](../../docs/adapters/cloud.md). + +## License + +MIT diff --git a/packages/flipper-cloud/eslint.config.js b/packages/flipper-cloud/eslint.config.js new file mode 100644 index 0000000..9b7c436 --- /dev/null +++ b/packages/flipper-cloud/eslint.config.js @@ -0,0 +1,29 @@ +const eslint = require('@eslint/js') +const tseslint = require('typescript-eslint') +const path = require('path') + +module.exports = tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: __dirname, + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + }, + }, + { + ignores: ['node_modules/', 'dist/', 'coverage/', '*.config.js', 'jest.config.ts', '**/*.test.ts'], + } +); diff --git a/packages/flipper-cloud/jest.config.ts b/packages/flipper-cloud/jest.config.ts new file mode 100644 index 0000000..93a0f83 --- /dev/null +++ b/packages/flipper-cloud/jest.config.ts @@ -0,0 +1,34 @@ +import type { Config } from 'jest' + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + reporters: [['default', { verbose: false }]], + testMatch: ['/src/**/*.test.ts'], + testTimeout: 5000, + collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + module: 'ESNext', + skipLibCheck: true, + rootDir: '../../', + }, + }, + ], + }, + extensionsToTreatAsEsm: ['.ts'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + '^@flippercloud/flipper$': '/../../packages/flipper/src/index.ts', + }, + testPathIgnorePatterns: ['/node_modules/', '/dist/'], +} + +export default config diff --git a/packages/flipper-cloud/package.json b/packages/flipper-cloud/package.json new file mode 100644 index 0000000..b38fc0a --- /dev/null +++ b/packages/flipper-cloud/package.json @@ -0,0 +1,50 @@ +{ + "name": "@flippercloud/flipper-cloud", + "description": "Flipper Cloud adapter for TypeScript/JavaScript", + "version": "0.0.1", + "author": "Jonathan Hoyt", + "license": "MIT", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "bun run build:esm && bun run build:cjs", + "build:esm": "tsc --module esnext --outDir dist/esm && tsc-esm-fix --target dist/esm --ext .js", + "build:cjs": "tsc --module commonjs --moduleResolution node --outDir dist/cjs", + "test": "bun test", + "test:watch": "bun test --watch", + "test:coverage": "bun test --coverage", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "type-check": "tsc --noEmit", + "format": "prettier --write \"src/**/*.{ts,tsx,json,md}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,json,md}\"", + "clean": "node -e \"try { require('fs').rmSync('dist', { recursive: true, force: true }); require('fs').rmSync('coverage', { recursive: true, force: true }); } catch (e) {}\"", + "prepublishOnly": "bun run clean && bun run build && bun test" + }, + "dependencies": { + "@flippercloud/flipper": "workspace:*" + }, + "devDependencies": { + "tsc-esm-fix": "^2.20.26" + }, + "engines": { + "node": ">=18.0.0", + "bun": ">=1.3.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/flipper-cloud/src/Cloud.test.ts b/packages/flipper-cloud/src/Cloud.test.ts new file mode 100644 index 0000000..008f921 --- /dev/null +++ b/packages/flipper-cloud/src/Cloud.test.ts @@ -0,0 +1,539 @@ +import { FlipperCloud, CloudAdapter } from './Cloud' +import { MemoryAdapter, Feature } from '@flippercloud/flipper' +import HttpAdapter from './HttpAdapter' + +// Mock fetch globally +const mockFetch = jest.fn() +global.fetch = mockFetch as any + +describe('FlipperCloud', () => { + let createdFlippers: Array>> = [] + + beforeEach(() => { + mockFetch.mockReset() + createdFlippers = [] + }) + + afterEach(() => { + // Clean up any pollers + createdFlippers.forEach(flipper => { + flipper.stopPolling() + }) + createdFlippers = [] + }) + + describe('basic setup', () => { + it('creates a Flipper instance with cloud adapter', async () => { + // Mock successful initial sync + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { + get: () => null, + } as unknown as Headers, + json: async () => ({ features: [] }), + } as Response) + + const flipper = await FlipperCloud({ + token: 'test-token', + syncInterval: 10000, + }) + createdFlippers.push(flipper) + + expect(flipper).toBeDefined() + expect(flipper.adapter).toBeInstanceOf(CloudAdapter) + }) + + it('performs initial sync before returning', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { + get: () => null, + } as unknown as Headers, + json: async () => ({ + features: [ + { + key: 'initial-feature', + gates: [{ key: 'boolean', value: true }], + }, + ], + }), + } as Response) + + const flipper = await FlipperCloud({ + token: 'test-token', + syncInterval: 10000, + }) + createdFlippers.push(flipper) + + const features = await flipper.features() + expect(features.length).toBe(1) + expect(features[0]?.name).toBe('initial-feature') + }) + + it('handles initial sync failure gracefully', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + mockFetch.mockRejectedValueOnce(new Error('Network error')) + + const flipper = await FlipperCloud({ + token: 'test-token', + syncInterval: 10000, + }) + createdFlippers.push(flipper) + + expect(flipper).toBeDefined() + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Flipper Cloud initial sync failed:', + expect.any(Error) + ) + + consoleErrorSpy.mockRestore() + }) + + it('accepts custom URL', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null } as unknown as Headers, + json: async () => ({ features: [] }), + } as Response) + + const flipper = await FlipperCloud({ + token: 'test-token', + url: 'http://localhost:5000/adapter', + syncInterval: 10000, + }) + createdFlippers.push(flipper) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:5000/adapter/features?exclude_gate_names=true', + expect.objectContaining({ + headers: expect.objectContaining({ + 'flipper-cloud-token': 'test-token', + }), + }) + ) + }) + + it('accepts custom timeout', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null } as unknown as Headers, + json: async () => ({ features: [] }), + } as Response) + + const flipper = await FlipperCloud({ + token: 'test-token', + timeout: 15000, + syncInterval: 10000, + }) + createdFlippers.push(flipper) + + expect(flipper).toBeDefined() + }) + + it('accepts custom local adapter', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null } as unknown as Headers, + json: async () => ({ features: [] }), + } as Response) + + const localAdapter = new MemoryAdapter() + const flipper = await FlipperCloud({ + token: 'test-token', + localAdapter, + syncInterval: 10000, + }) + createdFlippers.push(flipper) + + expect(flipper).toBeDefined() + }) + + it('accepts custom headers', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null } as unknown as Headers, + json: async () => ({ features: [] }), + } as Response) + + const flipper = await FlipperCloud({ + token: 'test-token', + headers: { + 'x-custom-header': 'custom-value', + }, + syncInterval: 10000, + }) + createdFlippers.push(flipper) + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + 'flipper-cloud-token': 'test-token', + 'x-custom-header': 'custom-value', + }), + }) + ) + }) + }) + + describe('sync method', () => { + it('forces a manual sync', async () => { + // Initial sync + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null } as unknown as Headers, + json: async () => ({ features: [] }), + } as Response) + + const flipper = await FlipperCloud({ + token: 'test-token', + syncInterval: 10000, + }) + createdFlippers.push(flipper) + + // Manual sync + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null } as unknown as Headers, + json: async () => ({ + features: [ + { + key: 'new-feature', + gates: [{ key: 'boolean', value: true }], + }, + ], + }), + } as Response) + + await flipper.sync() + + const features = await flipper.features() + expect(features.length).toBe(1) + expect(features[0]?.name).toBe('new-feature') + }) + }) + + describe('stopPolling method', () => { + it('stops background polling', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null } as unknown as Headers, + json: async () => ({ features: [] }), + } as Response) + + const flipper = await FlipperCloud({ + token: 'test-token', + syncInterval: 10000, + }) + createdFlippers.push(flipper) + + flipper.stopPolling() + + // Should not throw + expect(flipper).toBeDefined() + }) + }) + + describe('read operations', () => { + it('reads from local adapter', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null } as unknown as Headers, + json: async () => ({ + features: [ + { + key: 'test-feature', + gates: [{ key: 'boolean', value: true }], + }, + ], + }), + } as Response) + + const flipper = await FlipperCloud({ + token: 'test-token', + syncInterval: 10000, + }) + createdFlippers.push(flipper) + + const feature = await flipper.feature('test-feature') + const isEnabled = await feature.isEnabled() + expect(isEnabled).toBe(true) + + // Should not have made additional HTTP calls + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + }) + + describe('write operations', () => { + it('writes to both local and cloud', async () => { + // Initial sync + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null } as unknown as Headers, + json: async () => ({ features: [] }), + } as Response) + + const flipper = await FlipperCloud({ + token: 'test-token', + syncInterval: 10000, + }) + createdFlippers.push(flipper) + + // Enable feature (writes to cloud) + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + } as Response) + + await flipper.enable('new-feature') + + // Should have made 2 HTTP calls: initial sync + enable + expect(mockFetch).toHaveBeenCalledTimes(2) + + // Should be enabled locally + const feature = await flipper.feature('new-feature') + const isEnabled = await feature.isEnabled() + expect(isEnabled).toBe(true) + }) + }) + + describe('read-only mode', () => { + it('creates read-only adapter', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null } as unknown as Headers, + json: async () => ({ features: [] }), + } as Response) + + const flipper = await FlipperCloud({ + token: 'test-token', + readOnly: true, + syncInterval: 10000, + }) + createdFlippers.push(flipper) + + expect(flipper).toBeDefined() + + // Writes should throw + await expect(flipper.enable('test')).rejects.toThrow('write attempted while in read only mode') + }) + }) +}) + +describe('CloudAdapter', () => { + let localAdapter: MemoryAdapter + let remoteAdapter: MemoryAdapter + let cloudAdapter: CloudAdapter + + beforeEach(() => { + mockFetch.mockReset() + localAdapter = new MemoryAdapter() + remoteAdapter = new MemoryAdapter() + }) + + afterEach(() => { + // Cleanup handled by individual tests + }) + + describe('ensureSynced', () => { + it('syncs from poller when poller has newer data', async () => { + // Mock HTTP for poller + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null } as unknown as Headers, + json: async () => ({ + features: [ + { + key: 'poller-feature', + gates: [{ key: 'boolean', value: true }], + }, + ], + }), + } as Response) + + const flipper = await FlipperCloud({ + token: 'test-token', + localAdapter, + syncInterval: 10000, + }) + + cloudAdapter = flipper.adapter + + // Wait briefly for poller to start and sync + await new Promise(resolve => setTimeout(resolve, 100)) + + // Read should trigger ensureSynced + const features = await cloudAdapter.features() + expect(features.length).toBe(1) + expect(features[0]?.name).toBe('poller-feature') + }) + + it('does not sync if poller has no new data', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null } as unknown as Headers, + json: async () => ({ features: [] }), + } as Response) + + const flipper = await FlipperCloud({ + token: 'test-token', + localAdapter, + syncInterval: 10000, + }) + + cloudAdapter = flipper.adapter + + // First read + await cloudAdapter.features() + + // Second read (should not sync again) + await cloudAdapter.features() + + // Only initial sync should have happened + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + }) + + describe('manual sync', () => { + it('syncs from poller on demand', async () => { + // Initial sync + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null } as unknown as Headers, + json: async () => ({ features: [] }), + } as Response) + + const flipper = await FlipperCloud({ + token: 'test-token', + localAdapter, + syncInterval: 10000, + }) + + // Manual sync with new data + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null } as unknown as Headers, + json: async () => ({ + features: [ + { + key: 'manual-sync-feature', + gates: [{ key: 'boolean', value: true }], + }, + ], + }), + } as Response) + + await flipper.adapter.sync() + + const features = await flipper.adapter.features() + expect(features.length).toBe(1) + expect(features[0]?.name).toBe('manual-sync-feature') + }) + }) + + describe('override methods', () => { + it('calls ensureSynced before get', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null } as unknown as Headers, + json: async () => ({ + features: [ + { + key: 'test-feature', + gates: [{ key: 'boolean', value: true }], + }, + ], + }), + } as Response) + + const flipper = await FlipperCloud({ + token: 'test-token', + localAdapter, + syncInterval: 10000, + }) + + const feature = new Feature('test-feature', flipper.adapter, {}) + const result = await flipper.adapter.get(feature) + + expect(result.boolean).toBe('true') + }) + + it('calls ensureSynced before getMulti', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null } as unknown as Headers, + json: async () => ({ + features: [ + { + key: 'feature1', + gates: [{ key: 'boolean', value: true }], + }, + { + key: 'feature2', + gates: [{ key: 'boolean', value: false }], + }, + ], + }), + } as Response) + + const flipper = await FlipperCloud({ + token: 'test-token', + localAdapter, + syncInterval: 10000, + }) + + const feature1 = new Feature('feature1', flipper.adapter, {}) + const feature2 = new Feature('feature2', flipper.adapter, {}) + const result = await flipper.adapter.getMulti([feature1, feature2]) + + expect(result['feature1']?.boolean).toBe('true') + expect(result['feature2']?.boolean).toBeUndefined() + }) + + it('calls ensureSynced before getAll', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { get: () => null } as unknown as Headers, + json: async () => ({ + features: [ + { + key: 'feature1', + gates: [{ key: 'boolean', value: true }], + }, + ], + }), + } as Response) + + const flipper = await FlipperCloud({ + token: 'test-token', + localAdapter, + syncInterval: 10000, + }) + + const result = await flipper.adapter.getAll() + + expect(result['feature1']?.boolean).toBe('true') + }) + }) +}) diff --git a/packages/flipper-cloud/src/Cloud.ts b/packages/flipper-cloud/src/Cloud.ts new file mode 100644 index 0000000..1275d10 --- /dev/null +++ b/packages/flipper-cloud/src/Cloud.ts @@ -0,0 +1,276 @@ +import { Flipper, MemoryAdapter, DualWrite } from '@flippercloud/flipper' +import type { IAdapter } from '@flippercloud/flipper' +import HttpAdapter from './HttpAdapter' +import type { HttpAdapterOptions } from './HttpAdapter' +import Poller from './Poller' + +export interface CloudConfiguration { + /** + * Flipper Cloud token for your environment + */ + token: string + + /** + * Flipper Cloud URL (default: 'https://www.flippercloud.io/adapter') + */ + url?: string + + /** + * HTTP timeout in milliseconds (default: 5000) + */ + timeout?: number + + /** + * Local adapter for fast reads (default: MemoryAdapter) + * Use RedisAdapter or SequelizeAdapter for distributed systems + */ + localAdapter?: IAdapter + + /** + * Polling interval in milliseconds (default: 10000, minimum: 10000) + */ + syncInterval?: number + + /** + * Additional headers to send with HTTP requests + */ + headers?: Record + + /** + * Whether the adapter should be read-only (default: false) + */ + readOnly?: boolean +} + +/** + * Extended Flipper instance with Cloud-specific methods + */ +export interface CloudFlipper extends Flipper { + /** + * Force a manual sync from Flipper Cloud + */ + sync(): Promise + + /** + * Stop the background polling + */ + stopPolling(): void + + /** + * Get the underlying adapter (CloudAdapter wrapper) + */ + readonly adapter: CloudAdapter +} + +/** + * Creates a Flipper Cloud instance with automatic background sync. + * + * This function sets up a complete Flipper Cloud integration with: + * - HttpAdapter for communicating with Flipper Cloud + * - Local adapter (Memory by default) for fast reads + * - DualWrite to keep local and cloud in sync + * - Poller for background synchronization + * + * The returned Flipper instance reads from the local adapter (fast) and writes + * to both local and cloud (dual-write). A background poller keeps the local + * adapter synchronized with cloud changes. + * + * @param config - Cloud configuration + * @returns Promise that resolves to a Flipper instance with cloud methods + * + * @example + * // Basic usage with memory adapter + * const flipper = await FlipperCloud({ + * token: process.env.FLIPPER_CLOUD_TOKEN!, + * }) + * + * @example + * // Production usage with Redis for distributed systems + * import { RedisAdapter } from '@flippercloud/flipper-redis' + * import Redis from 'ioredis' + * + * const redis = new Redis() + * const flipper = await FlipperCloud({ + * token: process.env.FLIPPER_CLOUD_TOKEN!, + * localAdapter: new RedisAdapter({ client: redis }), + * syncInterval: 30000, // 30 seconds + * }) + * + * @example + * // Read-only mode for worker processes + * const flipper = await FlipperCloud({ + * token: process.env.FLIPPER_CLOUD_TOKEN!, + * readOnly: true, + * }) + */ +export async function FlipperCloud(config: CloudConfiguration): Promise { + const { + token, + url = 'https://www.flippercloud.io/adapter', + timeout = 5000, + localAdapter = new MemoryAdapter(), + syncInterval = 10000, + headers = {}, + readOnly = false, + } = config + + // Create HTTP adapter for cloud communication + const httpOptions: HttpAdapterOptions = { + url, + timeout, + readOnly, + headers: { + ...headers, + 'flipper-cloud-token': token, + }, + } + + const httpAdapter = new HttpAdapter(httpOptions) + + // Create poller for background sync + const poller = new Poller({ + remoteAdapter: httpAdapter, + interval: syncInterval, + startAutomatically: false, // We'll start after initial sync + }) + + // Perform initial sync before starting poller + // This ensures the local adapter has data before we start + try { + await poller.sync() + } catch (error) { + // Log but don't fail - we can still operate with empty local cache + console.error('Flipper Cloud initial sync failed:', error) + } + + // Start background polling to keep local adapter in sync with cloud + // Following Ruby pattern: ensure initial data is available, then start background polling + poller.start() + + // Create dual-write adapter (writes to cloud, reads from local with poller sync) + const cloudAdapter = new CloudAdapter(localAdapter, httpAdapter, poller) + + // Create Flipper instance + const flipper = new Flipper(cloudAdapter) as CloudFlipper + + // Add cloud-specific methods + flipper.sync = async (): Promise => { + await cloudAdapter.sync() + } + + flipper.stopPolling = (): void => { + poller.stop() + } + + return flipper +} + +/** + * Cloud adapter that combines DualWrite with Poller-based synchronization. + * + * This adapter: + * - Reads from local adapter (fast, low latency) + * - Writes to both local and remote (dual-write pattern) + * - Syncs from poller's adapter (which is kept up-to-date in background) + * + * The key difference from plain DualWrite is that reads check if the poller + * has synced new data and copies it to the local adapter before returning. + */ +export class CloudAdapter extends DualWrite { + private poller: Poller + private localAdapter: IAdapter + private lastSyncedAt: number = 0 + + constructor(localAdapter: IAdapter, remoteAdapter: IAdapter, poller: Poller) { + super(localAdapter, remoteAdapter) + this.localAdapter = localAdapter + this.poller = poller + } + + private async markLocalChange(): Promise { + // No need to sync back to poller's adapter - poller will sync from remote on next cycle + this.lastSyncedAt = Date.now() + } + + /** + * Ensure local adapter is synced with poller before read operations + */ + private async ensureSynced(): Promise { + const pollerLastSync = this.poller.lastSyncedAt + if (pollerLastSync > this.lastSyncedAt) { + // Poller has new data, copy it to local adapter + await this.localAdapter.import(this.poller.adapter) + this.lastSyncedAt = pollerLastSync + } + } + + override async add(feature: Parameters[0]): Promise>> { + // Avoid extra network round-trips: creating the feature locally is enough, + // since subsequent enable/disable calls will create/update it remotely. + await this.localAdapter.add(feature) + await this.markLocalChange() + return true + } + + override async features(): Promise>> { + await this.ensureSynced() + return await super.features() + } + + override async get(feature: Parameters[0]): Promise>> { + await this.ensureSynced() + return await super.get(feature) + } + + override async getMulti( + features: Parameters[0] + ): Promise>> { + await this.ensureSynced() + return await super.getMulti(features) + } + + override async getAll(): Promise>> { + await this.ensureSynced() + return await super.getAll() + } + + override async remove(feature: Parameters[0]): Promise>> { + const result = await super.remove(feature) + await this.markLocalChange() + return result + } + + override async clear(feature: Parameters[0]): Promise>> { + const result = await super.clear(feature) + await this.markLocalChange() + return result + } + + override async enable( + feature: Parameters[0], + gate: Parameters[1], + thing: Parameters[2] + ): Promise>> { + const result = await super.enable(feature, gate, thing) + await this.markLocalChange() + return result + } + + override async disable( + feature: Parameters[0], + gate: Parameters[1], + thing: Parameters[2] + ): Promise>> { + const result = await super.disable(feature, gate, thing) + await this.markLocalChange() + return result + } + + /** + * Manual sync from cloud + */ + async sync(): Promise { + await this.poller.sync() + await this.ensureSynced() + } +} diff --git a/packages/flipper-cloud/src/HttpAdapter.test.ts b/packages/flipper-cloud/src/HttpAdapter.test.ts new file mode 100644 index 0000000..495c14f --- /dev/null +++ b/packages/flipper-cloud/src/HttpAdapter.test.ts @@ -0,0 +1,522 @@ +import HttpAdapter, { HttpError } from './HttpAdapter' +import { Feature, MemoryAdapter } from '@flippercloud/flipper' + +// Mock fetch globally +const mockFetch = jest.fn() +global.fetch = mockFetch as any + +describe('HttpAdapter', () => { + let adapter: HttpAdapter + + beforeEach(() => { + mockFetch.mockReset() + adapter = new HttpAdapter({ + url: 'http://test.example.com/adapter', + headers: { + 'flipper-cloud-token': 'test-token', + }, + }) + }) + + describe('constructor', () => { + it('removes trailing slash from URL', () => { + const adapter = new HttpAdapter({ + url: 'http://test.example.com/adapter/', + }) + expect(adapter['url']).toBe('http://test.example.com/adapter') + }) + + it('sets default timeout', () => { + expect(adapter['timeout']).toBe(5000) + }) + + it('accepts custom timeout', () => { + const adapter = new HttpAdapter({ + url: 'http://test.example.com', + timeout: 10000, + }) + expect(adapter['timeout']).toBe(10000) + }) + + it('sets default headers', () => { + expect(adapter['headers']['content-type']).toBe('application/json') + expect(adapter['headers']['accept']).toBe('application/json') + expect(adapter['headers']['user-agent']).toContain('Flipper HTTP Adapter') + }) + + it('merges custom headers', () => { + expect(adapter['headers']['flipper-cloud-token']).toBe('test-token') + }) + }) + + describe('features', () => { + it('fetches all features', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + features: [{ key: 'feature1' }, { key: 'feature2' }], + }), + } as Response) + + const features = await adapter.features() + + expect(mockFetch).toHaveBeenCalledWith( + 'http://test.example.com/adapter/features?exclude_gate_names=true', + expect.objectContaining({ + headers: expect.objectContaining({ + 'flipper-cloud-token': 'test-token', + }), + }) + ) + expect(features).toHaveLength(2) + expect(features[0]?.name).toBe('feature1') + expect(features[1]?.name).toBe('feature2') + }) + + it('throws error on failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + json: async () => ({}), + } as Response) + + await expect(adapter.features()).rejects.toThrow(HttpError) + }) + }) + + describe('add', () => { + it('adds a feature', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + } as Response) + + const feature = new Feature('test', adapter, {}) + await adapter.add(feature) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://test.example.com/adapter/features', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ name: 'test' }), + }) + ) + }) + + it('throws on read-only adapter', async () => { + const readOnlyAdapter = new HttpAdapter({ + url: 'http://test.example.com', + readOnly: true, + }) + + const feature = new Feature('test', readOnlyAdapter, {}) + await expect(readOnlyAdapter.add(feature)).rejects.toThrow('write attempted while in read only mode') + }) + }) + + describe('remove', () => { + it('removes a feature', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 204, + json: async () => ({}), + } as Response) + + const feature = new Feature('test', adapter, {}) + await adapter.remove(feature) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://test.example.com/adapter/features/test', + expect.objectContaining({ + method: 'DELETE', + }) + ) + }) + }) + + describe('clear', () => { + it('clears a feature', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 204, + json: async () => ({}), + } as Response) + + const feature = new Feature('test', adapter, {}) + await adapter.clear(feature) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://test.example.com/adapter/features/test/clear', + expect.objectContaining({ + method: 'DELETE', + }) + ) + }) + }) + + describe('get', () => { + it('gets feature state', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + gates: [ + { key: 'boolean', value: true }, + { key: 'actors', value: ['user1', 'user2'] }, + ], + }), + } as Response) + + const feature = new Feature('test', adapter, {}) + const result = await adapter.get(feature) + + expect(result.boolean).toBe('true') + expect(result.actors).toEqual(new Set(['user1', 'user2'])) + }) + + it('returns default config on 404', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}), + } as Response) + + const feature = new Feature('test', adapter, {}) + const result = await adapter.get(feature) + + expect(result.boolean).toBeNull() + expect(result.actors).toEqual(new Set()) + }) + }) + + describe('getMulti', () => { + it('gets multiple features', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + features: [ + { + key: 'feature1', + gates: [{ key: 'boolean', value: true }], + }, + { + key: 'feature2', + gates: [{ key: 'actors', value: ['user1'] }], + }, + ], + }), + } as Response) + + const feature1 = new Feature('feature1', adapter, {}) + const feature2 = new Feature('feature2', adapter, {}) + const result = await adapter.getMulti([feature1, feature2]) + + expect(result['feature1']?.boolean).toBe('true') + expect(result['feature2']?.actors).toEqual(new Set(['user1'])) + }) + }) + + describe('getAll', () => { + it('gets all features', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { + get: (name: string) => (name === 'etag' ? '"abc123"' : null), + } as unknown as Headers, + json: async () => ({ + features: [ + { + key: 'feature1', + gates: [{ key: 'boolean', value: true }], + }, + ], + }), + } as Response) + + const result = await adapter.getAll() + + expect(result['feature1']?.boolean).toBe('true') + expect(adapter['lastGetAllEtag']).toBe('"abc123"') + }) + + it('sends If-None-Match on subsequent requests', async () => { + // First request - sets ETag + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { + get: (name: string) => (name === 'etag' ? '"abc123"' : null), + } as unknown as Headers, + json: async () => ({ + features: [{ key: 'feature1', gates: [] }], + }), + } as Response) + + await adapter.getAll() + + // Second request - should send If-None-Match + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { + get: () => null, + } as unknown as Headers, + json: async () => ({ + features: [{ key: 'feature1', gates: [] }], + }), + } as Response) + + await adapter.getAll() + + const secondCallHeaders = mockFetch.mock.calls[1]?.[1] + expect(secondCallHeaders).toBeDefined() + const headers = (secondCallHeaders as RequestInit)?.headers as Record + expect(headers?.['if-none-match']).toBe('"abc123"') + }) + + it('returns cached result on 304', async () => { + // First request + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { + get: (name: string) => (name === 'etag' ? '"abc123"' : null), + } as unknown as Headers, + json: async () => ({ + features: [{ key: 'feature1', gates: [{ key: 'boolean', value: true }] }], + }), + } as Response) + + const firstResult = await adapter.getAll() + + // Second request - 304 + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 304, + headers: { + get: () => null, + } as unknown as Headers, + json: async () => { + throw new Error('Should not parse body on 304') + }, + } as unknown as Response) + + const secondResult = await adapter.getAll() + + expect(secondResult).toEqual(firstResult) + }) + + it('throws error on 304 without cached result', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 304, + headers: { get: () => null } as unknown as Headers, + json: async () => ({}), + } as Response) + + await expect(adapter.getAll()).rejects.toThrow('Received 304 without cached result') + }) + }) + + describe('enable', () => { + it('enables boolean gate', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + } as Response) + + const feature = new Feature('test', adapter, {}) + const gate = feature.gates.find(g => g.key === 'boolean')! + const thing = gate.wrap(true) + + await adapter.enable(feature, gate, thing) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://test.example.com/adapter/features/test/boolean', + expect.objectContaining({ + method: 'POST', + body: '{}', + }) + ) + }) + + it('enables actor gate', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + } as Response) + + const feature = new Feature('test', adapter, {}) + const gate = feature.gates.find(g => g.key === 'actors')! + const thing = gate.wrap({ flipperId: 'user123' }) + + await adapter.enable(feature, gate, thing) + + const call = mockFetch.mock.calls[0] + expect(call?.[1]).toBeDefined() + const body = (call?.[1] as RequestInit)?.body as string + expect(JSON.parse(body)).toEqual({ flipper_id: 'user123' }) + }) + + it('enables group gate with allow_unregistered_groups', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + } as Response) + + const feature = new Feature('test', adapter, {}) + const gate = feature.gates.find(g => g.key === 'groups')! + const thing = gate.wrap('admins') + + await adapter.enable(feature, gate, thing) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://test.example.com/adapter/features/test/groups?allow_unregistered_groups=true', + expect.objectContaining({ + method: 'POST', + }) + ) + }) + + it('enables percentage gate', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + } as Response) + + const feature = new Feature('test', adapter, {}) + const gate = feature.gates.find(g => g.key === 'percentageOfActors')! + const thing = gate.wrap(25) + + await adapter.enable(feature, gate, thing) + + const call = mockFetch.mock.calls[0] + const body = (call?.[1] as RequestInit)?.body as string + expect(JSON.parse(body)).toEqual({ percentage: '25' }) + }) + + it('includes error details in exception', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 503, + json: async () => ({ + message: 'Feature limit exceeded', + more_info: 'https://example.com/docs', + }), + } as Response) + + const feature = new Feature('test', adapter, {}) + const gate = feature.gates.find(g => g.key === 'boolean')! + const thing = gate.wrap(true) + + await expect(adapter.enable(feature, gate, thing)).rejects.toThrow( + /Feature limit exceeded.*https:\/\/example\.com\/docs/s + ) + }) + }) + + describe('disable', () => { + it('disables with DELETE for most gates', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + } as Response) + + const feature = new Feature('test', adapter, {}) + const gate = feature.gates.find(g => g.key === 'actors')! + const thing = gate.wrap({ flipperId: 'user123' }) + + await adapter.disable(feature, gate, thing) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://test.example.com/adapter/features/test/actors', + expect.objectContaining({ + method: 'DELETE', + }) + ) + }) + + it('disables with POST for percentage gates', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + } as Response) + + const feature = new Feature('test', adapter, {}) + const gate = feature.gates.find(g => g.key === 'percentageOfActors')! + const thing = gate.wrap(0) + + await adapter.disable(feature, gate, thing) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://test.example.com/adapter/features/test/percentageOfActors', + expect.objectContaining({ + method: 'POST', + }) + ) + }) + }) + + describe('import', () => { + it('imports from another adapter', async () => { + const sourceAdapter = new MemoryAdapter() + const sourceFeature = new Feature('test', sourceAdapter, {}) + await sourceAdapter.add(sourceFeature) + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 204, + json: async () => ({}), + } as Response) + + await adapter.import(sourceAdapter) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://test.example.com/adapter/import', + expect.objectContaining({ + method: 'POST', + }) + ) + }) + }) + + describe('readOnly', () => { + it('returns false by default', () => { + expect(adapter.readOnly()).toBe(false) + }) + + it('returns true when read-only', () => { + const readOnlyAdapter = new HttpAdapter({ + url: 'http://test.example.com', + readOnly: true, + }) + expect(readOnlyAdapter.readOnly()).toBe(true) + }) + }) + + describe('timeout handling', () => { + it('aborts request after timeout', async () => { + // Mock a slow response + const abortError = new Error('The operation was aborted') + abortError.name = 'AbortError' + mockFetch.mockRejectedValueOnce(abortError as never) + + const shortTimeoutAdapter = new HttpAdapter({ + url: 'http://test.example.com', + timeout: 100, + }) + + await expect(shortTimeoutAdapter.features()).rejects.toThrow() + }) + }) +}) diff --git a/packages/flipper-cloud/src/HttpAdapter.ts b/packages/flipper-cloud/src/HttpAdapter.ts new file mode 100644 index 0000000..cbc156e --- /dev/null +++ b/packages/flipper-cloud/src/HttpAdapter.ts @@ -0,0 +1,543 @@ +import type { IAdapter, IGate, IType } from '@flippercloud/flipper' +import { Exporter, WriteAttemptedError } from '@flippercloud/flipper' +import type { Feature, Export, Dsl } from '@flippercloud/flipper' + +/** + * Error class for HTTP adapter failures. + */ +export class HttpError extends Error { + public statusCode: number + public response?: Response + + constructor(message: string, statusCode: number, response?: Response) { + super(message) + this.name = 'HttpError' + this.statusCode = statusCode + this.response = response + } +} + +/** + * Options for initializing the HttpAdapter. + */ +export interface HttpAdapterOptions { + /** + * The base URL for the Flipper API (e.g., 'https://www.flippercloud.io/adapter') + */ + url: string + + /** + * Optional headers to include in all requests. + */ + headers?: Record + + /** + * Optional timeout in milliseconds for requests (default: 5000). + */ + timeout?: number + + /** + * Whether the adapter is read-only (default: false). + */ + readOnly?: boolean +} + +/** + * HTTP adapter for communicating with Flipper Cloud API. + * + * Uses fetch to make HTTP requests to the Flipper API. Supports ETag caching + * for efficient get_all operations. + * + * @example + * import { HttpAdapter } from '@flippercloud/flipper-cloud' + * + * const adapter = new HttpAdapter({ + * url: 'https://www.flippercloud.io/adapter', + * headers: { + * 'flipper-cloud-token': 'your-token-here' + * } + * }) + * + * const flipper = new Flipper(adapter) + * await flipper.enable('new-feature') + */ +class HttpAdapter implements IAdapter { + /** + * The name of this adapter. + */ + public readonly name = 'http' + + /** + * The base URL for the API. + */ + private url: string + + /** + * Headers to include in all requests. + */ + private headers: Record + + /** + * Request timeout in milliseconds. + */ + private timeout: number + + /** + * Whether the adapter is read-only. + */ + private _readOnly: boolean + + /** + * Cached ETag from last get_all response. + */ + private lastGetAllEtag: string | null = null + + /** + * Cached result from last get_all response. + */ + private lastGetAllResult: Record> | null = null + + /** + * Creates a new HttpAdapter. + * @param options - Configuration options + */ + constructor(options: HttpAdapterOptions) { + this.url = options.url.replace(/\/$/, '') // Remove trailing slash + this.timeout = options.timeout ?? 5000 + this._readOnly = options.readOnly ?? false + + // Default headers + this.headers = { + 'content-type': 'application/json', + accept: 'application/json', + 'user-agent': 'Flipper HTTP Adapter (TypeScript)', + ...options.headers, + } + } + + /** + * Make an HTTP request with timeout support. + */ + private async fetch( + path: string, + options: RequestInit = {}, + additionalHeaders: Record = {} + ): Promise { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), this.timeout) + + try { + const response = await fetch(`${this.url}${path}`, { + ...options, + headers: { + ...this.headers, + ...additionalHeaders, + }, + signal: controller.signal, + }) + + return response + } finally { + clearTimeout(timeoutId) + } + } + + /** + * Handle HTTP errors and parse response body. + */ + private async handleResponse(response: Response): Promise { + if (!response.ok) { + let errorMessage = `Failed with status: ${response.status}` + + try { + const body = await response.json() + if (body && typeof body === 'object' && 'message' in body) { + errorMessage += `\n\n${String(body.message)}` + if ('more_info' in body) { + errorMessage += `\n${String(body.more_info)}` + } + } + } catch { + // If we can't parse JSON, just use status code + } + + throw new HttpError(errorMessage, response.status, response) + } + + if (response.status === 204) { + return null + } + + return await response.json() + } + + /** + * Get all features. + * @returns Array of all Feature instances + */ + async features(): Promise { + const response = await this.fetch('/features?exclude_gate_names=true') + const data = (await this.handleResponse(response)) as { + features: Array<{ key: string }> + } + + const module = await import('@flippercloud/flipper') + const Feature = module.Feature + return data.features.map(f => new Feature(f.key, this, {})) + } + + /** + * Add a feature to the adapter. + * @param feature - Feature to add + * @returns True if successful + */ + async add(feature: Feature): Promise { + this.ensureWritable() + const body = JSON.stringify({ name: feature.key }) + const response = await this.fetch('/features', { + method: 'POST', + body, + }) + await this.handleResponse(response) + return true + } + + /** + * Remove a feature from the adapter. + * @param feature - Feature to remove + * @returns True if successful + */ + async remove(feature: Feature): Promise { + this.ensureWritable() + const response = await this.fetch(`/features/${feature.key}`, { + method: 'DELETE', + }) + await this.handleResponse(response) + return true + } + + /** + * Clear all gate values for a feature. + * @param feature - Feature to clear + * @returns True if successful + */ + async clear(feature: Feature): Promise { + this.ensureWritable() + const response = await this.fetch(`/features/${feature.key}/clear`, { + method: 'DELETE', + }) + await this.handleResponse(response) + return true + } + + /** + * Get feature state from the adapter. + * @param feature - Feature to get state for + * @returns Feature gate values + */ + async get(feature: Feature): Promise> { + const response = await this.fetch(`/features/${feature.key}`) + + if (response.status === 404) { + return this.defaultConfig() + } + + const data = (await this.handleResponse(response)) as { + gates: Array<{ key: string; value: unknown }> + } + + return this.resultForFeature(feature, data.gates) + } + + /** + * Get multiple features' state from the adapter. + * + * Note: If a requested feature is not present in the cloud, it will be returned + * with default gate values (all gates null/empty). This matches Ruby HTTP adapter behavior + * and allows graceful handling of features that may be newly requested but not yet + * created in the cloud. + * + * @param features - Features to get state for + * @returns Map of feature keys to gate values + */ + async getMulti(features: Feature[]): Promise>> { + const keys = features.map(f => f.key).join(',') + const response = await this.fetch(`/features?keys=${keys}&exclude_gate_names=true`) + const data = (await this.handleResponse(response)) as { + features: Array<{ key: string; gates: Array<{ key: string; value: unknown }> }> + } + + const gatesByKey: Record> = {} + for (const feature of data.features) { + gatesByKey[feature.key] = feature.gates + } + + const result: Record> = {} + for (const feature of features) { + // If feature not in response, resultForFeature will use undefined gates and return defaults + result[feature.key] = this.resultForFeature(feature, gatesByKey[feature.key]) + } + + return result + } + + /** + * Get all features' state from the adapter with ETag caching. + * @returns Map of all feature keys to gate values + */ + async getAll(): Promise>> { + const additionalHeaders: Record = {} + + // Add If-None-Match header if we have a cached ETag + if (this.lastGetAllEtag) { + additionalHeaders['if-none-match'] = this.lastGetAllEtag + } + + const response = await this.fetch('/features?exclude_gate_names=true', {}, additionalHeaders) + + // Handle 304 Not Modified - return cached result + if (response.status === 304) { + if (this.lastGetAllResult) { + return this.lastGetAllResult + } + throw new HttpError('Received 304 without cached result', 304, response) + } + + // Store ETag from response for future requests + const etag = response.headers.get('etag') + if (etag) { + this.lastGetAllEtag = etag + } + + const data = (await this.handleResponse(response)) as { + features: Array<{ key: string; gates: Array<{ key: string; value: unknown }> }> + } + + const gatesByKey: Record> = {} + for (const feature of data.features) { + gatesByKey[feature.key] = feature.gates + } + + const result: Record> = {} + const module = await import('@flippercloud/flipper') + const Feature = module.Feature + + for (const key of Object.keys(gatesByKey)) { + const feature = new Feature(key, this, {}) + result[feature.key] = this.resultForFeature(feature, gatesByKey[feature.key]) + } + + // Cache the result for 304 responses + this.lastGetAllResult = result + return result + } + + /** + * Enable a gate for a feature. + * @param feature - Feature to enable + * @param gate - Gate to enable + * @param thing - Type with value to enable + * @returns True if successful + */ + async enable(feature: Feature, gate: IGate, thing: IType): Promise { + this.ensureWritable() + const body = this.requestBodyForGate(gate, thing.value) + const queryString = gate.key === 'groups' ? '?allow_unregistered_groups=true' : '' + const response = await this.fetch(`/features/${feature.key}/${gate.key}${queryString}`, { + method: 'POST', + body, + }) + await this.handleResponse(response) + return true + } + + /** + * Disable a gate for a feature. + * @param feature - Feature to disable + * @param gate - Gate to disable + * @param thing - Type with value to disable + * @returns True if successful + */ + async disable(feature: Feature, gate: IGate, thing: IType): Promise { + this.ensureWritable() + const body = this.requestBodyForGate(gate, thing.value) + const queryString = gate.key === 'groups' ? '?allow_unregistered_groups=true' : '' + + let method = 'DELETE' + if (gate.key === 'percentageOfActors' || gate.key === 'percentageOfTime') { + method = 'POST' + } + + const response = await this.fetch(`/features/${feature.key}/${gate.key}${queryString}`, { + method, + body, + }) + await this.handleResponse(response) + return true + } + + /** + * Check if the adapter is read-only. + * @returns True if read-only, false otherwise + */ + readOnly(): boolean { + return this._readOnly + } + + /** + * Export the adapter's features. + * @param options - Export options + * @returns Export object + */ + async export(options: { format?: string; version?: number } = {}): Promise { + const format = options.format ?? 'json' + const version = options.version ?? 1 + const exporter = Exporter.build({ format, version }) + return await exporter.call(this) + } + + /** + * Import features from another source. + * @param source - The source to import from (Dsl, Adapter, or Export) + * @returns True if successful + */ + async import(source: IAdapter | Export | Dsl): Promise { + this.ensureWritable() + const sourceAdapter = await this.getSourceAdapter(source) + const exportData = await sourceAdapter.export({ format: 'json', version: 1 }) + const response = await this.fetch('/import', { + method: 'POST', + body: exportData.contents, + }) + await this.handleResponse(response) + return true + } + + /** + * Extract an adapter from a source. + * @private + */ + private async getSourceAdapter(source: IAdapter | Export | Dsl): Promise { + if ('adapter' in source && source.adapter && typeof source.adapter !== 'function') { + return source.adapter + } + if ('adapter' in source && typeof source.adapter === 'function') { + return await source.adapter() + } + return source as IAdapter + } + + /** + * Ensure the adapter is writable. + * @private + */ + private ensureWritable(): void { + if (this._readOnly) { + throw new WriteAttemptedError() + } + } + + /** + * Build request body for a gate operation. + * @private + */ + private requestBodyForGate(gate: IGate, value: boolean | number | string): string { + let data: Record + + switch (gate.key) { + case 'boolean': + data = {} + break + case 'groups': + data = { name: String(value) } + break + case 'actors': + data = { flipper_id: String(value) } + break + case 'percentageOfActors': + case 'percentageOfTime': + data = { percentage: String(value) } + break + case 'expression': + data = typeof value === 'object' ? (value as Record) : {} + break + default: + throw new Error(`${gate.key} is not a valid flipper gate key`) + } + + return JSON.stringify(data) + } + + /** + * Convert API gate response to adapter format. + * @private + */ + private resultForFeature( + feature: Feature, + apiGates?: Array<{ key: string; value: unknown }> + ): Record { + const result = this.defaultConfig() + + if (!apiGates) { + return result + } + + for (const gate of feature.gates) { + const apiGate = apiGates.find(ag => ag.key === gate.key) + if (apiGate) { + result[gate.key] = this.valueForGate(gate, apiGate.value) + } + } + + return result + } + + /** + * Convert API gate value to adapter format. + * + * For boolean/integer/number gates: converts truthy values to strings + * (matching Ruby HTTP adapter behavior for consistency across Flipper SDKs). + * Null/undefined values are returned as-is. This string conversion ensures + * consistent serialization across platforms and API boundaries. + * + * @private + */ + private valueForGate(gate: IGate, value: unknown): unknown { + switch (gate.dataType) { + case 'boolean': + case 'integer': + case 'number': + // Following Ruby HTTP adapter: convert truthy values to strings + if (value === null || value === undefined) { + return value + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + return value + case 'json': + return value + case 'set': + return value && Array.isArray(value) ? new Set(value as string[]) : new Set() + default: + throw new Error(`${gate.dataType} is not supported by this adapter`) + } + } + + /** + * Get default config for a feature. + * @private + */ + private defaultConfig(): Record { + return { + boolean: null, + groups: new Set(), + actors: new Set(), + expression: null, + percentageOfActors: null, + percentageOfTime: null, + } + } +} + +export default HttpAdapter diff --git a/packages/flipper-cloud/src/Poller.test.ts b/packages/flipper-cloud/src/Poller.test.ts new file mode 100644 index 0000000..e990e6a --- /dev/null +++ b/packages/flipper-cloud/src/Poller.test.ts @@ -0,0 +1,362 @@ +import Poller from './Poller' +import { MemoryAdapter, Feature, IAdapter } from '@flippercloud/flipper' + +describe('Poller', () => { + let remoteAdapter: MemoryAdapter + let poller: Poller + + beforeEach(() => { + remoteAdapter = new MemoryAdapter() + }) + + afterEach(() => { + poller?.stop() + }) + + describe('constructor', () => { + it('creates a poller with default interval', () => { + poller = new Poller({ + remoteAdapter, + startAutomatically: false, + }) + + expect(poller.adapter).toBeInstanceOf(MemoryAdapter) + expect(poller.lastSyncedAt).toBe(0) + }) + + it('enforces minimum interval', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + + poller = new Poller({ + remoteAdapter, + interval: 5000, // Below minimum + startAutomatically: false, + }) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('poll interval must be >= 10000ms') + ) + + consoleWarnSpy.mockRestore() + }) + + it('accepts custom interval above minimum', () => { + poller = new Poller({ + remoteAdapter, + interval: 30000, + startAutomatically: false, + }) + + expect(poller).toBeDefined() + }) + + it('starts automatically by default', async () => { + // Add a feature to remote adapter + const feature = new Feature('test-feature', remoteAdapter, {}) + await remoteAdapter.add(feature) + + poller = new Poller({ + remoteAdapter, + interval: 10000, + }) + + // Wait briefly for initial sync to complete + await new Promise(resolve => setTimeout(resolve, 50)) + + // Local adapter should have the feature + const features = await poller.adapter.features() + expect(features.length).toBe(1) + expect(features[0]?.name).toBe('test-feature') + }) + + it('does not start automatically when disabled', async () => { + // Add a feature to remote adapter + const feature = new Feature('test-feature', remoteAdapter, {}) + await remoteAdapter.add(feature) + + poller = new Poller({ + remoteAdapter, + interval: 10000, + startAutomatically: false, + }) + + // Wait briefly - should not sync since not started + await new Promise(resolve => setTimeout(resolve, 50)) + + // Local adapter should NOT have the feature yet + const features = await poller.adapter.features() + expect(features.length).toBe(0) + }) + }) + + describe('start', () => { + it('starts polling', async () => { + poller = new Poller({ + remoteAdapter, + startAutomatically: false, + }) + + // Add feature to remote + const feature = new Feature('test-feature', remoteAdapter, {}) + await remoteAdapter.add(feature) + + poller.start() + + // Wait for initial sync + await new Promise(resolve => setTimeout(resolve, 50)) + + const features = await poller.adapter.features() + expect(features.length).toBe(1) + }) + + it('is safe to call multiple times', () => { + poller = new Poller({ + remoteAdapter, + startAutomatically: false, + }) + + poller.start() + poller.start() + poller.start() + + // Should not throw + expect(poller).toBeDefined() + }) + }) + + describe('stop', () => { + it('stops polling', async () => { + poller = new Poller({ + remoteAdapter, + interval: 100, // Short interval for testing + startAutomatically: false, + }) + + poller.start() + await new Promise(resolve => setTimeout(resolve, 50)) + poller.stop() + + // Add feature after stopping + const feature = new Feature('new-feature', remoteAdapter, {}) + await remoteAdapter.add(feature) + + // Wait longer than interval - should NOT sync + await new Promise(resolve => setTimeout(resolve, 150)) + + // Should not have synced the new feature + const features = await poller.adapter.features() + expect(features.length).toBe(0) + }) + + it('is safe to call multiple times', () => { + poller = new Poller({ + remoteAdapter, + startAutomatically: false, + }) + + poller.stop() + poller.stop() + poller.stop() + + // Should not throw + expect(poller).toBeDefined() + }) + }) + + describe('sync', () => { + it('imports from remote to local adapter', async () => { + poller = new Poller({ + remoteAdapter, + startAutomatically: false, + }) + + // Add features to remote + const feature1 = new Feature('feature1', remoteAdapter, {}) + const feature2 = new Feature('feature2', remoteAdapter, {}) + await remoteAdapter.add(feature1) + await remoteAdapter.add(feature2) + + // Sync manually + await poller.sync() + + // Local adapter should have both features + const features = await poller.adapter.features() + expect(features.length).toBe(2) + expect(features.map(f => f.name).sort()).toEqual(['feature1', 'feature2']) + }) + + it('updates lastSyncedAt timestamp', async () => { + poller = new Poller({ + remoteAdapter, + startAutomatically: false, + }) + + expect(poller.lastSyncedAt).toBe(0) + + const beforeSync = Date.now() + await poller.sync() + const afterSync = Date.now() + + expect(poller.lastSyncedAt).toBeGreaterThanOrEqual(beforeSync) + expect(poller.lastSyncedAt).toBeLessThanOrEqual(afterSync) + }) + + it('handles sync errors gracefully', async () => { + const errorAdapter = { + name: 'error-adapter', + features: async () => [], + get: async () => ({}), + getMulti: async () => ({}), + getAll: async () => ({}), + add: async () => true, + remove: async () => true, + clear: async () => true, + enable: async () => true, + disable: async () => true, + readOnly: () => false, + export: async () => { + throw new Error('Sync failed') + }, + import: async () => true, + } as unknown as IAdapter + + poller = new Poller({ + remoteAdapter: errorAdapter, + startAutomatically: false, + }) + + // Should throw when syncing + await expect(poller.sync()).rejects.toThrow('Sync failed') + }) + }) + + describe('lastSyncedAt', () => { + it('returns 0 initially', () => { + poller = new Poller({ + remoteAdapter, + startAutomatically: false, + }) + + expect(poller.lastSyncedAt).toBe(0) + }) + + it('updates after successful sync', async () => { + poller = new Poller({ + remoteAdapter, + startAutomatically: false, + }) + + await poller.sync() + + expect(poller.lastSyncedAt).toBeGreaterThan(0) + }) + + it('increases with each sync', async () => { + poller = new Poller({ + remoteAdapter, + startAutomatically: false, + }) + + await poller.sync() + const firstSync = poller.lastSyncedAt + + await new Promise(resolve => setTimeout(resolve, 10)) + await poller.sync() + const secondSync = poller.lastSyncedAt + + expect(secondSync).toBeGreaterThan(firstSync) + }) + }) + + describe('background polling', () => { + it('syncs periodically', async () => { + poller = new Poller({ + remoteAdapter, + interval: 10000, // Minimum allowed interval + startAutomatically: false, + }) + + // Add initial feature + const feature1 = new Feature('feature1', remoteAdapter, {}) + await remoteAdapter.add(feature1) + + poller.start() + await new Promise(resolve => setTimeout(resolve, 100)) + + // Should have synced initial feature + let features = await poller.adapter.features() + expect(features.length).toBe(1) + + // Add another feature to remote + const feature2 = new Feature('feature2', remoteAdapter, {}) + await remoteAdapter.add(feature2) + + // Manually trigger another sync (instead of waiting 10+ seconds) + await poller.sync() + + // Now should have both features + features = await poller.adapter.features() + expect(features.length).toBe(2) + expect(features.map(f => f.name).sort()).toEqual(['feature1', 'feature2']) + }) + + it('handles errors without stopping', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + let callCount = 0 + + const errorAdapter = { + name: 'error-adapter', + features: async () => [], + get: async () => ({}), + getMulti: async () => ({}), + getAll: async () => ({}), + add: async () => true, + remove: async () => true, + clear: async () => true, + enable: async () => true, + disable: async () => true, + readOnly: () => false, + export: async () => { + callCount++ + if (callCount === 1) { + throw new Error('First sync failed') + } + return { + contents: 'test', + features: () => ({}), + version: 1, + format: 'test', + adapter: 'test', + equals: () => false, + } + }, + import: async () => true, + } as unknown as IAdapter + + poller = new Poller({ + remoteAdapter: errorAdapter, + interval: 100, // Short interval for testing + }) + + // Wait for initial sync attempt (fails) + await new Promise(resolve => setTimeout(resolve, 50)) + + // Should have logged error but not crashed + expect(consoleErrorSpy).toHaveBeenCalled() + + // Wait for next poll interval - should retry and succeed + await new Promise(resolve => setTimeout(resolve, 150)) + + // Should not have thrown + expect(poller).toBeDefined() + + consoleErrorSpy.mockRestore() + }) + }) + + describe('MINIMUM_POLL_INTERVAL', () => { + it('is set to 10 seconds', () => { + expect(Poller.MINIMUM_POLL_INTERVAL).toBe(10000) + }) + }) +}) diff --git a/packages/flipper-cloud/src/Poller.ts b/packages/flipper-cloud/src/Poller.ts new file mode 100644 index 0000000..4d14c22 --- /dev/null +++ b/packages/flipper-cloud/src/Poller.ts @@ -0,0 +1,152 @@ +import { MemoryAdapter } from '@flippercloud/flipper' +import type { IAdapter } from '@flippercloud/flipper' + +export interface PollerOptions { + /** + * Remote adapter to sync from (typically HttpAdapter) + */ + remoteAdapter: IAdapter + + /** + * Polling interval in milliseconds (default: 10000) + * Minimum: 10000 (10 seconds) + */ + interval?: number + + /** + * Whether to start polling automatically (default: true) + */ + startAutomatically?: boolean +} + +/** + * Poller manages background synchronization from a remote adapter to a local adapter. + * + * The poller runs on an interval, fetching the latest feature flag state from the + * remote adapter and importing it into a local memory adapter. This local adapter + * is then used by the Poll wrapper to provide fast reads. + * + * @example + * const poller = new Poller({ + * remoteAdapter: httpAdapter, + * interval: 10000, // 10 seconds + * }) + * + * // Access the synced local adapter + * const features = await poller.adapter.features() + * + * // Stop polling when done + * poller.stop() + */ +export default class Poller { + /** + * Minimum allowed poll interval in milliseconds (10 seconds) + */ + static readonly MINIMUM_POLL_INTERVAL = 10000 + + /** + * Local memory adapter that holds synced state + */ + readonly adapter: IAdapter + + /** + * Remote adapter to sync from + */ + private readonly remoteAdapter: IAdapter + + /** + * Polling interval in milliseconds + */ + private readonly interval: number + + /** + * Interval timer ID + */ + private timer: ReturnType | null = null + + /** + * Timestamp of last successful sync (milliseconds since epoch) + */ + private _lastSyncedAt: number = 0 + + /** + * Creates a new Poller instance. + * @param options - Poller configuration + */ + constructor(options: PollerOptions) { + this.remoteAdapter = options.remoteAdapter + this.adapter = new MemoryAdapter() + + // Enforce minimum interval + let interval = options.interval ?? 10000 + if (interval < Poller.MINIMUM_POLL_INTERVAL) { + console.warn( + `Flipper Cloud poll interval must be >= ${Poller.MINIMUM_POLL_INTERVAL}ms but was ${interval}ms. ` + + `Setting interval to ${Poller.MINIMUM_POLL_INTERVAL}ms.` + ) + interval = Poller.MINIMUM_POLL_INTERVAL + } + this.interval = interval + + // Start automatically if requested + if (options.startAutomatically !== false) { + this.start() + } + } + + /** + * Gets the timestamp of the last successful sync. + * @returns Milliseconds since epoch + */ + get lastSyncedAt(): number { + return this._lastSyncedAt + } + + /** + * Starts the polling background process. + * Safe to call multiple times - will only start one timer. + */ + start(): void { + if (this.timer !== null) { + return // Already running + } + + // Run first sync immediately only when we don't already have fresh data + if (this._lastSyncedAt === 0) { + void this.sync().catch(error => { + console.error('Flipper Cloud poller initial sync error:', error) + }) + } + + // Set up interval for subsequent syncs + this.timer = setInterval(() => { + void this.sync().catch(error => { + // Silently catch errors to prevent the poller from stopping + // Users can instrument sync calls if they want to track errors + console.error('Flipper Cloud poller sync error:', error) + }) + }, this.interval) + } + + /** + * Stops the polling background process. + * Safe to call multiple times. + */ + stop(): void { + if (this.timer !== null) { + clearInterval(this.timer) + this.timer = null + } + } + + /** + * Performs a synchronous import from remote to local adapter. + * Updates the lastSyncedAt timestamp on success. + * @returns Promise that resolves when sync is complete + */ + async sync(): Promise { + const exportData = await this.remoteAdapter.export({ format: 'json', version: 1 }) + await this.adapter.import(exportData) + this._lastSyncedAt = Date.now() + } +} diff --git a/packages/flipper-cloud/src/index.ts b/packages/flipper-cloud/src/index.ts new file mode 100644 index 0000000..877e513 --- /dev/null +++ b/packages/flipper-cloud/src/index.ts @@ -0,0 +1,8 @@ +export { default as HttpAdapter, HttpError } from './HttpAdapter' +export type { HttpAdapterOptions } from './HttpAdapter' + +export { default as Poller } from './Poller' +export type { PollerOptions } from './Poller' + +export { FlipperCloud } from './Cloud' +export type { CloudConfiguration, CloudFlipper } from './Cloud' diff --git a/packages/flipper-cloud/tsconfig.json b/packages/flipper-cloud/tsconfig.json new file mode 100644 index 0000000..954ae52 --- /dev/null +++ b/packages/flipper-cloud/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist/esm", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/flipper/src/MemoryAdapter.ts b/packages/flipper/src/MemoryAdapter.ts index 64453dd..29b366d 100644 --- a/packages/flipper/src/MemoryAdapter.ts +++ b/packages/flipper/src/MemoryAdapter.ts @@ -260,11 +260,13 @@ class MemoryAdapter implements IAdapter { public async disable(feature: Feature, gate: IGate, thing: IType): Promise { switch (gate.dataType) { case 'boolean': { - await this.clear(feature) + // For boolean gate, just delete the boolean key (don't clear entire feature) + this.delete(this.key(feature, gate)) break } case 'number': { - await this.clear(feature) + // For number gate, just delete the number key (don't clear entire feature) + this.delete(this.key(feature, gate)) break } case 'set': {