feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
# @coze-arch/bot-flags
feature gating for bot studio
## Overview
This package is part of the Coze Studio monorepo and provides architecture functionality. It serves as a core component in the Coze ecosystem.
## Getting Started
### Installation
Add this package to your `package.json`:
```json
{
"dependencies": {
"@coze-arch/bot-flags": "workspace:*"
}
}
```
Then run:
```bash
rush update
```
### Usage
```typescript
import { /* exported functions/components */ } from '@coze-arch/bot-flags';
// Example usage
// TODO: Add specific usage examples
```
## Features
- Core functionality for Coze Studio
- TypeScript support
- Modern ES modules
## API Reference
### Exports
- `type FEATURE_FLAGS, type FetchFeatureGatingFunction`
- `getFlags`
- `useFlags`
- `pullFeatureFlags`
For detailed API documentation, please refer to the TypeScript definitions.
## Development
This package is built with:
- TypeScript
- Modern JavaScript
- Vitest for testing
- ESLint for code quality
## Contributing
This package is part of the Coze Studio monorepo. Please follow the monorepo contribution guidelines.
## License
Apache-2.0

View File

@@ -0,0 +1,80 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { logger } from '@coze-arch/logger';
import { readFromCache, saveToCache } from '../src/utils/persist-cache'; // Adjust the import path
// Mocking localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
};
vi.stubGlobal('localStorage', localStorageMock);
describe('Feature Flags Cache', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('readFromCache', () => {
it('should return undefined if cache is empty', async () => {
localStorageMock.getItem.mockReturnValueOnce(undefined);
const result = await readFromCache();
expect(result).toBeUndefined();
});
it('should return feature flags if cache has valid data', async () => {
const validFlags = { feature1: true, feature2: false };
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(validFlags));
const result = await readFromCache();
expect(result).toEqual(validFlags);
});
it('should return undefined if cache has invalid data', async () => {
localStorageMock.getItem.mockReturnValueOnce(
JSON.stringify({ invalid: 'data' }),
);
const result = await readFromCache();
expect(result).toBeUndefined();
});
});
describe('saveToCache', () => {
it('should save feature flags to cache', async () => {
const flags = { feature1: true, feature2: false };
await saveToCache(flags);
expect(localStorageMock.setItem).toBeCalledWith(
'cache:@coze-arch/bot-flags',
JSON.stringify(flags),
);
});
it('should save feature flags to cache', async () => {
await saveToCache({ fg: 'test' });
expect(localStorageMock.setItem).not.toBeCalled();
});
it('should save feature flags to cache', async () => {
localStorageMock.setItem.mockImplementation(() => {
throw new Error('test');
});
await saveToCache({ feature: true });
expect(logger.persist.error).toBeCalled();
});
});
});

View File

