pp-flags 1.0.0

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.
@@ -0,0 +1,232 @@
1
+ import { jest } from '@jest/globals';
2
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
3
+
4
+ // Mock posthog-js with config structure for initialization check
5
+ const mockPostHog = {
6
+ config: {
7
+ api_key: 'test-api-key',
8
+ },
9
+ isFeatureEnabled: jest.fn((flagName) => {
10
+ const mockFlags = {
11
+ 'test-feature': true,
12
+ 'disabled-feature': false,
13
+ };
14
+ return mockFlags[flagName] || false;
15
+ }),
16
+ };
17
+
18
+ jest.unstable_mockModule('posthog-js', () => ({
19
+ default: mockPostHog,
20
+ }));
21
+
22
+ // Mock fetch globally
23
+ global.fetch = jest.fn();
24
+
25
+ // Mock window for browser environment
26
+ global.window = {};
27
+
28
+ /**
29
+ * Helper: set up fetch mock to return a JSON version response,
30
+ * import a fresh module, configure it, and wait for version to load.
31
+ */
32
+ async function importModuleWithVersion(version) {
33
+ global.fetch.mockResolvedValueOnce({
34
+ ok: true,
35
+ json: async () => ({ version }),
36
+ });
37
+
38
+ const mod = await import(`../index.js?t=${Date.now()}-${Math.random()}`);
39
+
40
+ // Configure with a fake site API URL — this triggers version loading
41
+ // Awaiting configure() ensures the version is fully loaded before returning
42
+ await mod.configure({ siteApiBaseUrl: 'http://localhost:3003/a' });
43
+
44
+ return mod;
45
+ }
46
+
47
+ async function importModuleWithFailedFetch() {
48
+ global.fetch.mockResolvedValueOnce({ ok: false });
49
+
50
+ const mod = await import(`../index.js?t=${Date.now()}-${Math.random()}`);
51
+
52
+ await mod.configure({ siteApiBaseUrl: 'http://localhost:3003/a' });
53
+
54
+ return mod;
55
+ }
56
+
57
+ describe('isFeatureEnabled', () => {
58
+ beforeEach(() => {
59
+ jest.clearAllMocks();
60
+ global.fetch.mockClear();
61
+
62
+ // Reset PostHog mock
63
+ mockPostHog.config = { api_key: 'test-api-key' };
64
+ mockPostHog.isFeatureEnabled.mockImplementation((flagName) => {
65
+ const mockFlags = {
66
+ 'test-feature': true,
67
+ 'disabled-feature': false,
68
+ };
69
+ return mockFlags[flagName] || false;
70
+ });
71
+ });
72
+
73
+ afterEach(() => {
74
+ jest.clearAllMocks();
75
+ });
76
+
77
+ describe('PostHog feature flags (ph:)', () => {
78
+ let isFeatureEnabled;
79
+
80
+ beforeEach(async () => {
81
+ const mod = await import(`../index.js?t=${Date.now()}-${Math.random()}`);
82
+ isFeatureEnabled = mod.isFeatureEnabled;
83
+ });
84
+
85
+ it('should return true for enabled PostHog feature', () => {
86
+ mockPostHog.isFeatureEnabled.mockReturnValue(true);
87
+
88
+ const result = isFeatureEnabled('ph:test-feature');
89
+ expect(result).toBe(true);
90
+ });
91
+
92
+ it('should return false for disabled PostHog feature', () => {
93
+ mockPostHog.isFeatureEnabled.mockReturnValue(false);
94
+
95
+ const result = isFeatureEnabled('ph:disabled-feature');
96
+ expect(result).toBe(false);
97
+ });
98
+
99
+ it('should return false if PostHog is not initialized', async () => {
100
+ const originalConfig = mockPostHog.config;
101
+ mockPostHog.config = null;
102
+
103
+ const freshModule = await import(`../index.js?t=${Date.now()}-uninit`);
104
+ const freshIsFeatureEnabled = freshModule.isFeatureEnabled;
105
+
106
+ const result = freshIsFeatureEnabled('ph:test-feature');
107
+ expect(result).toBe(false);
108
+
109
+ mockPostHog.config = originalConfig;
110
+ });
111
+
112
+ it('should return false for empty PostHog flag name', () => {
113
+ const result = isFeatureEnabled('ph:');
114
+ expect(result).toBe(false);
115
+ });
116
+ });
117
+
118
+ describe('nf-ext feature flags', () => {
119
+ it('should return true if feature exists and is enabled in current version', async () => {
120
+ // Version 1.0.2 has "paapi" enabled in nfExtensions
121
+ const mod = await importModuleWithVersion('1.0.2');
122
+
123
+ const result = mod.isFeatureEnabled('nf-ext:paapi');
124
+ expect(result).toBe(true);
125
+ expect(global.fetch).toHaveBeenCalledWith('http://localhost:3003/a/nf-ext/version');
126
+ });
127
+
128
+ it('should return false if feature does not exist in current version', async () => {
129
+ const mod = await importModuleWithVersion('1.0.2');
130
+
131
+ const result = mod.isFeatureEnabled('nf-ext:nonexistent-feature');
132
+ expect(result).toBe(false);
133
+ });
134
+
135
+ it('should return false if version does not exist in nfExtensions', async () => {
136
+ const mod = await importModuleWithVersion('1.0.99');
137
+
138
+ const result = mod.isFeatureEnabled('nf-ext:paapi');
139
+ expect(result).toBe(false);
140
+ });
141
+
142
+ it('should use cached version on subsequent calls', async () => {
143
+ const mod = await importModuleWithVersion('1.0.2');
144
+
145
+ const result1 = mod.isFeatureEnabled('nf-ext:paapi');
146
+ expect(result1).toBe(true);
147
+ const fetchCallCount = global.fetch.mock.calls.length;
148
+
149
+ // Second call should use cache (no new fetch)
150
+ const result2 = mod.isFeatureEnabled('nf-ext:paapi');
151
+ expect(result2).toBe(true);
152
+ expect(global.fetch.mock.calls.length).toBe(fetchCallCount);
153
+ });
154
+
155
+ it('should return false if fetch fails', async () => {
156
+ const mod = await importModuleWithFailedFetch();
157
+
158
+ const result = mod.isFeatureEnabled('nf-ext:paapi');
159
+ expect(result).toBe(false);
160
+ });
161
+
162
+ it('should return false for empty feature name', async () => {
163
+ const mod = await importModuleWithVersion('1.0.2');
164
+
165
+ const result = mod.isFeatureEnabled('nf-ext:');
166
+ expect(result).toBe(false);
167
+ });
168
+
169
+ it('should return false if configure() was not called', async () => {
170
+ // Import without calling configure — no siteApiBaseUrl set
171
+ const mod = await import(`../index.js?t=${Date.now()}-noconfig`);
172
+
173
+ const result = mod.isFeatureEnabled('nf-ext:paapi');
174
+ expect(result).toBe(false);
175
+ });
176
+
177
+ it('should return true if specified version >= current version (nf-ext:version:XXX)', async () => {
178
+ const mod = await importModuleWithVersion('1.0.3');
179
+
180
+ // Current version is 1.0.3
181
+ expect(mod.isFeatureEnabled('nf-ext:version:1.0.3')).toBe(true); // 1.0.3 >= 1.0.3
182
+ expect(mod.isFeatureEnabled('nf-ext:version:1.0.4')).toBe(true); // 1.0.4 >= 1.0.3
183
+ expect(mod.isFeatureEnabled('nf-ext:version:1.0.2')).toBe(false); // 1.0.2 < 1.0.3
184
+ });
185
+
186
+ it('should handle multi-part version numbers correctly', async () => {
187
+ const mod = await importModuleWithVersion('1.2.5');
188
+
189
+ expect(mod.isFeatureEnabled('nf-ext:version:1.2.5')).toBe(true); // 1.2.5 >= 1.2.5
190
+ expect(mod.isFeatureEnabled('nf-ext:version:1.2.6')).toBe(true); // 1.2.6 >= 1.2.5
191
+ expect(mod.isFeatureEnabled('nf-ext:version:1.3.0')).toBe(true); // 1.3.0 >= 1.2.5
192
+ expect(mod.isFeatureEnabled('nf-ext:version:1.2.4')).toBe(false); // 1.2.4 < 1.2.5
193
+ expect(mod.isFeatureEnabled('nf-ext:version:1.1.9')).toBe(false); // 1.1.9 < 1.2.5
194
+ });
195
+
196
+ it('should return false for empty version in nf-ext:version:', async () => {
197
+ const mod = await importModuleWithVersion('1.0.2');
198
+
199
+ const result = mod.isFeatureEnabled('nf-ext:version:');
200
+ expect(result).toBe(false);
201
+ });
202
+ });
203
+
204
+ describe('Invalid inputs', () => {
205
+ let isFeatureEnabled;
206
+
207
+ beforeEach(async () => {
208
+ const mod = await import(`../index.js?t=${Date.now()}-${Math.random()}`);
209
+ isFeatureEnabled = mod.isFeatureEnabled;
210
+ });
211
+
212
+ it('should return false for null input', () => {
213
+ expect(isFeatureEnabled(null)).toBe(false);
214
+ });
215
+
216
+ it('should return false for undefined input', () => {
217
+ expect(isFeatureEnabled(undefined)).toBe(false);
218
+ });
219
+
220
+ it('should return false for non-string input', () => {
221
+ expect(isFeatureEnabled(123)).toBe(false);
222
+ });
223
+
224
+ it('should return false for string without colon', () => {
225
+ expect(isFeatureEnabled('invalid-format')).toBe(false);
226
+ });
227
+
228
+ it('should return false for unknown prefix', () => {
229
+ expect(isFeatureEnabled('unknown:value')).toBe(false);
230
+ });
231
+ });
232
+ });
package/index.js ADDED
@@ -0,0 +1,295 @@
1
+ // Import posthog - it will be initialized by the app before use
2
+ import posthog from 'posthog-js';
3
+
4
+ /**
5
+ * Cached PostHog client (checked at module initialization)
6
+ */
7
+ let cachedPostHogClient = null;
8
+
9
+ /**
10
+ * Initialize PostHog client check (called at module load)
11
+ */
12
+ function initializePostHogClient() {
13
+ if (typeof window === 'undefined') {
14
+ return;
15
+ }
16
+
17
+ // Check if PostHog is initialized (has config with api_key)
18
+ if (posthog && posthog.config && posthog.config.api_key) {
19
+ cachedPostHogClient = posthog;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Get PostHog client (synchronous, returns cached value)
25
+ * @returns {object|null} - PostHog client if available and initialized, null otherwise
26
+ */
27
+ function getPostHogClient() {
28
+ return cachedPostHogClient;
29
+ }
30
+
31
+ // Check PostHog initialization when module loads
32
+ initializePostHogClient();
33
+
34
+ // Also check periodically in case PostHog initializes later
35
+ if (typeof window !== 'undefined') {
36
+ // Check again after a short delay in case PostHog initializes asynchronously
37
+ setTimeout(() => {
38
+ initializePostHogClient();
39
+ }, 100);
40
+ }
41
+
42
+ const nfExtensions = {
43
+ "1.0.2": {
44
+ "paapi": true,
45
+ "amazon-scrape": false,
46
+ "product-boxes": true
47
+ },
48
+ "1.0.1": {
49
+ "amazon-scrape": true
50
+ }
51
+ };
52
+
53
+ /**
54
+ * Configured site API base URL (set via configure())
55
+ */
56
+ let siteApiBaseUrl = null;
57
+
58
+ /**
59
+ * Cached current extension version (loaded at module initialization)
60
+ */
61
+ let cachedExtensionVersion = null;
62
+
63
+ /**
64
+ * Promise that resolves when the extension version is loaded
65
+ */
66
+ let versionLoadPromise = null;
67
+
68
+ /**
69
+ * Get the version load promise (for testing purposes)
70
+ * @returns {Promise<string|null>|null} - The version load promise or null
71
+ */
72
+ export function getVersionLoadPromise() {
73
+ return versionLoadPromise;
74
+ }
75
+
76
+ /**
77
+ * Compare two semantic versions
78
+ * @param {string} version1 - First version to compare
79
+ * @param {string} version2 - Second version to compare
80
+ * @returns {number} - Returns 1 if version1 > version2, -1 if version1 < version2, 0 if equal
81
+ */
82
+ function compareVersions(version1, version2) {
83
+ const v1Parts = version1.split('.').map(Number);
84
+ const v2Parts = version2.split('.').map(Number);
85
+
86
+ // Pad shorter version with zeros
87
+ const maxLength = Math.max(v1Parts.length, v2Parts.length);
88
+ while (v1Parts.length < maxLength) v1Parts.push(0);
89
+ while (v2Parts.length < maxLength) v2Parts.push(0);
90
+
91
+ for (let i = 0; i < maxLength; i++) {
92
+ if (v1Parts[i] > v2Parts[i]) return 1;
93
+ if (v1Parts[i] < v2Parts[i]) return -1;
94
+ }
95
+
96
+ return 0;
97
+ }
98
+
99
+ /**
100
+ * Load the current extension version from the site API (async)
101
+ * The site API proxies the request to Google's update server to avoid CORS issues.
102
+ * @returns {Promise<string|null>} - The version string or null if not found
103
+ */
104
+ async function loadExtensionVersion() {
105
+ try {
106
+ if (!siteApiBaseUrl) {
107
+ return null;
108
+ }
109
+
110
+ const response = await fetch(`${siteApiBaseUrl}/nf-ext/version`);
111
+ if (!response.ok) {
112
+ return null;
113
+ }
114
+
115
+ const data = await response.json();
116
+ return data.version || null;
117
+ } catch (error) {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Get the current extension version (synchronous, returns cached value)
124
+ * @returns {string|null} - The version string or null if not loaded yet
125
+ */
126
+ function getCurrentExtensionVersion() {
127
+ return cachedExtensionVersion;
128
+ }
129
+
130
+ /**
131
+ * Initialize extension version loading (called after configure())
132
+ * Fetches the extension version from the site API.
133
+ */
134
+ function initializeExtensionVersion() {
135
+ if (typeof window === 'undefined') {
136
+ // Server-side, skip loading
137
+ return;
138
+ }
139
+
140
+ // Only fetch from site API if configured
141
+ if (!siteApiBaseUrl) {
142
+ return;
143
+ }
144
+
145
+ if (versionLoadPromise) {
146
+ // Already loading or loaded
147
+ return;
148
+ }
149
+
150
+ versionLoadPromise = loadExtensionVersion().then(version => {
151
+ cachedExtensionVersion = version;
152
+ return version;
153
+ }).catch(() => {
154
+ cachedExtensionVersion = null;
155
+ return null;
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Configure the flags package with required settings.
161
+ * Must be called (and awaited) before nf-ext flags will work.
162
+ *
163
+ * @example
164
+ * ```js
165
+ * import { configure } from 'pp-flags';
166
+ * await configure({ siteApiBaseUrl: 'http://localhost:3003/a' });
167
+ * // Now isFeatureEnabled('nf-ext:...') will have the version loaded
168
+ * ```
169
+ *
170
+ * @param {Object} options - Configuration options
171
+ * @param {string} options.siteApiBaseUrl - The base URL for the site API (e.g., 'http://localhost:3003/a')
172
+ * @returns {Promise<string|null>} - Resolves with the loaded version, or null if loading failed
173
+ */
174
+ export async function configure({ siteApiBaseUrl: url }) {
175
+ if (url) {
176
+ siteApiBaseUrl = url;
177
+ }
178
+
179
+ // Trigger version loading now that we have the URL
180
+ initializeExtensionVersion();
181
+
182
+ // Wait for version to finish loading before returning
183
+ if (versionLoadPromise) {
184
+ return versionLoadPromise;
185
+ }
186
+
187
+ return null;
188
+ }
189
+
190
+
191
+ /**
192
+ * Check if a featured version is enabled by comparing with XML version or PostHog feature flag
193
+ *
194
+ * This function uses the global PostHog instance (initialized via posthog.init() in your app).
195
+ * The XML version and PostHog client are pre-loaded when the module is imported.
196
+ *
197
+ * @example Using in React components:
198
+ * ```jsx
199
+ * import { isFeatureEnabled } from 'pp-flags';
200
+ *
201
+ * function MyComponent() {
202
+ * // Synchronous check - no async/await needed
203
+ * const isEnabled = isFeatureEnabled('ph:my-feature');
204
+ * const isPaapiEnabled = isFeatureEnabled('nf-ext:paapi');
205
+ * const isVersionOk = isFeatureEnabled('nf-ext:version:1.0.3');
206
+ * // ...
207
+ * }
208
+ * ```
209
+ *
210
+ * @param {string} flagString - Flag string in format "nf-ext:feature-name" (e.g., "nf-ext:paapi"), "nf-ext:version:1.0.3", or "ph:feature-flag-name"
211
+ * @returns {boolean} - True if enabled, false otherwise. Returns false if version/PostHog not loaded yet.
212
+ */
213
+ export function isFeatureEnabled(flagString) {
214
+ try {
215
+ // Extract version from parameter string
216
+ if (!flagString || typeof flagString !== 'string') {
217
+ return false;
218
+ }
219
+
220
+ // Check if this is a PostHog feature flag (starts with "ph:")
221
+ if (flagString.startsWith('ph:')) {
222
+ const featureFlagName = flagString.substring(3); // Get everything after "ph:"
223
+ if (!featureFlagName) {
224
+ return false;
225
+ }
226
+
227
+ // Get PostHog client (cached at module load)
228
+ const posthogClient = getPostHogClient();
229
+
230
+ // Check if PostHog is available and has the method
231
+ if (posthogClient && typeof posthogClient.isFeatureEnabled === 'function') {
232
+ try {
233
+ return posthogClient.isFeatureEnabled(featureFlagName) || false;
234
+ } catch (error) {
235
+ // Return false if PostHog check fails
236
+ return false;
237
+ }
238
+ }
239
+ // Return false if PostHog is not available or not initialized
240
+ return false;
241
+ }
242
+
243
+ // Handle nf-ext feature check (e.g., "nf-ext:paapi" or "nf-ext:version:1.0.3")
244
+ if (flagString.startsWith('nf-ext:')) {
245
+ const afterPrefix = flagString.substring(7); // Get everything after "nf-ext:"
246
+ if (!afterPrefix) {
247
+ return false;
248
+ }
249
+
250
+ // Get current version from cache (loaded at module initialization)
251
+ const currentVersion = getCurrentExtensionVersion();
252
+ if (!currentVersion) {
253
+ // Version not loaded yet, return false
254
+ return false;
255
+ }
256
+
257
+ // Check if this is a version comparison (nf-ext:version:XXX)
258
+ if (afterPrefix.startsWith('version:')) {
259
+ const specifiedVersion = afterPrefix.substring(8); // Get everything after "version:"
260
+ if (!specifiedVersion) {
261
+ return false;
262
+ }
263
+
264
+ // Compare versions: return true if specifiedVersion >= currentVersion
265
+ // This means: return true if the specified version is at least the current version
266
+ const comparison = compareVersions(specifiedVersion, currentVersion);
267
+ return comparison >= 0;
268
+ }
269
+
270
+ // Otherwise, treat it as a feature name (e.g., "nf-ext:paapi")
271
+ const featureName = afterPrefix;
272
+
273
+ // Look up features for this version in nfExtensions
274
+ const versionFeatures = nfExtensions[currentVersion];
275
+ if (!versionFeatures) {
276
+ return false;
277
+ }
278
+
279
+ // Check if the feature exists and is enabled
280
+ return !!versionFeatures[featureName];
281
+ }
282
+
283
+ // Handle other prefixes (legacy support or future extensions)
284
+ const parts = flagString.split(':');
285
+ if (parts.length < 2) {
286
+ return false;
287
+ }
288
+
289
+ // For other formats, return false (or implement custom logic)
290
+ return false;
291
+ } catch (error) {
292
+ // Return false on any error
293
+ return false;
294
+ }
295
+ }
package/jest.config.js ADDED
@@ -0,0 +1,7 @@
1
+ // jest.config.js
2
+ export default {
3
+ transform: {},
4
+ moduleNameMapper: {
5
+ '^(\\.{1,2}/.*)\\.js$': '$1',
6
+ }
7
+ };
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "pp-flags",
3
+ "version": "1.0.0",
4
+ "description": "Minimal feature flags package for frontend and backend",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "echo \"Error: no test specified\" && exit 1"
9
+ },
10
+ "author": "",
11
+ "license": "ISC",
12
+ "dependencies": {
13
+ "posthog-js": "^1.227.0"
14
+ }
15
+ }