@flagdown/react 0.0.4 → 0.0.6
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/README.md +39 -3
- package/dist/index.d.ts +6 -3
- package/dist/index.js +101 -9
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Use the `Feature` component to switch between new UI and a fallback in React 18+.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
npm install @flagdown/react@0.0.
|
|
6
|
+
npm install @flagdown/react@0.0.6
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
```tsx
|
|
@@ -49,11 +49,47 @@ a successful later refresh restores values. An optional `onError(error)` callbac
|
|
|
49
49
|
can report failures. Changing project configuration immediately discards the
|
|
50
50
|
previous project's values. Unmounting cancels in-flight work and future refreshes.
|
|
51
51
|
|
|
52
|
-
Client tokens are public
|
|
53
|
-
flags. Replacing a token in the dashboard invalidates the previous token; update
|
|
52
|
+
Client tokens are public and project-scoped. They read flags and submit feature
|
|
53
|
+
error reports; they cannot manage flags or read error reports. Replacing a token in the dashboard invalidates the previous token; update
|
|
54
54
|
apps using it. Client-side flags are not an authorization boundary.
|
|
55
55
|
|
|
56
|
+
### Feature errors
|
|
57
|
+
|
|
58
|
+
`Feature` catches errors thrown by its enabled descendants during rendering,
|
|
59
|
+
constructors and React lifecycle/effect work. It renders `fallback` (or nothing)
|
|
60
|
+
and submits one best-effort error report, including the flag key, bounded error
|
|
61
|
+
message, JavaScript stack, React component stack and SDK version. Read reports in
|
|
62
|
+
the dashboard's **Recent feature errors** section. Errors do not change the flag
|
|
63
|
+
for other users. Repeated polls keep a crashed feature on its fallback. Remount
|
|
64
|
+
the feature, toggle it off then on, or change project configuration to retry it.
|
|
65
|
+
|
|
66
|
+
`onError` receives both flag-read failures and caught feature errors. Reporting
|
|
67
|
+
failures never recurse or prevent the fallback from rendering. Uploads time out
|
|
68
|
+
after five seconds and are not retried. Set `captureErrors={false}` on the provider
|
|
69
|
+
to disable uploads while retaining fallback behavior and the `onError` callback.
|
|
70
|
+
|
|
71
|
+
Only the enabled subtree is covered. Errors in event handlers, asynchronous
|
|
72
|
+
callbacks/promises, server rendering, and the fallback itself are not caught.
|
|
73
|
+
Nested features use the nearest boundary. No global browser listeners, cookies,
|
|
74
|
+
user identity or page URL are collected. URL queries/fragments and the configured
|
|
75
|
+
client token are removed from report text. Error messages and stacks can still
|
|
76
|
+
contain application data; disable uploads if those strings contain sensitive data.
|
|
77
|
+
|
|
78
|
+
The API accepts up to 60 new reports per project per minute, retains the latest
|
|
79
|
+
100 reports, and returns the newest 50 to authenticated project members. Client
|
|
80
|
+
reports are untrusted diagnostic data, not evidence for automatic rollback.
|
|
81
|
+
|
|
56
82
|
`Feature` is the SDK's flag consumption API. The evaluation context and evaluator
|
|
57
83
|
are internal; the package exposes no flag hooks or direct evaluation functions.
|
|
58
84
|
Use `FlagdownProvider` to configure your project. A provider without project
|
|
59
85
|
configuration renders each feature's fallback.
|
|
86
|
+
|
|
87
|
+
API transport types are generated from `openapi/flagdown-api.json` with
|
|
88
|
+
`openapi-typescript` before SDK typechecking and building. They stay internal;
|
|
89
|
+
the public exports remain `Feature`, `FlagdownProvider` and their prop types.
|
|
90
|
+
The runtime still rejects malformed or non-boolean flag values.
|
|
91
|
+
|
|
92
|
+
For SDK development, run `npm run --workspace @flagdown/react generate:types`
|
|
93
|
+
to regenerate `src/generated/api.d.ts`. This file is ignored by git and is never
|
|
94
|
+
the source of truth. Backend CI checks candidate contracts against the current
|
|
95
|
+
SDK before publishing the generated specification to this repository.
|
package/dist/index.d.ts
CHANGED
|
@@ -4,19 +4,22 @@ import { PropsWithChildren, ReactNode } from 'react';
|
|
|
4
4
|
type FeatureProps = PropsWithChildren<{
|
|
5
5
|
/** Key of the flag controlling this feature. */
|
|
6
6
|
flag: string;
|
|
7
|
-
/** Rendered while the flag is off,
|
|
7
|
+
/** Rendered while the flag is off, unavailable, or its enabled UI has crashed. */
|
|
8
8
|
fallback?: ReactNode;
|
|
9
9
|
}>;
|
|
10
10
|
declare function Feature({ flag, fallback, children }: FeatureProps): react.JSX.Element;
|
|
11
11
|
|
|
12
12
|
interface FlagdownProviderProps extends PropsWithChildren {
|
|
13
13
|
apiUrl?: string;
|
|
14
|
-
/** Public
|
|
14
|
+
/** Public project token for flag reads and feature error reports. */
|
|
15
15
|
clientToken?: string;
|
|
16
16
|
/** Refresh interval; minimum 1 second, default 10 seconds. */
|
|
17
17
|
refreshIntervalMs?: number;
|
|
18
|
+
/** Send errors caught by Feature to the project's recent error list. Default true. */
|
|
19
|
+
captureErrors?: boolean;
|
|
20
|
+
/** Called for flag-read failures and errors caught by Feature. */
|
|
18
21
|
onError?: (error: Error) => void;
|
|
19
22
|
}
|
|
20
|
-
declare function FlagdownProvider({ children, apiUrl, clientToken, refreshIntervalMs, onError }: FlagdownProviderProps): react.JSX.Element;
|
|
23
|
+
declare function FlagdownProvider({ children, apiUrl, clientToken, refreshIntervalMs, captureErrors, onError }: FlagdownProviderProps): react.JSX.Element;
|
|
21
24
|
|
|
22
25
|
export { Feature, type FeatureProps, FlagdownProvider, type FlagdownProviderProps };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/Feature.tsx
|
|
2
|
-
import { useContext } from "react";
|
|
2
|
+
import { Component, useContext } from "react";
|
|
3
3
|
|
|
4
4
|
// src/FlagdownContext.tsx
|
|
5
5
|
import { createContext } from "react";
|
|
@@ -7,42 +7,130 @@ var FlagdownContext = createContext(void 0);
|
|
|
7
7
|
|
|
8
8
|
// src/Feature.tsx
|
|
9
9
|
import { Fragment, jsx } from "react/jsx-runtime";
|
|
10
|
+
var FeatureBoundary = class extends Component {
|
|
11
|
+
state = { failed: false, scope: this.props.scope };
|
|
12
|
+
static getDerivedStateFromProps(props, state) {
|
|
13
|
+
return props.scope === state.scope ? null : { failed: false, scope: props.scope };
|
|
14
|
+
}
|
|
15
|
+
static getDerivedStateFromError() {
|
|
16
|
+
return { failed: true };
|
|
17
|
+
}
|
|
18
|
+
componentDidCatch(error, info) {
|
|
19
|
+
try {
|
|
20
|
+
this.props.reportError?.(this.props.flag, error, info.componentStack ?? "");
|
|
21
|
+
} catch {
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
render() {
|
|
25
|
+
return this.state.failed ? this.props.fallback : this.props.children;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
10
28
|
function Feature({ flag, fallback = null, children }) {
|
|
11
29
|
const evaluator = useContext(FlagdownContext);
|
|
12
30
|
if (!evaluator) throw new Error("Feature must be used within a FlagdownProvider");
|
|
13
|
-
return /* @__PURE__ */ jsx(Fragment, { children:
|
|
31
|
+
if (!evaluator.isEnabled(flag)) return /* @__PURE__ */ jsx(Fragment, { children: fallback });
|
|
32
|
+
return /* @__PURE__ */ jsx(FeatureBoundary, { flag, fallback, scope: evaluator.scope, reportError: evaluator.reportError, children }, flag);
|
|
14
33
|
}
|
|
15
34
|
|
|
16
35
|
// src/FlagdownProvider.tsx
|
|
17
|
-
import { useEffect, useMemo, useState, useRef } from "react";
|
|
36
|
+
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
|
18
37
|
|
|
19
38
|
// src/flags.ts
|
|
20
39
|
var disabledFlagEvaluator = {
|
|
21
40
|
isEnabled: () => false
|
|
22
41
|
};
|
|
23
42
|
|
|
43
|
+
// src/api-types.ts
|
|
44
|
+
var clientFlagsPath = "/v1/client/flags";
|
|
45
|
+
var clientErrorsPath = "/v1/client/errors";
|
|
46
|
+
|
|
24
47
|
// src/remote.ts
|
|
25
48
|
async function fetchFlagState(apiUrl, clientToken, signal) {
|
|
26
|
-
const
|
|
27
|
-
|
|
49
|
+
const headers = { "x-flagdown-client-token": clientToken };
|
|
50
|
+
const response = await fetch(apiUrl.replace(/\/$/, "") + clientFlagsPath, {
|
|
51
|
+
headers,
|
|
28
52
|
cache: "no-store",
|
|
29
53
|
signal
|
|
30
54
|
});
|
|
31
55
|
if (!response.ok) throw new Error(`Flagdown request failed (${response.status})`);
|
|
32
56
|
const data = await response.json();
|
|
33
|
-
if (!data || typeof data !== "object" || !("flags" in data) || !data.flags || typeof data.flags !== "object" || Array.isArray(data.flags)
|
|
57
|
+
if (!data || typeof data !== "object" || !("flags" in data) || !data.flags || typeof data.flags !== "object" || Array.isArray(data.flags)) {
|
|
34
58
|
throw new Error("Invalid Flagdown response");
|
|
35
59
|
}
|
|
36
|
-
|
|
60
|
+
const flags = Object.fromEntries(Object.entries(data.flags).map(([key, enabled]) => {
|
|
61
|
+
if (typeof enabled !== "boolean") throw new Error("Invalid Flagdown response");
|
|
62
|
+
return [key, enabled];
|
|
63
|
+
}));
|
|
64
|
+
const result = { flags };
|
|
65
|
+
return result.flags;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// package.json
|
|
69
|
+
var version = "0.0.6";
|
|
70
|
+
|
|
71
|
+
// src/errors.ts
|
|
72
|
+
function normalizeFeatureError(value) {
|
|
73
|
+
try {
|
|
74
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
75
|
+
} catch {
|
|
76
|
+
return new Error("Unknown feature error");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function reportFeatureError(apiUrl, clientToken, flagKey, error, componentStack) {
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
const timeout = setTimeout(() => controller.abort(), 5e3);
|
|
82
|
+
try {
|
|
83
|
+
const clean = (text, limit) => text.replaceAll(clientToken, "[client token]").replace(/https?:\/\/[^\s)]+/g, (url) => url.replace(/[?#].*$/, "")).slice(0, limit);
|
|
84
|
+
const report = {
|
|
85
|
+
eventId: crypto.randomUUID(),
|
|
86
|
+
flagKey,
|
|
87
|
+
name: clean(error.name || "Error", 100),
|
|
88
|
+
message: clean(error.message, 1e3),
|
|
89
|
+
stack: error.stack ? clean(error.stack, 4e3) : void 0,
|
|
90
|
+
componentStack: clean(componentStack, 2e3),
|
|
91
|
+
sdkVersion: version
|
|
92
|
+
};
|
|
93
|
+
let body = JSON.stringify(report);
|
|
94
|
+
if (new TextEncoder().encode(body).byteLength > 16384) {
|
|
95
|
+
delete report.stack;
|
|
96
|
+
delete report.componentStack;
|
|
97
|
+
body = JSON.stringify(report);
|
|
98
|
+
}
|
|
99
|
+
const headers = {
|
|
100
|
+
"x-flagdown-client-token": clientToken,
|
|
101
|
+
"Content-Type": "application/json"
|
|
102
|
+
};
|
|
103
|
+
await fetch(apiUrl.replace(/\/$/, "") + clientErrorsPath, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers,
|
|
106
|
+
body,
|
|
107
|
+
signal: controller.signal,
|
|
108
|
+
keepalive: true,
|
|
109
|
+
credentials: "omit",
|
|
110
|
+
cache: "no-store"
|
|
111
|
+
});
|
|
112
|
+
} catch {
|
|
113
|
+
} finally {
|
|
114
|
+
clearTimeout(timeout);
|
|
115
|
+
}
|
|
37
116
|
}
|
|
38
117
|
|
|
39
118
|
// src/FlagdownProvider.tsx
|
|
40
119
|
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
41
|
-
function FlagdownProvider({ children, apiUrl, clientToken, refreshIntervalMs = 1e4, onError }) {
|
|
120
|
+
function FlagdownProvider({ children, apiUrl, clientToken, refreshIntervalMs = 1e4, captureErrors = true, onError }) {
|
|
42
121
|
const errorHandler = useRef(onError);
|
|
43
122
|
useEffect(() => {
|
|
44
123
|
errorHandler.current = onError;
|
|
45
124
|
}, [onError]);
|
|
125
|
+
const scope = useMemo(() => ({}), [apiUrl, clientToken]);
|
|
126
|
+
const reportError = useCallback((flagKey, value2, componentStack) => {
|
|
127
|
+
const error = normalizeFeatureError(value2);
|
|
128
|
+
if (captureErrors && apiUrl && clientToken) void reportFeatureError(apiUrl, clientToken, flagKey, error, componentStack);
|
|
129
|
+
try {
|
|
130
|
+
errorHandler.current?.(error);
|
|
131
|
+
} catch {
|
|
132
|
+
}
|
|
133
|
+
}, [apiUrl, clientToken, captureErrors]);
|
|
46
134
|
const [snapshot, setSnapshot] = useState();
|
|
47
135
|
useEffect(() => {
|
|
48
136
|
if (!apiUrl || !clientToken) return;
|
|
@@ -75,7 +163,11 @@ function FlagdownProvider({ children, apiUrl, clientToken, refreshIntervalMs = 1
|
|
|
75
163
|
controller?.abort();
|
|
76
164
|
};
|
|
77
165
|
}, [apiUrl, clientToken, refreshIntervalMs]);
|
|
78
|
-
const value = useMemo(() =>
|
|
166
|
+
const value = useMemo(() => ({
|
|
167
|
+
scope,
|
|
168
|
+
reportError,
|
|
169
|
+
...snapshot && snapshot.apiUrl === apiUrl && snapshot.clientToken === clientToken ? { isEnabled: (key) => Object.hasOwn(snapshot.flags, key) && snapshot.flags[key] === true } : disabledFlagEvaluator
|
|
170
|
+
}), [snapshot, apiUrl, clientToken, scope, reportError]);
|
|
79
171
|
return /* @__PURE__ */ jsx2(FlagdownContext.Provider, { value, children });
|
|
80
172
|
}
|
|
81
173
|
export {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flagdown/react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "React SDK for evaluating Flagdown feature flags.",
|
|
@@ -28,9 +28,10 @@
|
|
|
28
28
|
"LICENSE"
|
|
29
29
|
],
|
|
30
30
|
"scripts": {
|
|
31
|
-
"
|
|
31
|
+
"generate:types": "node scripts/generate-api-types.mjs",
|
|
32
|
+
"build": "node scripts/generate-api-types.mjs && tsup src/index.ts --format esm --dts --clean",
|
|
32
33
|
"test": "bun test",
|
|
33
|
-
"typecheck": "tsc --noEmit"
|
|
34
|
+
"typecheck": "node scripts/generate-api-types.mjs && tsc --noEmit"
|
|
34
35
|
},
|
|
35
36
|
"peerDependencies": {
|
|
36
37
|
"react": ">=18",
|
|
@@ -38,6 +39,7 @@
|
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
41
|
"@types/react": "^18",
|
|
42
|
+
"openapi-typescript": "7.13.0",
|
|
41
43
|
"react": "^18",
|
|
42
44
|
"react-dom": "^18",
|
|
43
45
|
"tsup": "^8",
|