@@ -0,0 +1,283 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type Mock } from 'vitest';
import { featureFlagStorage } from '../src/utils/storage';
import {
readFgPromiseFromContext,
readFgValuesFromContext,
} from '../src/utils/read-from-context';
import { readFromCache, saveToCache } from '../src/utils/persist-cache';
const fetchFeatureGating = vi.fn();
const $wait = ms => new Promise(r => setTimeout(r, ms));
vi.mock('../src/utils/wait', () => ({
wait: vi.fn().mockImplementation($wait),
ONE_SEC: 1000,
nextTick: vi.fn().mockImplementation(async () => {
await $wait(10);
}),
}));
vi.mock('../src/utils/persist-cache', () => ({
readFromCache: vi.fn(),
saveToCache: vi.fn(),
}));
vi.mock('../src/utils/read-from-context', () => ({
readFgPromiseFromContext: vi.fn(),
readFgValuesFromContext: vi.fn().mockReturnValue(undefined),
}));
vi.mock('../src/utils/storage', () => ({
featureFlagStorage: {
setFlags: vi.fn(),
getFlags: vi.fn().mockReturnValue({}),
},
}));
describe('pullFeatureFlags', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
// Runs successfully with default context
it('should access values from global static value', async () => {
(readFgValuesFromContext as Mock).mockReturnValue({ foo: true });
const { pullFeatureFlags } = await import('../src/pull-feature-flags');
// Invoke function
await pullFeatureFlags({ fetchFeatureGating });
expect(readFgValuesFromContext).toBeCalled();
expect(readFgPromiseFromContext).not.toBeCalled();
expect(readFromCache).not.toBeCalled();
expect(fetchFeatureGating).not.toBeCalled();
expect(featureFlagStorage.setFlags).toBeCalledWith({ foo: true });
expect(saveToCache).toBeCalledWith({ foo: true });
});
// Runs successfully with default context
it('should access values from global context', async () => {
(readFgValuesFromContext as Mock).mockReturnValue(undefined);
readFgPromiseFromContext.mockResolvedValue({ foo: true });
readFromCache.mockResolvedValue(undefined);
fetchFeatureGating.mockResolvedValue(undefined);
const { pullFeatureFlags } = await import('../src/pull-feature-flags');
// Invoke function
await pullFeatureFlags({ fetchFeatureGating });
expect(readFgPromiseFromContext).toBeCalled();
expect(readFromCache).toBeCalled();
expect(featureFlagStorage.setFlags).toBeCalledWith({ foo: true });
expect(saveToCache).toBeCalledWith({ foo: true });
});
it('should access values from localstorage', async () => {
readFgPromiseFromContext.mockResolvedValueOnce(undefined);
readFromCache.mockResolvedValue({ foo: true });
fetchFeatureGating.mockResolvedValue(undefined);
readFgPromiseFromContext.mockResolvedValueOnce({ foo: false });
const { pullFeatureFlags } = await import('../src/pull-feature-flags');
// Invoke function
vi.useFakeTimers();
const p = pullFeatureFlags({ pollingInterval: 1000, fetchFeatureGating });
vi.runAllTimersAsync();
await p;
vi.useRealTimers();
expect(readFgPromiseFromContext).toBeCalled();
expect(readFromCache).toBeCalled();
expect(fetchFeatureGating).toBeCalled();
expect(featureFlagStorage.setFlags).toBeCalledWith({ foo: true });
});
it('should access values from api', async () => {
readFgPromiseFromContext.mockResolvedValue(undefined);
readFromCache.mockResolvedValue(undefined);
fetchFeatureGating.mockResolvedValue({ foo: true });
const { pullFeatureFlags } = await import('../src/pull-feature-flags');
// Invoke function
await pullFeatureFlags({ fetchFeatureGating });
expect(readFgPromiseFromContext).toBeCalled();
expect(readFromCache).toBeCalled();
expect(fetchFeatureGating).toBeCalled();
expect(featureFlagStorage.setFlags).toBeCalledWith({ foo: true });
expect(saveToCache).toBeCalledWith({ foo: true });
});
it('should access values from global context firstly', async () => {
// 从localStorage & global context 都取到值的情况下,优先使用 context 值
readFromCache.mockImplementation(async () => {
await $wait(100);
return { foo: true };
});
readFgPromiseFromContext.mockResolvedValue({ foo: false });
fetchFeatureGating.mockResolvedValue(undefined);
const { pullFeatureFlags } = await import('../src/pull-feature-flags');
// Invoke function
await pullFeatureFlags({ fetchFeatureGating });
expect(readFgPromiseFromContext).toBeCalled();
expect(readFromCache).toBeCalled();
expect(fetchFeatureGating).toBeCalled();
expect(featureFlagStorage.setFlags).toBeCalledWith({ foo: false });
});
it('should fallback to default value', async () => {
readFromCache.mockResolvedValue(undefined);
readFgPromiseFromContext.mockResolvedValue(undefined);
fetchFeatureGating.mockResolvedValueOnce(undefined);
fetchFeatureGating.mockResolvedValueOnce({ foo: true });
const { pullFeatureFlags } = await import('../src/pull-feature-flags');
// Invoke function
await pullFeatureFlags({
strict: false,
pollingInterval: 10,
timeout: 1000,
fetchFeatureGating,
});
expect(readFgPromiseFromContext).toBeCalledTimes(2);
expect(readFromCache).toBeCalledTimes(2);
expect(fetchFeatureGating).toBeCalledTimes(2);
expect(featureFlagStorage.setFlags).toBeCalledWith({});
});
it('should throw error with strict mode', async () => {
readFromCache.mockResolvedValue(undefined);
readFgPromiseFromContext.mockResolvedValue(undefined);
fetchFeatureGating.mockResolvedValue(undefined);
const { pullFeatureFlags } = await import('../src/pull-feature-flags');
// Invoke function
await expect(
pullFeatureFlags({
strict: true,
pollingInterval: 10,
fetchFeatureGating,
}),
).rejects.toThrowError('Fetch Feature Flags timeout');
expect(readFgPromiseFromContext).toBeCalled();
expect(readFromCache).toBeCalled();
expect(fetchFeatureGating).toBeCalled();
expect(featureFlagStorage.setFlags).not.toBeCalled();
});
it('should throw error with strict mode once timeout', async () => {
const resolveAfterTimeout = async () => {
await $wait(2000);
return { foo: true };
};
readFromCache.mockImplementation(resolveAfterTimeout);
readFgPromiseFromContext.mockImplementation(resolveAfterTimeout);
fetchFeatureGating.mockImplementation(resolveAfterTimeout);
const { pullFeatureFlags } = await import('../src/pull-feature-flags');
// Invoke function
await expect(
pullFeatureFlags({
strict: true,
timeout: 1000,
pollingInterval: 10,
fetchFeatureGating,
}),
).rejects.toThrowError('Fetch Feature Flags timeout');
expect(readFgPromiseFromContext).toBeCalled();
expect(readFromCache).toBeCalled();
expect(fetchFeatureGating).toBeCalled();
expect(featureFlagStorage.setFlags).not.toBeCalled();
});
it('should resolve value even if any step failure', async () => {
readFromCache.mockResolvedValue({ foo: true });
readFgPromiseFromContext.mockRejectedValue(new Error('jweofj'));
readFgValuesFromContext.mockReturnValue(undefined);
fetchFeatureGating.mockRejectedValueOnce(new Error('test'));
fetchFeatureGating.mockResolvedValue({ foo: false });
const { pullFeatureFlags } = await import('../src/pull-feature-flags');
// Invoke function
vi.useFakeTimers();
const p = pullFeatureFlags({
pollingInterval: 10,
timeout: 10,
fetchFeatureGating,
});
vi.runAllTimersAsync();
await p;
vi.useRealTimers();
expect(featureFlagStorage.setFlags).toBeCalledWith({ foo: true });
expect(featureFlagStorage.setFlags).toBeCalledWith({ foo: false });
});
it('should fallback to default value & and retry even if all step failure', async () => {
readFromCache.mockRejectedValue(new Error('wfe'));
readFgPromiseFromContext.mockRejectedValue(new Error('jweofj'));
fetchFeatureGating.mockRejectedValueOnce(new Error('test'));
fetchFeatureGating.mockResolvedValue({ foo: true });
const { pullFeatureFlags } = await import('../src/pull-feature-flags');
// Invoke function
await pullFeatureFlags({ pollingInterval: 10, fetchFeatureGating });
expect(fetchFeatureGating).toBeCalledTimes(2);
expect(featureFlagStorage.setFlags.mock.calls[0][0]).toEqual({});
expect(featureFlagStorage.setFlags.mock.calls[1][0]).toEqual({ foo: true });
});
it('should throw Error if all step break down', async () => {
(readFgValuesFromContext as Mock).mockImplementationOnce(() => {
throw new Error('test');
});
readFromCache.mockRejectedValue(new Error('wfe'));
readFgPromiseFromContext.mockRejectedValue(new Error('jweofj'));
fetchFeatureGating.mockRejectedValue(new Error('test'));
const { pullFeatureFlags } = await import('../src/pull-feature-flags');
// Invoke function
await expect(
pullFeatureFlags({ strict: true, timeout: 1000, fetchFeatureGating }),
).rejects.toThrowError('Fetch Feature Flags timeout');
expect(featureFlagStorage.setFlags).not.toBeCalled();
});
});

