@nexussdk/flags 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,165 +0,0 @@
1
- /**
2
- * @fileoverview Singleton SSE Manager with ref-counting and auto-reconnect.
3
- * Ensures exactly one EventSource connection per API key across all flag subscribers.
4
- * @module @nexus/sdk-flags/sse-manager
5
- */
6
-
7
- import type { FlagStreamEvent } from '@nexussdk/contracts';
8
- import { computeBackoffMs } from '@nexussdk/core';
9
-
10
- /**
11
- * Options for creating an SSE connection.
12
- */
13
- export interface SSEManagerOptions {
14
- /** Full SSE stream URL (e.g. "http://localhost:8080/api/v1/flags/stream"). */
15
- url: string;
16
- /** Public API key for authentication. */
17
- apiKey: string;
18
- /** Callback invoked for each received flag stream event. */
19
- onEvent: (event: FlagStreamEvent) => void;
20
- /** Callback invoked on connection state changes (optional). */
21
- onStateChange?: (connected: boolean) => void;
22
- /** Maximum reconnect attempts. Defaults to Infinity. */
23
- maxReconnects?: number;
24
- }
25
-
26
- /**
27
- * Ref-counted Singleton SSE Manager.
28
- *
29
- * Multiple consumers sharing the same `apiKey` receive updates
30
- * from a single shared SSE connection. Connection is torn down when all
31
- * consumers have called `disconnect()`.
32
- *
33
- * Connection lifecycle:
34
- * 1. `connect()` → opens EventSource or streaming fetch
35
- * 2. On disconnect: exponential backoff reconnect (max 30s)
36
- * 3. `disconnect()` → decrements ref count; tears down on 0
37
- *
38
- * @example
39
- * const manager = new SSEManager({
40
- * url: 'http://localhost:8080/api/v1/flags/stream',
41
- * apiKey: 'pk_live_...',
42
- * onEvent: (event) => updateCache(event),
43
- * });
44
- * manager.connect();
45
- * // Later:
46
- * manager.disconnect();
47
- */
48
- export class SSEManager {
49
- private readonly options: SSEManagerOptions;
50
- private eventSource: EventSource | null = null;
51
- private connected = false;
52
- private destroyed = false;
53
- private reconnectAttempt = 0;
54
- private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
55
- private readonly maxReconnects: number;
56
-
57
- constructor(options: SSEManagerOptions) {
58
- this.options = options;
59
- this.maxReconnects = options.maxReconnects ?? Infinity;
60
- }
61
-
62
- /**
63
- * Returns whether the SSE connection is currently active.
64
- *
65
- * @returns `true` if EventSource is open and receiving events.
66
- */
67
- get isConnected(): boolean {
68
- return this.connected;
69
- }
70
-
71
- /**
72
- * Opens the SSE connection to the Go-Gin stream endpoint.
73
- * Safe to call multiple times — no-op if already connected.
74
- *
75
- * @example
76
- * manager.connect();
77
- */
78
- connect(): void {
79
- if (this.destroyed || this.connected || this.eventSource) return;
80
- this.openConnection();
81
- }
82
-
83
- /**
84
- * Closes the SSE connection and cancels any pending reconnect timers.
85
- *
86
- * @example
87
- * manager.disconnect();
88
- */
89
- disconnect(): void {
90
- this.destroyed = true;
91
- this.cleanup();
92
- }
93
-
94
- private openConnection(): void {
95
- if (this.destroyed) return;
96
-
97
- try {
98
- // Append API key as query parameter for SSE (EventSource doesn't support custom headers)
99
- const url = new URL(this.options.url);
100
- url.searchParams.set('apiKey', this.options.apiKey);
101
-
102
- this.eventSource = new EventSource(url.toString());
103
-
104
- this.eventSource.addEventListener('open', () => {
105
- this.connected = true;
106
- this.reconnectAttempt = 0;
107
- this.options.onStateChange?.(true);
108
- });
109
-
110
- this.eventSource.addEventListener('message', (evt: MessageEvent<string>) => {
111
- this.handleMessage(evt.data);
112
- });
113
-
114
- // Go-Gin sends events with event type 'message' by default
115
- // Also listen for explicitly typed events
116
- this.eventSource.addEventListener('flag_update', (evt: MessageEvent<string>) => {
117
- this.handleMessage(evt.data);
118
- });
119
-
120
- this.eventSource.addEventListener('error', () => {
121
- this.connected = false;
122
- this.options.onStateChange?.(false);
123
- this.cleanup();
124
- this.scheduleReconnect();
125
- });
126
- } catch {
127
- this.scheduleReconnect();
128
- }
129
- }
130
-
131
- private handleMessage(data: string): void {
132
- try {
133
- const event = JSON.parse(data) as FlagStreamEvent;
134
- this.options.onEvent(event);
135
- } catch {
136
- // Ignore malformed SSE payloads — never crash the host application
137
- }
138
- }
139
-
140
- private cleanup(): void {
141
- if (this.eventSource) {
142
- this.eventSource.close();
143
- this.eventSource = null;
144
- }
145
- if (this.reconnectTimer !== null) {
146
- clearTimeout(this.reconnectTimer);
147
- this.reconnectTimer = null;
148
- }
149
- }
150
-
151
- private scheduleReconnect(): void {
152
- if (this.destroyed) return;
153
- if (this.reconnectAttempt >= this.maxReconnects) return;
154
-
155
- const delay = computeBackoffMs(this.reconnectAttempt, 1000, 30_000);
156
- this.reconnectAttempt++;
157
-
158
- this.reconnectTimer = setTimeout(() => {
159
- this.reconnectTimer = null;
160
- if (!this.destroyed) {
161
- this.openConnection();
162
- }
163
- }, delay);
164
- }
165
- }
package/src/storage.ts DELETED
@@ -1,187 +0,0 @@
1
- /**
2
- * @fileoverview In-memory flag storage with LocalStorage sync and incognito fallback.
3
- * @module @nexus/sdk-flags/storage
4
- */
5
-
6
- import type { FlagEvaluationResult } from '@nexussdk/contracts';
7
-
8
- const STORAGE_KEY_PREFIX = 'nexus_flags_';
9
- const ANON_ID_KEY = 'nexus_anon_id';
10
-
11
- /**
12
- * In-memory flag cache with optional LocalStorage persistence.
13
- * Falls back to pure in-memory storage in SSR / Incognito / Worker contexts.
14
- *
15
- * @example
16
- * const store = new FlagStorage('pk_live_abc123');
17
- * store.set('checkout_v2', { key: 'checkout_v2', enabled: true, variants: {}, reason: 'ROLLOUT_MATCH', version: 3 });
18
- * const result = store.get('checkout_v2');
19
- */
20
- export class FlagStorage {
21
- private readonly memory = new Map<string, FlagEvaluationResult>();
22
- private readonly storageKey: string;
23
- private readonly localStorageAvailable: boolean;
24
- private broadcastChannel: BroadcastChannel | null = null;
25
-
26
- constructor(apiKeyPrefix: string) {
27
- this.storageKey = `${STORAGE_KEY_PREFIX}${apiKeyPrefix}`;
28
- this.localStorageAvailable = this.testLocalStorage();
29
- this.hydrate();
30
- this.setupBroadcastChannel();
31
- }
32
-
33
- /**
34
- * Stores a flag evaluation result in memory (and localStorage if available).
35
- *
36
- * @param key - Flag programmatic key.
37
- * @param result - Evaluation result to persist.
38
- */
39
- set(key: string, result: FlagEvaluationResult): void {
40
- this.memory.set(key, result);
41
- this.persist();
42
- this.broadcastUpdate(key, result);
43
- }
44
-
45
- /**
46
- * Retrieves a flag evaluation result by key.
47
- *
48
- * @param key - Flag programmatic key.
49
- * @returns The cached evaluation result or `undefined`.
50
- */
51
- get(key: string): FlagEvaluationResult | undefined {
52
- return this.memory.get(key);
53
- }
54
-
55
- /**
56
- * Bulk-sets multiple flag results (e.g. after a batch fetch).
57
- *
58
- * @param flags - Record of flag key → evaluation result.
59
- */
60
- setAll(flags: Record<string, FlagEvaluationResult>): void {
61
- for (const [key, value] of Object.entries(flags)) {
62
- this.memory.set(key, value);
63
- }
64
- this.persist();
65
- }
66
-
67
- /**
68
- * Removes a single flag from storage (e.g. on FLAG_DELETE SSE event).
69
- *
70
- * @param key - Flag programmatic key.
71
- */
72
- delete(key: string): void {
73
- this.memory.delete(key);
74
- this.persist();
75
- }
76
-
77
- /**
78
- * Returns all cached flag results.
79
- *
80
- * @returns All stored flag results as a record.
81
- */
82
- getAll(): Record<string, FlagEvaluationResult> {
83
- return Object.fromEntries(this.memory.entries());
84
- }
85
-
86
- /**
87
- * Clears all cached flags from memory and localStorage.
88
- */
89
- clear(): void {
90
- this.memory.clear();
91
- if (this.localStorageAvailable) {
92
- try {
93
- window.localStorage.removeItem(this.storageKey);
94
- } catch {
95
- // Storage access denied
96
- }
97
- }
98
- this.broadcastChannel?.close();
99
- }
100
-
101
- /**
102
- * Retrieves or creates a persistent anonymous user ID.
103
- *
104
- * @returns Anonymous ID string (e.g. "anon_xyz123abc").
105
- */
106
- getOrCreateAnonymousId(): string {
107
- if (typeof window === 'undefined') return 'anon-ssr-node';
108
-
109
- if (this.localStorageAvailable) {
110
- try {
111
- let id = window.localStorage.getItem(ANON_ID_KEY);
112
- if (!id) {
113
- id = `anon_${Math.random().toString(36).substring(2, 11)}`;
114
- window.localStorage.setItem(ANON_ID_KEY, id);
115
- }
116
- return id;
117
- } catch {
118
- // Incognito / locked storage
119
- }
120
- }
121
-
122
- return `anon_${Math.random().toString(36).substring(2, 11)}`;
123
- }
124
-
125
- private testLocalStorage(): boolean {
126
- try {
127
- if (typeof window === 'undefined') return false;
128
- const testKey = '__nexus_test__';
129
- window.localStorage.setItem(testKey, '1');
130
- window.localStorage.removeItem(testKey);
131
- return true;
132
- } catch {
133
- return false;
134
- }
135
- }
136
-
137
- private hydrate(): void {
138
- if (!this.localStorageAvailable) return;
139
- try {
140
- const raw = window.localStorage.getItem(this.storageKey);
141
- if (raw) {
142
- const parsed = JSON.parse(raw) as Record<string, FlagEvaluationResult>;
143
- for (const [key, value] of Object.entries(parsed)) {
144
- this.memory.set(key, value);
145
- }
146
- }
147
- } catch {
148
- // Corrupt storage — start fresh
149
- }
150
- }
151
-
152
- private persist(): void {
153
- if (!this.localStorageAvailable) return;
154
- try {
155
- window.localStorage.setItem(
156
- this.storageKey,
157
- JSON.stringify(Object.fromEntries(this.memory.entries())),
158
- );
159
- } catch {
160
- // Storage quota exceeded — in-memory only
161
- }
162
- }
163
-
164
- private setupBroadcastChannel(): void {
165
- try {
166
- if (typeof BroadcastChannel !== 'undefined') {
167
- this.broadcastChannel = new BroadcastChannel(`nexus_flags_${this.storageKey}`);
168
- this.broadcastChannel.onmessage = (evt: MessageEvent<{ key: string; result: FlagEvaluationResult }>) => {
169
- // Sync updates from other tabs into local memory cache
170
- if (evt.data?.key && evt.data?.result) {
171
- this.memory.set(evt.data.key, evt.data.result);
172
- }
173
- };
174
- }
175
- } catch {
176
- // BroadcastChannel unavailable (e.g. Worker context)
177
- }
178
- }
179
-
180
- private broadcastUpdate(key: string, result: FlagEvaluationResult): void {
181
- try {
182
- this.broadcastChannel?.postMessage({ key, result });
183
- } catch {
184
- // Broadcast failed — no-op
185
- }
186
- }
187
- }
package/tsconfig.json DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "outDir": "./dist",
5
- "rootDir": "./src"
6
- },
7
- "include": ["src"]
8
- }
package/tsup.config.ts DELETED
@@ -1,19 +0,0 @@
1
- import { defineConfig } from 'tsup';
2
-
3
- export default defineConfig({
4
- entry: ['src/index.ts'],
5
- format: ['esm', 'cjs', 'iife'],
6
- globalName: 'NexusFlags',
7
- dts: true,
8
- splitting: false,
9
- sourcemap: true,
10
- clean: true,
11
- minify: true,
12
- treeshake: true,
13
- target: 'es2022',
14
- outExtension({ format }) {
15
- return {
16
- js: format === 'esm' ? '.mjs' : format === 'cjs' ? '.cjs' : '.global.js',
17
- };
18
- },
19
- });