@forge/feature-flags-node 1.0.0-next.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.
- package/CHANGELOG.md +12 -0
- package/LICENSE.txt +7 -0
- package/README.md +145 -0
- package/out/data-adapter.d.ts +15 -0
- package/out/data-adapter.d.ts.map +1 -0
- package/out/data-adapter.js +104 -0
- package/out/index.d.ts +19 -0
- package/out/index.d.ts.map +1 -0
- package/out/index.js +66 -0
- package/package.json +24 -0
package/CHANGELOG.md
ADDED
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright (c) 2025 Atlassian
|
|
2
|
+
Permission is hereby granted to use this software in accordance with the terms
|
|
3
|
+
and conditions outlined in the Atlassian Developer Terms, which can be found
|
|
4
|
+
at the following URL:
|
|
5
|
+
https://developer.atlassian.com/platform/marketplace/atlassian-developer-terms/
|
|
6
|
+
By using this software, you agree to comply with these terms and conditions.
|
|
7
|
+
If you do not agree with these terms, you are not permitted to use this software.
|
package/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# @forge/feature-flags-node
|
|
2
|
+
|
|
3
|
+
A feature flag SDK for Atlassian Forge apps running on Forge Node Runtime. This package provides a simple interface for evaluating feature flags.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
yarn add @forge/feature-flags-node
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Basic Usage
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { getAppContext } from "@forge/api";
|
|
17
|
+
import { ForgeFeatureFlags } from "@forge/feature-flags-node";
|
|
18
|
+
|
|
19
|
+
export const handler = async (payload, context) => {
|
|
20
|
+
const { environmentType } = getAppContext();
|
|
21
|
+
|
|
22
|
+
// Initialize the feature flags service
|
|
23
|
+
const featureFlags = new ForgeFeatureFlags();
|
|
24
|
+
await featureFlags.initialize({
|
|
25
|
+
environment: environmentType?.toLowerCase() || "development" // Optional, defaults to 'development'
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Define a user
|
|
29
|
+
const user = {
|
|
30
|
+
userID: context?.principal?.accountId,
|
|
31
|
+
custom: {
|
|
32
|
+
license: context?.license?.isActive
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Check a feature flag (synchronous after initialization)
|
|
37
|
+
const isEnabled = featureFlags.checkFlag(user, "new-feature");
|
|
38
|
+
|
|
39
|
+
// Get multiple flags at once (synchronous)
|
|
40
|
+
const flags = featureFlags.getFeatureFlags(user, ["feature-a", "feature-b"]);
|
|
41
|
+
|
|
42
|
+
// Shutdown when done
|
|
43
|
+
await featureFlags.shutdown();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## ⚠️ Important: Initialization Best Practices
|
|
49
|
+
|
|
50
|
+
**ALWAYS initialize the ForgeFeatureFlags SDK inside your handler function, NEVER initialize globally as it will use the stale feature flags**
|
|
51
|
+
|
|
52
|
+
### ✅ Correct Pattern - Initialize Inside Handler
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
import { getAppContext } from "@forge/api";
|
|
56
|
+
import { ForgeFeatureFlags } from "@forge/feature-flags-node";
|
|
57
|
+
|
|
58
|
+
export const handler = async (payload, context) => {
|
|
59
|
+
// ✅ Initialize inside the handler function
|
|
60
|
+
const featureFlags = new ForgeFeatureFlags();
|
|
61
|
+
await featureFlags.initialize({
|
|
62
|
+
environment: getAppContext().environmentType?.toLowerCase() || "development"
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Use feature flags...
|
|
66
|
+
const isEnabled = featureFlags.checkFlag(user, "new-feature");
|
|
67
|
+
|
|
68
|
+
// Shutdown when done
|
|
69
|
+
await featureFlags.shutdown();
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### ❌ Incorrect Pattern - Global Initialization
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
// ❌ NEVER do this - Global initialization
|
|
77
|
+
const featureFlags = new ForgeFeatureFlags();
|
|
78
|
+
await featureFlags.initialize(); // This will cause problems!
|
|
79
|
+
|
|
80
|
+
export const handler = async (payload, context) => {
|
|
81
|
+
// This will fail if token expires or network issues occur
|
|
82
|
+
const isEnabled = featureFlags.checkFlag(user, "new-feature");
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Polling Behavior
|
|
87
|
+
|
|
88
|
+
The package automatically polls for feature flag updates every 30 seconds:
|
|
89
|
+
|
|
90
|
+
1. **Cache Updates**: New data is cached for fallback purposes
|
|
91
|
+
2. **Error Resilience**: If a fetch fails, the package falls back to cached data
|
|
92
|
+
3. **Immediate Updates**: Feature flag changes are reflected within 30 seconds
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
// Polling happens automatically in the background
|
|
96
|
+
const featureFlags = new ForgeFeatureFlags();
|
|
97
|
+
await featureFlags.initialize();
|
|
98
|
+
|
|
99
|
+
// Feature flags are automatically updated every 30 seconds
|
|
100
|
+
// No manual intervention required
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## API Reference
|
|
104
|
+
|
|
105
|
+
### `ForgeFeatureFlags`
|
|
106
|
+
|
|
107
|
+
#### `constructor()`
|
|
108
|
+
Creates a new instance of the ForgeFeatureFlags class.
|
|
109
|
+
|
|
110
|
+
#### `initialize(config?: ForgeFeatureFlagConfig): Promise<void>`
|
|
111
|
+
Initializes the feature flags service with the provided configuration.
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
interface ForgeFeatureFlagConfig {
|
|
115
|
+
environment?: 'development' | 'staging' | 'production';
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
#### `checkFlag(user: ForgeUser, flagName: string): boolean`
|
|
120
|
+
Checks if a feature flag is enabled for the given user. **Synchronous** after initialization.
|
|
121
|
+
|
|
122
|
+
#### `getFeatureFlags(user: ForgeUser, flagNames: string[]): Record<string, boolean>`
|
|
123
|
+
Gets multiple feature flags at once. **Synchronous** after initialization.
|
|
124
|
+
|
|
125
|
+
#### `shutdown(): Promise<void>`
|
|
126
|
+
Shuts down the feature flags service and cleans up resources.
|
|
127
|
+
|
|
128
|
+
#### `isInitialized(): boolean`
|
|
129
|
+
Checks if the service is initialized.
|
|
130
|
+
|
|
131
|
+
### User Interface
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
interface ForgeUser {
|
|
135
|
+
userID: string;
|
|
136
|
+
custom?: Record<string, string | number | boolean>;
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Configuration Options
|
|
141
|
+
|
|
142
|
+
| Option | Type | Default | Description |
|
|
143
|
+
|--------|------|---------|-------------|
|
|
144
|
+
| `environment` | `'development' \| 'staging' \| 'production'` | `'development'` | Environment tier for feature flag evaluation |
|
|
145
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { IDataAdapter, AdapterResponse } from 'statsig-node';
|
|
2
|
+
export declare class ForgeDataAdapter implements IDataAdapter {
|
|
3
|
+
private readonly pollingIntervalMs;
|
|
4
|
+
private readonly pollingEnabled;
|
|
5
|
+
private readonly cache;
|
|
6
|
+
constructor(pollingIntervalMs?: number, pollingEnabled?: boolean);
|
|
7
|
+
get(key: string): Promise<AdapterResponse>;
|
|
8
|
+
private fetchFromAtlassianServers;
|
|
9
|
+
set(key: string, value: string): Promise<void>;
|
|
10
|
+
initialize(): Promise<void>;
|
|
11
|
+
shutdown(): Promise<void>;
|
|
12
|
+
supportsPollingUpdatesFor(key: string): boolean;
|
|
13
|
+
getPollingIntervalMs(): number;
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=data-adapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"data-adapter.d.ts","sourceRoot":"","sources":["../src/data-adapter.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAK7D,qBAAa,gBAAiB,YAAW,YAAY;IAIjD,OAAO,CAAC,QAAQ,CAAC,iBAAiB;IAClC,OAAO,CAAC,QAAQ,CAAC,cAAc;IAJjC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAkC;gBAGrC,iBAAiB,GAAE,MAAc,EACjC,cAAc,GAAE,OAAc;IAM3C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;YA+ClC,yBAAyB;IAiCjC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAU9C,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAK3B,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAQ/B,yBAAyB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IA8B/C,oBAAoB,IAAI,MAAM;CAG/B"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ForgeDataAdapter = void 0;
|
|
4
|
+
const api_1 = require("@forge/api");
|
|
5
|
+
class ForgeDataAdapter {
|
|
6
|
+
pollingIntervalMs;
|
|
7
|
+
pollingEnabled;
|
|
8
|
+
cache = new Map();
|
|
9
|
+
constructor(pollingIntervalMs = 30000, pollingEnabled = true) {
|
|
10
|
+
this.pollingIntervalMs = pollingIntervalMs;
|
|
11
|
+
this.pollingEnabled = pollingEnabled;
|
|
12
|
+
}
|
|
13
|
+
async get(key) {
|
|
14
|
+
try {
|
|
15
|
+
const data = await this.fetchFromAtlassianServers();
|
|
16
|
+
if (data) {
|
|
17
|
+
const serializedData = JSON.stringify(data);
|
|
18
|
+
this.cache.set(key, serializedData);
|
|
19
|
+
return {
|
|
20
|
+
result: serializedData,
|
|
21
|
+
time: Date.now()
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const cachedValue = this.cache.get(key);
|
|
25
|
+
if (cachedValue) {
|
|
26
|
+
return {
|
|
27
|
+
result: cachedValue,
|
|
28
|
+
time: Date.now()
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
result: undefined,
|
|
33
|
+
time: Date.now()
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
const cachedValue = this.cache.get(key);
|
|
38
|
+
if (cachedValue) {
|
|
39
|
+
return {
|
|
40
|
+
result: cachedValue,
|
|
41
|
+
time: Date.now()
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
result: undefined,
|
|
46
|
+
time: Date.now()
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async fetchFromAtlassianServers() {
|
|
51
|
+
const runtime = (0, api_1.__getRuntime)();
|
|
52
|
+
if (runtime.proxy?.tokenExpiry && runtime.proxy.tokenExpiry * 1000 < Date.now()) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const remainingTime = runtime.lambdaContext?.getRemainingTimeInMillis?.();
|
|
56
|
+
if (remainingTime !== undefined && remainingTime < 5000) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const response = await (0, api_1.__fetchProduct)({ provider: 'app', remote: 'feature-flags', type: 'feature-flags' })('/', {
|
|
60
|
+
method: 'GET',
|
|
61
|
+
redirect: 'follow',
|
|
62
|
+
headers: {
|
|
63
|
+
'Content-Type': 'application/json'
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
68
|
+
}
|
|
69
|
+
return response.json();
|
|
70
|
+
}
|
|
71
|
+
async set(key, value) {
|
|
72
|
+
try {
|
|
73
|
+
this.cache.set(key, value);
|
|
74
|
+
}
|
|
75
|
+
catch { }
|
|
76
|
+
}
|
|
77
|
+
async initialize() { }
|
|
78
|
+
async shutdown() {
|
|
79
|
+
this.cache.clear();
|
|
80
|
+
}
|
|
81
|
+
supportsPollingUpdatesFor(key) {
|
|
82
|
+
if (!this.pollingEnabled) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const runtime = (0, api_1.__getRuntime)();
|
|
87
|
+
if (runtime.proxy?.tokenExpiry && runtime.proxy.tokenExpiry * 1000 < Date.now()) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
const remainingTime = runtime.lambdaContext?.getRemainingTimeInMillis?.();
|
|
91
|
+
if (remainingTime !== undefined && remainingTime < 10000) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
getPollingIntervalMs() {
|
|
101
|
+
return this.pollingIntervalMs;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
exports.ForgeDataAdapter = ForgeDataAdapter;
|
package/out/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface ForgeFeatureFlagConfig {
|
|
2
|
+
environment?: 'development' | 'staging' | 'production';
|
|
3
|
+
}
|
|
4
|
+
export interface ForgeUser {
|
|
5
|
+
userID: string;
|
|
6
|
+
custom?: Record<string, string | number | boolean>;
|
|
7
|
+
}
|
|
8
|
+
export declare class ForgeFeatureFlags {
|
|
9
|
+
private initialized;
|
|
10
|
+
private dataAdapter;
|
|
11
|
+
initialize(config?: ForgeFeatureFlagConfig): Promise<void>;
|
|
12
|
+
checkFlag(user: ForgeUser, flagName: string): boolean;
|
|
13
|
+
getFeatureFlags(user: ForgeUser, flagNames: string[]): Record<string, boolean>;
|
|
14
|
+
shutdown(): Promise<void>;
|
|
15
|
+
isInitialized(): boolean;
|
|
16
|
+
private ensureInitialized;
|
|
17
|
+
private convertUser;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,sBAAsB;IACrC,WAAW,CAAC,EAAE,aAAa,GAAG,SAAS,GAAG,YAAY,CAAC;CACxD;AAED,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC;CACpD;AAKD,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,WAAW,CAAiC;IAKvC,UAAU,CAAC,MAAM,GAAE,sBAA2B,GAAG,OAAO,CAAC,IAAI,CAAC;IA0BpE,SAAS,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO;IAQrD,eAAe,CAAC,IAAI,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAcxE,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAe/B,aAAa,IAAI,OAAO;IAI/B,OAAO,CAAC,iBAAiB;IAMzB,OAAO,CAAC,WAAW;CAMpB"}
|
package/out/index.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ForgeFeatureFlags = void 0;
|
|
4
|
+
const statsig_node_1 = require("statsig-node");
|
|
5
|
+
const data_adapter_1 = require("./data-adapter");
|
|
6
|
+
class ForgeFeatureFlags {
|
|
7
|
+
initialized = false;
|
|
8
|
+
dataAdapter = null;
|
|
9
|
+
async initialize(config = {}) {
|
|
10
|
+
if (this.initialized) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
statsig_node_1.Statsig.shutdown();
|
|
15
|
+
}
|
|
16
|
+
catch { }
|
|
17
|
+
this.dataAdapter = new data_adapter_1.ForgeDataAdapter(30000, true);
|
|
18
|
+
const statsigOptions = {
|
|
19
|
+
environment: { tier: config.environment || 'development' },
|
|
20
|
+
disableRulesetsSync: false,
|
|
21
|
+
disableIdListsSync: true,
|
|
22
|
+
initTimeoutMs: 3000,
|
|
23
|
+
localMode: true,
|
|
24
|
+
dataAdapter: this.dataAdapter
|
|
25
|
+
};
|
|
26
|
+
await statsig_node_1.Statsig.initialize('forge-internal-key', statsigOptions);
|
|
27
|
+
this.initialized = true;
|
|
28
|
+
}
|
|
29
|
+
checkFlag(user, flagName) {
|
|
30
|
+
this.ensureInitialized();
|
|
31
|
+
return statsig_node_1.Statsig.checkGate(this.convertUser(user), flagName);
|
|
32
|
+
}
|
|
33
|
+
getFeatureFlags(user, flagNames) {
|
|
34
|
+
this.ensureInitialized();
|
|
35
|
+
const results = {};
|
|
36
|
+
for (const flagName of flagNames) {
|
|
37
|
+
results[flagName] = statsig_node_1.Statsig.checkGate(this.convertUser(user), flagName);
|
|
38
|
+
}
|
|
39
|
+
return results;
|
|
40
|
+
}
|
|
41
|
+
async shutdown() {
|
|
42
|
+
if (!this.initialized) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
statsig_node_1.Statsig.shutdown();
|
|
46
|
+
if (this.dataAdapter) {
|
|
47
|
+
await this.dataAdapter.shutdown();
|
|
48
|
+
}
|
|
49
|
+
this.initialized = false;
|
|
50
|
+
}
|
|
51
|
+
isInitialized() {
|
|
52
|
+
return this.initialized;
|
|
53
|
+
}
|
|
54
|
+
ensureInitialized() {
|
|
55
|
+
if (!this.initialized) {
|
|
56
|
+
throw new Error('ForgeFeatureFlags not initialized. Call initialize() first.');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
convertUser(user) {
|
|
60
|
+
return {
|
|
61
|
+
userID: user.userID,
|
|
62
|
+
custom: user.custom
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
exports.ForgeFeatureFlags = ForgeFeatureFlags;
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forge/feature-flags-node",
|
|
3
|
+
"version": "1.0.0-next.0",
|
|
4
|
+
"description": "Feature Flags Node SDK for Atlassian Forge apps running on Node Runtime",
|
|
5
|
+
"author": "Atlassian",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE.txt",
|
|
7
|
+
"main": "out/index.js",
|
|
8
|
+
"types": "out/index.d.ts",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "yarn run clean && yarn run compile",
|
|
11
|
+
"clean": "rm -rf ./out && rm -f tsconfig.tsbuildinfo",
|
|
12
|
+
"compile": "tsc -b -v"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/node": "20.19.1"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@forge/api": "^6.0.2-next.3",
|
|
19
|
+
"statsig-node": "^6.4.3"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"registry": "https://packages.atlassian.com/api/npm/npm-public/"
|
|
23
|
+
}
|
|
24
|
+
}
|