View File

@@ -0,0 +1,63 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
readFgPromiseFromContext,
readFgValuesFromContext,
} from '../src/utils/read-from-context';
describe('readFgPromiseFromContext', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return feature flags if set within timeout', async () => {
const featureFlags = { feature1: true, feature2: false };
vi.stubGlobal(
'__fetch_fg_promise__',
Promise.resolve({ data: featureFlags }),
);
const result = await readFgPromiseFromContext();
expect(result).toEqual(featureFlags);
});
it('should return undefined if feature flags are not set', async () => {
vi.stubGlobal('__fetch_fg_promise__', undefined);
const result = await readFgPromiseFromContext();
expect(result).toBeUndefined();
});
});
describe('readFgValuesFromContext', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return feature flags if set within timeout', () => {
const featureFlags = { feature1: true, feature2: false };
vi.stubGlobal('__fg_values__', featureFlags);
const result = readFgValuesFromContext();
expect(result).toEqual(featureFlags);
});
it('should return undefined if feature flags are not set', async () => {
vi.stubGlobal('__fg_values__', undefined);
const result = await readFgValuesFromContext();
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,125 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
describe('FeatureFlagStorage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
it('should initialize correctly', async () => {
const { featureFlagStorage } = await import('../src/utils/storage');
expect(featureFlagStorage.inited).toBe(false);
});
it('should return inited status correctly', async () => {
const { featureFlagStorage } = await import('../src/utils/storage');
expect(featureFlagStorage.inited).toBe(false);
featureFlagStorage.setFlags({ feature1: true });
expect(featureFlagStorage.inited).toBe(true);
});
it('should set and get feature flags correctly', async () => {
const { featureFlagStorage } = await import('../src/utils/storage');
const flags = { feature1: true, feature2: false };
featureFlagStorage.setFlags(flags);
const fgValues = featureFlagStorage.getFlags();
expect(fgValues.feature1).toEqual(true);
expect(fgValues.feature2).toEqual(false);
// fallback to false if key not exits
expect(fgValues.feature3).toEqual(false);
});
it('should emit change event on setting new flags', async () => {
const { featureFlagStorage } = await import('../src/utils/storage');
const flags = { feature1: true, feature2: false };
const listener = vi.fn();
featureFlagStorage.on('change', listener);
featureFlagStorage.setFlags(flags);
expect(listener).toHaveBeenCalledWith(flags);
});
it('should not emit change event when setting same flags', async () => {
const { featureFlagStorage } = await import('../src/utils/storage');
const flags = { feature1: true, feature2: false };
featureFlagStorage.setFlags(flags);
const listener = vi.fn();
featureFlagStorage.on('change', listener);
featureFlagStorage.setFlags(flags);
expect(listener).not.toHaveBeenCalled();
});
it('should throw error when getting flags before initialization', async () => {
vi.stubEnv('NODE_ENV', 'development');
const { featureFlagStorage } = await import('../src/utils/storage');
expect(() => featureFlagStorage.getFlags()).toThrow(
'Trying access feature flag values before the storage been init.',
);
});
it('should clear flags correctly', async () => {
vi.stubEnv('NODE_ENV', 'development');
const { featureFlagStorage } = await import('../src/utils/storage');
featureFlagStorage.setFlags({ feature1: true });
featureFlagStorage.clear();
expect(() => featureFlagStorage.getFlags()).toThrow();
});
it('should return all keys', async () => {
const { featureFlagStorage } = await import('../src/utils/storage');
featureFlagStorage.setFlags({ feature1: true, feature2: false });
const flags = featureFlagStorage.getFlags();
expect(flags.keys).toEqual(['feature1', 'feature2']);
});
it('should return none keys', async () => {
vi.stubEnv('NODE_ENV', 'production');
const { featureFlagStorage } = await import('../src/utils/storage');
const flags = featureFlagStorage.getFlags();
expect(flags.keys).toEqual([]);
});
it('should return isInited', async () => {
vi.stubEnv('NODE_ENV', 'production');
const { featureFlagStorage } = await import('../src/utils/storage');
const flags = featureFlagStorage.getFlags();
expect(flags.isInited).toEqual(false);
featureFlagStorage.setFlags({ feature1: true, feature2: false });
expect(flags.isInited).toEqual(true);
});
it('should unshift the function into interceptors by calling `use`', async () => {
vi.stubEnv('NODE_ENV', 'production');
const { featureFlagStorage } = await import('../src/utils/storage');
const fnGetter = vi.fn();
featureFlagStorage.use(fnGetter);
const flags = featureFlagStorage.getFlags();
const testKey = 'bot.arch.bot.fg.test.1';
// trigger get of the proxy
flags[testKey];
expect(fnGetter).toHaveBeenCalledWith(testKey);
// trigger set of the proxy
const fnSetter = vi.fn(() => {
flags['bot.arch.bot.fg.test.1'] = true;
});
expect(fnSetter).toThrowError();
});
});

View File

@@ -0,0 +1,71 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react-hooks';
import { featureFlagStorage } from '../src/utils/storage';
import { useFlags } from '../src/use-flags'; // Adjust the import path
import { getFlags } from '../src/get-flags';
vi.mock('../src/utils/storage', () => ({
featureFlagStorage: {
on: vi.fn(),
off: vi.fn(),
},
}));
vi.mock('../src/get-flags', () => ({
getFlags: vi.fn(),
}));
describe('useFlags', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should return initial flags', () => {
const initialFlags = { feature1: true, feature2: false };
getFlags.mockImplementation(() => initialFlags);
const { result } = renderHook(() => useFlags());
expect(result.current[0]).toEqual(initialFlags);
});
it('should update flags on storage change', () => {
const initialFlags = { feature1: true, feature2: false };
const updatedFlags = { feature1: false, feature2: true };
getFlags.mockImplementation(() => initialFlags);
const { result } = renderHook(() => useFlags());
act(() => {
getFlags.mockImplementation(() => updatedFlags);
featureFlagStorage.on.mock.calls[0][1](); // Simulate 'change' event
});
expect(result.current[0]).toEqual(updatedFlags);
});
it('should remove event listener on unmount', () => {
const { unmount } = renderHook(() => useFlags());
unmount();
expect(featureFlagStorage.off).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,12 @@
{
"operationSettings": [
{
"operationName": "test:cov",
"outputFolderNames": ["coverage"]
},
{
"operationName": "ts-check",
"outputFolderNames": ["./dist"]
}
]
}

View File

@@ -0,0 +1,7 @@
const { defineConfig } = require('@coze-arch/eslint-config');
module.exports = defineConfig({
packageRoot: __dirname,
preset: 'web',
rules: {},
});

View File

@@ -0,0 +1,55 @@
{
"name": "@coze-arch/bot-flags",
"version": "0.0.1",
"description": "feature gating for bot studio ",
"license": "Apache-2.0",
"author": "fanwenjie.fe@bytedance.com",
"maintainers": [],
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./constant": "./src/constant.ts",
"./_featureStorage": "./src/utils/storage.ts",
"./init-flags": "./src/init-flags.ts"
},
"main": "src/index.tsx",
"typesVersions": {
"*": {
"constant": [
"./src/constant.ts"
],
"_featureStorage": [
"./src/utils/storage.ts"
],
"init-flags": [
"./src/init-flags.ts"
]
}
},
"scripts": {
"build": "exit 0",
"lint": "eslint ./ --cache",
"test": "vitest --run --passWithNoTests",
"test:cov": "npm run test -- --coverage"
},
"dependencies": {
"@coze-arch/logger": "workspace:*",
"eventemitter3": "^5.0.1"
},
"devDependencies": {
"@coze-arch/bot-typings": "workspace:*",
"@coze-arch/eslint-config": "workspace:*",
"@coze-arch/ts-config": "workspace:*",
"@coze-arch/vitest-config": "workspace:*",
"@testing-library/react-hooks": "^8.0.1",
"@types/node": "^18",
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"@vitest/coverage-v8": "~3.0.5",
"react": "~18.2.0",
"react-dom": "~18.2.0",
"sucrase": "^3.32.0",
"vitest": "~3.0.5"
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const log = {
info: vi.fn().mockImplementation(console.log.bind(console, '[info]')),
error: vi.fn().mockImplementation(console.error.bind(console, '[error]')),
success: vi.fn().mockImplementation(console.log.bind(console, '[success]')),
};
vi.mock('@coze-arch/logger', () => ({
logger: {
...log,
persist: log,
},
reporter: {
createReporterWithPreset: vi
.fn()
.mockReturnValue({ tracer: vi.fn().mockReturnValue({ trace: vi.fn() }) }),
},
}));

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const IS_DEV = process.env.NODE_ENV === 'development';
export const PACKAGE_NAMESPACE = '@flow-arch/flags';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { featureFlagStorage } from './utils/storage';
export const getFlags = () => {
const flags = featureFlagStorage.getFlags();
return flags;
};

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference types='@coze-arch/bot-typings' />

View File

@@ -0,0 +1,21 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { type FEATURE_FLAGS, type FetchFeatureGatingFunction } from './types';
export { getFlags } from './get-flags';
export { useFlags } from './use-flags';
export { pullFeatureFlags } from './pull-feature-flags';

View File

@@ -0,0 +1,225 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { logger } from '@coze-arch/logger';
import { wait, ONE_SEC } from './utils/wait';
import { isObject } from './utils/tools';
import { featureFlagStorage } from './utils/storage';
import { reporter } from './utils/repoter';
import {
readFgValuesFromContext,
readFgPromiseFromContext,
} from './utils/read-from-context';
import { readFromCache, saveToCache } from './utils/persist-cache';
import { type FEATURE_FLAGS, type FetchFeatureGatingFunction } from './types';
import { PACKAGE_NAMESPACE } from './constant';
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const DEFAULT_POLLING_INTERVAL = 5 * ONE_SEC;
// 设置 17 作为时间分片大小
const TIME_PIECE = 17;
interface PullFeatureFlagsParams {
// 取值超时时间
timeout: number;
// 严格模式下,不会插入兜底逻辑,且取不到数值时直接报错
strict: boolean;
// 轮训间隔,生产环境默认 60 秒;开发 & 测试环境默认 10 秒
pollingInterval: number;
fetchFeatureGating: FetchFeatureGatingFunction;
}
interface WorkResult {
values: FEATURE_FLAGS;
source: 'context' | 'remote' | 'bailout' | 'persist' | 'static_context';
}
const runPipeline = async (
context: PullFeatureFlagsParams,
): Promise<WorkResult> => {
try {
const fgValues = readFgValuesFromContext();
if (fgValues) {
saveToCache(fgValues);
return { values: fgValues, source: 'static_context' };
}
} catch (e) {
logger.persist.error({
namespace: PACKAGE_NAMESPACE,
message: (e as Error).message,
error: e as Error,
});
}
const { timeout: to, strict } = context;
// 超时时间不应该小于 1s
const timeout = Math.max(to, ONE_SEC);
const works: (() => Promise<WorkResult | undefined>)[] = [];
const waitTimeout = wait.bind(null, timeout + ONE_SEC);
// 从线上环境取值
works.push(async () => {
try {
const values = await context.fetchFeatureGating();
if (isObject(values)) {
saveToCache(values);
return { values, source: 'remote' };
}
await waitTimeout();
} catch (e) {
// TODO: 这里加埋点,上报接口异常
logger.persist.error({
namespace: PACKAGE_NAMESPACE,
message: 'Fetch fg by "fetchFeatureGating" failure',
error: e as Error,
});
await waitTimeout();
}
});
// 从浏览器全局对象取值
// 这里需要判断一下,只有浏览器环境才执行
works.push(async () => {
try {
const values = await readFgPromiseFromContext();
if (values && isObject(values)) {
saveToCache(values);
return { values: values as FEATURE_FLAGS, source: 'context' };
}
logger.persist.info({
namespace: PACKAGE_NAMESPACE,
message: "Can't not read fg from global context",
});
// 强制等等超时,以免整个 works resolve 到错误的值
await waitTimeout();
} catch (e) {
// TODO: 这里加埋点,上报接口异常
logger.persist.error({
namespace: PACKAGE_NAMESPACE,
message: 'Fetch fg from context failure',
error: e as Error,
});
await waitTimeout();
}
});
// 从缓存中取值
works.push(async () => {
try {
const values = await readFromCache();
if (values) {
// 等待 xx ms 后再读 persist以确保优先从 context 取值
await wait(timeout - TIME_PIECE);
return { values, source: 'persist' };
}
await waitTimeout();
} catch (e) {
// TODO: 这里加埋点,上报接口异常
logger.persist.error({
namespace: PACKAGE_NAMESPACE,
message: 'Fetch fg from persist cache failure',
error: e as Error,
});
await waitTimeout();
}
});
// 兜底,超时取不到值返回默认值,也就是全部都是 false
works.push(async () => {
await wait(timeout + TIME_PIECE);
if (strict) {
throw new Error('Fetch Feature Flags timeout.');
}
return { values: {} as unknown as FEATURE_FLAGS, source: 'bailout' };
});
// 这里不可能返回 undefined所以做一次强制转换
const res = (await Promise.race(
works.map(work => work()),
)) as unknown as WorkResult;
return res;
};
const normalize = (
context?: Partial<PullFeatureFlagsParams>,
): PullFeatureFlagsParams => {
const ctx = context || {};
if (!ctx.fetchFeatureGating) {
throw new Error('fetchFeatureGating is required');
}
const DEFAULT_CONTEXT: Partial<PullFeatureFlagsParams> = {
timeout: 2000,
strict: false,
pollingInterval: DEFAULT_POLLING_INTERVAL,
};
const normalizeContext = Object.assign(
DEFAULT_CONTEXT,
Object.keys(ctx)
// 只取不为 undefined 的东西
.filter(k => typeof ctx[k] !== 'undefined')
.reduce((acc, k) => ({ ...acc, [k]: ctx[k] }), {}),
);
return normalizeContext as PullFeatureFlagsParams;
};
const pullFeatureFlags = async (context?: Partial<PullFeatureFlagsParams>) => {
const tracer = reporter.tracer({
eventName: 'load-fg',
});
const normalizeContext = normalize(context);
const { strict, pollingInterval } = normalizeContext;
tracer.trace('start');
const start = performance.now();
const retry = async () => {
// 出现错误时,自动重试
await wait(pollingInterval);
await pullFeatureFlags(context);
};
try {
const res = await runPipeline(normalizeContext);
const { values, source } = res;
// TODO: 这里应该上报数量,后续 logger 提供相关能力后要改一下
logger.persist.success({
namespace: PACKAGE_NAMESPACE,
message: `Load FG from ${source} start at ${start}ms and spend ${
performance.now() - start
}ms`,
});
tracer.trace('finish');
featureFlagStorage.setFlags(values);
if (['bailout', 'persist'].includes(source)) {
await retry();
}
} catch (e) {
logger.persist.error({
namespace: PACKAGE_NAMESPACE,
message: 'Failure to load FG',
error: e as Error,
});
if (!strict) {
featureFlagStorage.setFlags({} as unknown as FEATURE_FLAGS);
await retry();
} else {
throw e;
}
}
};
export { pullFeatureFlags };

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FEATURE_FLAGS as ORIGIN_FEATURE_FLAGS } from './feature-flags';
// eslint-disable-next-line @typescript-eslint/naming-convention
type FEATURE_FLAGS = ORIGIN_FEATURE_FLAGS & {
/**
* 返回所有可用 key 列表
*/
keys: string[];
/**
* FG 是否已经完成初始化
*/
isInited: boolean;
};
declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/naming-convention
__fetch_fg_promise__: Promise<{ data: FEATURE_FLAGS }>;
// eslint-disable-next-line @typescript-eslint/naming-convention
__fg_values__: FEATURE_FLAGS;
}
}
export { type FEATURE_FLAGS };
export type FetchFeatureGatingFunction = () => Promise<FEATURE_FLAGS>;

View File

@@ -0,0 +1,39 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useEffect } from 'react';
import { featureFlagStorage } from './utils/storage';
import { type FEATURE_FLAGS } from './types';
import { getFlags } from './get-flags';
export const useFlags = (): [FEATURE_FLAGS] => {
const plainFlags = getFlags();
// 监听 fg store 事件,触发 react 组件响应变化
const [, setTick] = useState<number>(0);
useEffect(() => {
const cb = () => {
setTick(Date.now());
};
featureFlagStorage.on('change', cb);
return () => {
featureFlagStorage.off('change', cb);
};
}, []);
return [plainFlags];
};

View File

@@ -0,0 +1,68 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { logger } from '@coze-arch/logger';
import { type FEATURE_FLAGS } from '../types';
import { PACKAGE_NAMESPACE } from '../constant';
import { nextTick } from './wait';
const PERSIST_CACHE_KEY = 'cache:@coze-arch/bot-flags';
const isFlagsShapeObj = (obj: unknown) => {
if (typeof obj === 'object') {
const shape = obj as FEATURE_FLAGS;
return (
// 如果包含任意属性值不是 boolean则认为不是 flags 对象
Object.keys(shape).some(r => typeof shape[r] !== 'boolean') === false
);
}
return false;
};
export const readFromCache = async (): Promise<FEATURE_FLAGS | undefined> => {
await Promise.resolve(undefined);
const content = window.localStorage.getItem(PERSIST_CACHE_KEY);
if (!content) {
return undefined;
}
try {
const res = JSON.parse(content);
if (isFlagsShapeObj(res)) {
return res;
}
return undefined;
} catch (e) {
return undefined;
}
};
export const saveToCache = async (flags: FEATURE_FLAGS) => {
await nextTick();
try {
if (isFlagsShapeObj(flags)) {
const content = JSON.stringify(flags);
window.localStorage.setItem(PERSIST_CACHE_KEY, content);
}
} catch (e) {
// do nothing
logger.persist.error({
namespace: PACKAGE_NAMESPACE,
message: 'save fg failure',
error: e as Error,
});
}
};

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type FEATURE_FLAGS } from '../types';
export const readFgPromiseFromContext = async (): Promise<
FEATURE_FLAGS | undefined
> => {
const { __fetch_fg_promise__: globalFetchFgPromise } = window;
if (globalFetchFgPromise) {
const res = await globalFetchFgPromise;
return res.data as FEATURE_FLAGS;
}
return undefined;
};
export const readFgValuesFromContext = () => {
const { __fg_values__: globalFgValues } = window;
if (globalFgValues && Object.keys(globalFgValues).length > 0) {
return globalFgValues as FEATURE_FLAGS;
}
return undefined;
};

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { reporter as originReporter } from '@coze-arch/logger';
import { PACKAGE_NAMESPACE } from '../constant';
export const reporter = originReporter.createReporterWithPreset({
namespace: PACKAGE_NAMESPACE,
});

View File

@@ -0,0 +1,132 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import EventEmitter from 'eventemitter3';
import { logger } from '@coze-arch/logger';
import type { FEATURE_FLAGS } from '../types';
import { IS_DEV } from '../constant';
import { isEqual } from './tools';
type Interceptor = (key: string) => boolean | undefined;
class FeatureFlagStorage extends EventEmitter {
#proxy: FEATURE_FLAGS | undefined = undefined;
#cache: FEATURE_FLAGS | undefined = undefined;
#inited = false;
#interceptors: Interceptor[] = [];
constructor() {
super();
// fallback
this.#interceptors.push((name: string) => {
const cache = this.#cache;
if (!cache) {
return false;
}
// 从 remote 取值
if (Reflect.has(cache, name)) {
return Reflect.get(cache, name);
}
});
this.#proxy = new Proxy(Object.create(null), {
get: (target, name: string) => {
const cache = this.#cache;
switch (name) {
case 'keys': {
return typeof cache === 'object' ? Reflect.ownKeys(cache) : [];
}
case 'isInited': {
return this.#inited;
}
default: {
return this.#retrieveValueFromInterceptors(name);
}
}
},
set() {
throw new Error('Do not set flag value anytime anyway.');
},
}) as FEATURE_FLAGS;
}
#retrieveValueFromInterceptors(key: string) {
const interceptors = this.#interceptors;
for (const func of interceptors) {
const res = func(key);
if (typeof res === 'boolean') {
return res;
}
}
return false;
}
// has first set FG value
get inited() {
return this.#inited;
}
setFlags(values: FEATURE_FLAGS) {
const cache = this.#cache;
if (isEqual(cache, values)) {
return false;
}
this.#cache = values;
this.#inited = true;
this.notify(values);
return true;
}
notify(values?: FEATURE_FLAGS) {
this.emit('change', values);
}
getFlags(): FEATURE_FLAGS {
if (!this.#inited) {
const error = new Error(
'Trying access feature flag values before the storage been init.',
);
logger.persist.error({ namespace: '@coze-arch/bot-flags', error });
if (IS_DEV) {
throw error;
}
}
return this.#proxy as FEATURE_FLAGS;
}
clear() {
this.#cache = undefined;
this.#inited = false;
}
use(func: Interceptor) {
if (typeof func === 'function') {
this.#interceptors.unshift(func);
} else {
throw new Error('Unexpected retrieve func');
}
}
getPureFlags() {
return this.#cache;
}
}
// singleton
export const featureFlagStorage = new FeatureFlagStorage();

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const isObject = (obj: unknown) => typeof obj === 'object';
export const isEqual = (
obj1: Record<string, boolean> | undefined,
obj2: Record<string, boolean> | undefined,
) => {
// 有任意一个不是对象时,则直接返回 false
if (!isObject(obj1) || !isObject(obj2)) {
return false;
}
const o1 = obj1 as Record<string, boolean>;
const o2 = obj2 as Record<string, boolean>;
// 检查两个对象有相同的键数,如果数量不同,则一定不相等
if (Object.keys(o1).length !== Object.keys(o2).length) {
return false;
}
// 如果键数相同,然后我们检查每个键的值
for (const key in o1) {
// 如果键不存在于第二个对象或者值不同返回false
if (!(key in o2) || o1[key] !== o2[key]) {
return false;
}
}
// 如果所有键都存在于两个对象,并且所有的值都相同,返回 true
return true;
};

View File

@@ -0,0 +1,24 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const ONE_SEC = 1000;
export const wait = (ms: number) =>
new Promise(r => {
setTimeout(r, ms);
});
export const nextTick = () => new Promise(r => requestAnimationFrame(r));

View File

@@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"compilerOptions": {
"strictNullChecks": true,
"types": ["node"],
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo"
},
"include": ["src"],
"references": [
{
"path": "../bot-typings/tsconfig.build.json"
},
{
"path": "../../../config/eslint-config/tsconfig.build.json"
},
{
"path": "../../../config/ts-config/tsconfig.build.json"
},
{
"path": "../../../config/vitest-config/tsconfig.build.json"
},
{
"path": "../logger/tsconfig.build.json"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"composite": true
},
"references": [
{
"path": "./tsconfig.build.json"
},
{
"path": "./tsconfig.misc.json"
}
],
"exclude": ["**/*"]
}

View File

@@ -0,0 +1,17 @@
{
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"$schema": "https://json.schemastore.org/tsconfig",
"include": ["__tests__", "setup", "vitest.config.ts"],
"exclude": ["./dist"],
"references": [
{
"path": "./tsconfig.build.json"
}
],
"compilerOptions": {
"rootDir": "./",
"outDir": "./dist",
"strictNullChecks": true,
"types": ["vitest/globals", "node"]
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { defineConfig } from '@coze-arch/vitest-config';
export default defineConfig({
dirname: __dirname,
preset: 'web',
test: {
setupFiles: ['./setup'],
coverage: {
exclude: ['src/index.ts', 'src/types.ts', 'src/feature-flags.ts'],
all: true,
},
},
});