@shelbyb/daywalker-sdk 0.1.4 → 0.3.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/README.md +31 -2
- package/dist/analytics/index.d.ts +108 -0
- package/dist/analytics/index.js +371 -0
- package/dist/cli.js +5 -0
- package/dist/client.d.ts +15 -1
- package/dist/client.js +59 -12
- package/dist/config.d.ts +6 -0
- package/dist/config.js +22 -0
- package/dist/crypto/signatures.d.ts +18 -5
- package/dist/crypto/signatures.js +100 -41
- package/dist/flags/client.d.ts +102 -0
- package/dist/flags/client.js +302 -0
- package/dist/flags/common/events.d.ts +59 -0
- package/dist/flags/common/events.js +204 -0
- package/dist/flags/common/index.d.ts +5 -0
- package/dist/flags/common/index.js +12 -0
- package/dist/flags/common/sse.d.ts +62 -0
- package/dist/flags/common/sse.js +183 -0
- package/dist/flags/engine/bucket.d.ts +21 -0
- package/dist/flags/engine/bucket.js +41 -0
- package/dist/flags/engine/context.d.ts +30 -0
- package/dist/flags/engine/context.js +81 -0
- package/dist/flags/engine/evaluate.d.ts +54 -0
- package/dist/flags/engine/evaluate.js +177 -0
- package/dist/flags/engine/index.d.ts +7 -0
- package/dist/flags/engine/index.js +7 -0
- package/dist/flags/engine/operators.d.ts +13 -0
- package/dist/flags/engine/operators.js +139 -0
- package/dist/flags/engine/semver.d.ts +13 -0
- package/dist/flags/engine/semver.js +35 -0
- package/dist/flags/engine/sha1.d.ts +6 -0
- package/dist/flags/engine/sha1.js +43 -0
- package/dist/flags/engine/types.d.ts +120 -0
- package/dist/flags/engine/types.js +17 -0
- package/dist/flags/node.d.ts +97 -0
- package/dist/flags/node.js +263 -0
- package/dist/flags/react.d.ts +27 -0
- package/dist/flags/react.js +62 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/next.d.ts +15 -2
- package/dist/next.js +41 -9
- package/dist/openfeature/server.d.ts +26 -0
- package/dist/openfeature/server.js +42 -0
- package/dist/openfeature/shared.d.ts +19 -0
- package/dist/openfeature/shared.js +40 -0
- package/dist/openfeature/web.d.ts +29 -0
- package/dist/openfeature/web.js +50 -0
- package/dist/types.d.ts +3 -0
- package/docs/analytics.md +32 -0
- package/docs/flags-node.md +49 -0
- package/docs/flags-react.md +35 -0
- package/docs/flags.md +53 -0
- package/docs/openfeature.md +30 -0
- package/package.json +62 -7
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { Evaluator, MemoryStore, evaluateFlag, parseContext } from "./engine/index.js";
|
|
4
|
+
import { EventProcessor, SseClient, noopLogger } from "./common/index.js";
|
|
5
|
+
import { assertSecureUrl } from "../config.js";
|
|
6
|
+
/**
|
|
7
|
+
* Server-side client. One instance per process and SDK key.
|
|
8
|
+
*
|
|
9
|
+
* const client = init(process.env.DEVPULSE_SDK_KEY!, { baseUrl });
|
|
10
|
+
* await client.waitForInitialization({ timeoutMs: 5000 });
|
|
11
|
+
* const enabled = await client.variation("new-checkout", { key: user.id, email: user.email }, false);
|
|
12
|
+
*/
|
|
13
|
+
export class DevPulseNodeClient extends EventEmitter {
|
|
14
|
+
sdkKey;
|
|
15
|
+
options;
|
|
16
|
+
store = new MemoryStore();
|
|
17
|
+
stream = null;
|
|
18
|
+
poller = null;
|
|
19
|
+
etag = null;
|
|
20
|
+
events = null;
|
|
21
|
+
initialized = false;
|
|
22
|
+
ready;
|
|
23
|
+
resolveReady;
|
|
24
|
+
rejectReady;
|
|
25
|
+
streamFailures = 0;
|
|
26
|
+
logger;
|
|
27
|
+
base;
|
|
28
|
+
constructor(sdkKey, options) {
|
|
29
|
+
super();
|
|
30
|
+
this.sdkKey = sdkKey;
|
|
31
|
+
this.options = options;
|
|
32
|
+
this.logger = options.logger ?? noopLogger;
|
|
33
|
+
this.base = options.baseUrl.replace(/\/$/, "");
|
|
34
|
+
this.ready = new Promise((res, rej) => {
|
|
35
|
+
this.resolveReady = res;
|
|
36
|
+
this.rejectReady = rej;
|
|
37
|
+
});
|
|
38
|
+
this.ready.catch(() => undefined);
|
|
39
|
+
if (options.initialData || options.offline) {
|
|
40
|
+
if (options.initialData)
|
|
41
|
+
this.store.replace(options.initialData);
|
|
42
|
+
this.markReady();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
// Checked once the SDK key will actually be sent (offline / fixture clients make no requests).
|
|
46
|
+
assertSecureUrl("baseUrl", options.baseUrl);
|
|
47
|
+
if (options.sendEvents !== false) {
|
|
48
|
+
this.events = new EventProcessor({
|
|
49
|
+
url: `${this.base}/sdk/v1/events`,
|
|
50
|
+
headers: { authorization: sdkKey },
|
|
51
|
+
flushIntervalMs: options.flushIntervalMs ?? 5000,
|
|
52
|
+
privateAttributes: options.privateAttributes,
|
|
53
|
+
allAttributesPrivate: options.allAttributesPrivate,
|
|
54
|
+
fetch: options.fetch,
|
|
55
|
+
logger: this.logger,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (options.stream === false)
|
|
59
|
+
this.startPolling();
|
|
60
|
+
else
|
|
61
|
+
this.startStreaming();
|
|
62
|
+
}
|
|
63
|
+
headers() {
|
|
64
|
+
return { authorization: this.sdkKey, "user-agent": "DevPulseNode/0.1.0" };
|
|
65
|
+
}
|
|
66
|
+
startStreaming() {
|
|
67
|
+
this.stream = new SseClient({
|
|
68
|
+
url: `${this.base}/sdk/v1/server/stream`,
|
|
69
|
+
headers: () => this.headers(),
|
|
70
|
+
fetch: this.options.fetch,
|
|
71
|
+
onOpen: () => {
|
|
72
|
+
this.streamFailures = 0;
|
|
73
|
+
this.stopPolling();
|
|
74
|
+
},
|
|
75
|
+
onEvent: (ev) => this.onStreamEvent(ev.type, ev.data),
|
|
76
|
+
onError: (err) => {
|
|
77
|
+
this.logger.warn(`DevPulse stream error: ${err.message}`);
|
|
78
|
+
if (err.status === 401 || err.status === 403) {
|
|
79
|
+
this.fail(new Error("Invalid SDK key"));
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
// After repeated failures, poll so data stays fresh while we keep retrying the stream.
|
|
83
|
+
if (++this.streamFailures >= 3)
|
|
84
|
+
this.startPolling();
|
|
85
|
+
return true;
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
this.stream.start();
|
|
89
|
+
}
|
|
90
|
+
startPolling() {
|
|
91
|
+
if (this.poller)
|
|
92
|
+
return;
|
|
93
|
+
const interval = Math.max(30_000, this.options.pollIntervalMs ?? 30_000);
|
|
94
|
+
const poll = async () => {
|
|
95
|
+
try {
|
|
96
|
+
const res = await (this.options.fetch ?? fetch)(`${this.base}/sdk/v1/server/flags`, { headers: { ...this.headers(), ...(this.etag ? { "if-none-match": this.etag } : {}) } });
|
|
97
|
+
if (res.status === 304)
|
|
98
|
+
return this.markReady();
|
|
99
|
+
if (res.status === 401 || res.status === 403)
|
|
100
|
+
return this.fail(new Error("Invalid SDK key"));
|
|
101
|
+
if (!res.ok)
|
|
102
|
+
throw new Error(`Poll failed with ${res.status}`);
|
|
103
|
+
this.etag = res.headers.get("etag");
|
|
104
|
+
this.applyPut((await res.json()));
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
this.logger.warn("DevPulse poll failed", err);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
this.poller = setInterval(poll, interval);
|
|
111
|
+
this.poller.unref?.();
|
|
112
|
+
void poll();
|
|
113
|
+
}
|
|
114
|
+
stopPolling() {
|
|
115
|
+
if (this.poller)
|
|
116
|
+
clearInterval(this.poller);
|
|
117
|
+
this.poller = null;
|
|
118
|
+
}
|
|
119
|
+
onStreamEvent(type, data) {
|
|
120
|
+
try {
|
|
121
|
+
const payload = JSON.parse(data);
|
|
122
|
+
if (type === "put")
|
|
123
|
+
this.applyPut(payload.data);
|
|
124
|
+
else if (type === "patch" || type === "delete") {
|
|
125
|
+
const [, kind, key] = String(payload.path).split("/");
|
|
126
|
+
const isFlag = kind === "flags";
|
|
127
|
+
const prev = isFlag ? this.store.getFlag(key) : this.store.getSegment(key);
|
|
128
|
+
if (type === "patch")
|
|
129
|
+
this.store.upsert(isFlag ? "flag" : "segment", payload.data);
|
|
130
|
+
else if (!prev || prev.version < payload.version)
|
|
131
|
+
(isFlag ? this.store.flags : this.store.segments).delete(key);
|
|
132
|
+
if (isFlag)
|
|
133
|
+
this.emitUpdate(key);
|
|
134
|
+
else
|
|
135
|
+
for (const f of this.store.flags.values())
|
|
136
|
+
if (JSON.stringify(f.rules).includes(`"${key}"`))
|
|
137
|
+
this.emitUpdate(f.key);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
this.logger.error("Malformed stream event", err);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
applyPut(data) {
|
|
145
|
+
const before = new Map([...this.store.flags.entries()].map(([k, f]) => [k, f.version]));
|
|
146
|
+
this.store.replace(data);
|
|
147
|
+
for (const [k, f] of this.store.flags)
|
|
148
|
+
if (before.get(k) !== f.version)
|
|
149
|
+
this.emitUpdate(k);
|
|
150
|
+
for (const k of before.keys())
|
|
151
|
+
if (!this.store.flags.has(k))
|
|
152
|
+
this.emitUpdate(k);
|
|
153
|
+
this.markReady();
|
|
154
|
+
}
|
|
155
|
+
emitUpdate(key) {
|
|
156
|
+
this.emit("update", { key });
|
|
157
|
+
this.emit(`update:${key}`, { key });
|
|
158
|
+
}
|
|
159
|
+
markReady() {
|
|
160
|
+
if (this.initialized)
|
|
161
|
+
return;
|
|
162
|
+
this.initialized = true;
|
|
163
|
+
this.resolveReady();
|
|
164
|
+
this.emit("ready");
|
|
165
|
+
}
|
|
166
|
+
fail(err) {
|
|
167
|
+
if (!this.initialized)
|
|
168
|
+
this.rejectReady(err);
|
|
169
|
+
this.emit("failed", err);
|
|
170
|
+
}
|
|
171
|
+
// ---------- Public API ----------
|
|
172
|
+
waitForInitialization(opts = {}) {
|
|
173
|
+
const ready = this.ready.then(() => this);
|
|
174
|
+
if (!opts.timeoutMs)
|
|
175
|
+
return ready;
|
|
176
|
+
return Promise.race([ready, new Promise((_, rej) => setTimeout(() => rej(new Error("DevPulse initialization timed out")), opts.timeoutMs).unref?.())]);
|
|
177
|
+
}
|
|
178
|
+
get isInitialized() {
|
|
179
|
+
return this.initialized;
|
|
180
|
+
}
|
|
181
|
+
/** Evaluate a flag locally. Never throws; returns `defaultValue` on any problem. */
|
|
182
|
+
variationDetailSync(key, context, defaultValue) {
|
|
183
|
+
if (!this.initialized)
|
|
184
|
+
return { value: defaultValue, variationIndex: null, reason: { kind: "ERROR", errorKind: "CLIENT_NOT_READY" } };
|
|
185
|
+
const { prerequisites, ...detail } = evaluateFlag(this.store, key, context, defaultValue);
|
|
186
|
+
const flag = this.store.getFlag(key);
|
|
187
|
+
let out = detail;
|
|
188
|
+
if (detail.reason.kind !== "ERROR" && defaultValue !== null && typeof defaultValue !== typeof detail.value) {
|
|
189
|
+
out = { value: defaultValue, variationIndex: null, reason: { kind: "ERROR", errorKind: "WRONG_TYPE" } };
|
|
190
|
+
}
|
|
191
|
+
if (this.events) {
|
|
192
|
+
for (const p of prerequisites)
|
|
193
|
+
this.events.recordEvaluation({ key: p.flag.key, context, value: p.detail.value, default: null, variation: p.detail.variationIndex, version: p.flag.version, reason: p.detail.reason, trackEvents: p.flag.trackEvents });
|
|
194
|
+
this.events.recordEvaluation({ key, context, value: out.value, default: defaultValue, variation: out.variationIndex, version: flag?.version, reason: out.reason, trackEvents: flag?.trackEvents });
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
variationSync(key, context, defaultValue) {
|
|
199
|
+
return this.variationDetailSync(key, context, defaultValue).value;
|
|
200
|
+
}
|
|
201
|
+
async variation(key, context, defaultValue) {
|
|
202
|
+
return this.variationSync(key, context, defaultValue);
|
|
203
|
+
}
|
|
204
|
+
async variationDetail(key, context, defaultValue) {
|
|
205
|
+
return this.variationDetailSync(key, context, defaultValue);
|
|
206
|
+
}
|
|
207
|
+
async boolVariation(key, context, defaultValue) {
|
|
208
|
+
return this.variation(key, context, defaultValue);
|
|
209
|
+
}
|
|
210
|
+
async stringVariation(key, context, defaultValue) {
|
|
211
|
+
return this.variation(key, context, defaultValue);
|
|
212
|
+
}
|
|
213
|
+
async numberVariation(key, context, defaultValue) {
|
|
214
|
+
return this.variation(key, context, defaultValue);
|
|
215
|
+
}
|
|
216
|
+
async jsonVariation(key, context, defaultValue) {
|
|
217
|
+
return this.variation(key, context, defaultValue);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Evaluate every flag for a context, e.g. to bootstrap a browser client
|
|
221
|
+
* during server rendering. Doesn't record analytics.
|
|
222
|
+
*/
|
|
223
|
+
async allFlagsState(context, opts = {}) {
|
|
224
|
+
let ctx;
|
|
225
|
+
try {
|
|
226
|
+
ctx = parseContext(context);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return { valid: false, values: {}, toJSON: () => ({}) };
|
|
230
|
+
}
|
|
231
|
+
const evaluator = new Evaluator(this.store);
|
|
232
|
+
const state = {};
|
|
233
|
+
for (const f of this.store.flags.values()) {
|
|
234
|
+
if (opts.clientSideOnly && !f.clientSide)
|
|
235
|
+
continue;
|
|
236
|
+
const { detail } = evaluator.evaluate(f, ctx);
|
|
237
|
+
state[f.key] = { value: detail.value, variation: detail.variationIndex, version: f.version, ...(opts.withReasons ? { reason: detail.reason } : {}) };
|
|
238
|
+
}
|
|
239
|
+
return { valid: this.initialized, values: Object.fromEntries(Object.entries(state).map(([k, v]) => [k, v.value])), toJSON: () => state };
|
|
240
|
+
}
|
|
241
|
+
/** Secure mode hash for a context, for the browser SDK's `hash` option. */
|
|
242
|
+
secureModeHash(context) {
|
|
243
|
+
return createHmac("sha256", this.sdkKey).update(parseContext(context).canonicalKey).digest("hex");
|
|
244
|
+
}
|
|
245
|
+
track(key, context, data, metricValue) {
|
|
246
|
+
this.events?.track(key, context, data, metricValue);
|
|
247
|
+
}
|
|
248
|
+
identify(context) {
|
|
249
|
+
this.events?.identify(context);
|
|
250
|
+
}
|
|
251
|
+
async flush() {
|
|
252
|
+
await this.events?.flush();
|
|
253
|
+
}
|
|
254
|
+
async close() {
|
|
255
|
+
this.stream?.stop();
|
|
256
|
+
this.stopPolling();
|
|
257
|
+
await this.events?.close();
|
|
258
|
+
this.removeAllListeners();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
export function init(sdkKey, options) {
|
|
262
|
+
return new DevPulseNodeClient(sdkKey, options);
|
|
263
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type ClientOptions, type ContextInput, type DevPulseClient, type EvaluationDetail, type FlagSet, type FlagValue } from "./client.js";
|
|
3
|
+
export type { ClientOptions, ContextInput, DevPulseClient, FlagSet, FlagValue };
|
|
4
|
+
export interface DevPulseProviderProps {
|
|
5
|
+
clientSideId: string;
|
|
6
|
+
context: ContextInput;
|
|
7
|
+
options: ClientOptions;
|
|
8
|
+
/** Use an existing client instead of creating one. */
|
|
9
|
+
client?: DevPulseClient;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Provides a DevPulse client to the tree. On the server (SSR/RSC streaming)
|
|
14
|
+
* no network connection is opened; flags come from `options.bootstrap`, so the
|
|
15
|
+
* first client render matches the server HTML. Changing `context` calls identify().
|
|
16
|
+
*/
|
|
17
|
+
export declare function DevPulseProvider({ clientSideId, context, options, client: external, children }: DevPulseProviderProps): import("react").JSX.Element;
|
|
18
|
+
export declare function useDevPulseClient(): DevPulseClient;
|
|
19
|
+
/** Current value of one flag; re-renders only when that flag changes. */
|
|
20
|
+
export declare function useFlag<T extends FlagValue>(key: string, defaultValue: T): T;
|
|
21
|
+
export declare function useFlagDetail<T extends FlagValue>(key: string, defaultValue: T): EvaluationDetail<T>;
|
|
22
|
+
/** All flag values; re-renders when any flag changes. */
|
|
23
|
+
export declare function useFlags(): Record<string, FlagValue>;
|
|
24
|
+
/** True once flags have loaded (always true when bootstrapped). */
|
|
25
|
+
export declare function useFlagsReady(): boolean;
|
|
26
|
+
/** Track a custom event for metrics and experiments. */
|
|
27
|
+
export declare function useTrack(): (key: string, data?: unknown, metricValue?: number) => void;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { createClient } from "./client.js";
|
|
4
|
+
const ClientContext = createContext(null);
|
|
5
|
+
const isBrowser = typeof window !== "undefined";
|
|
6
|
+
/**
|
|
7
|
+
* Provides a DevPulse client to the tree. On the server (SSR/RSC streaming)
|
|
8
|
+
* no network connection is opened; flags come from `options.bootstrap`, so the
|
|
9
|
+
* first client render matches the server HTML. Changing `context` calls identify().
|
|
10
|
+
*/
|
|
11
|
+
export function DevPulseProvider({ clientSideId, context, options, client: external, children }) {
|
|
12
|
+
const [client] = useState(() => external ?? createClient(clientSideId, context, isBrowser ? options : { ...options, streaming: false, sendEvents: false, bootstrap: options.bootstrap ?? {} }));
|
|
13
|
+
const contextJson = JSON.stringify(context);
|
|
14
|
+
const first = useRef(true);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (first.current) {
|
|
17
|
+
first.current = false;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
void client.identify(JSON.parse(contextJson));
|
|
21
|
+
}, [client, contextJson]);
|
|
22
|
+
useEffect(() => () => void (!external && client.close()), [client, external]);
|
|
23
|
+
return _jsx(ClientContext.Provider, { value: client, children: children });
|
|
24
|
+
}
|
|
25
|
+
export function useDevPulseClient() {
|
|
26
|
+
const client = useContext(ClientContext);
|
|
27
|
+
if (!client)
|
|
28
|
+
throw new Error("useDevPulseClient must be used inside <DevPulseProvider>");
|
|
29
|
+
return client;
|
|
30
|
+
}
|
|
31
|
+
/** Current value of one flag; re-renders only when that flag changes. */
|
|
32
|
+
export function useFlag(key, defaultValue) {
|
|
33
|
+
return useFlagDetail(key, defaultValue).value;
|
|
34
|
+
}
|
|
35
|
+
export function useFlagDetail(key, defaultValue) {
|
|
36
|
+
const client = useDevPulseClient();
|
|
37
|
+
const defaultJson = JSON.stringify(defaultValue);
|
|
38
|
+
const subscribe = useMemo(() => (cb) => client.on(`change:${key}`, cb), [client, key]);
|
|
39
|
+
// Snapshot is the serialized value so useSyncExternalStore sees stable identities.
|
|
40
|
+
const snapshot = () => JSON.stringify(client.allFlags()[key] ?? null);
|
|
41
|
+
const serialized = useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
42
|
+
// Record the evaluation (analytics) once per distinct value.
|
|
43
|
+
return useMemo(() => client.variationDetail(key, JSON.parse(defaultJson)), [client, key, defaultJson, serialized]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
44
|
+
}
|
|
45
|
+
/** All flag values; re-renders when any flag changes. */
|
|
46
|
+
export function useFlags() {
|
|
47
|
+
const client = useDevPulseClient();
|
|
48
|
+
const subscribe = useMemo(() => (cb) => client.on("change", cb), [client]);
|
|
49
|
+
const json = useSyncExternalStore(subscribe, () => JSON.stringify(client.allFlags()), () => JSON.stringify(client.allFlags()));
|
|
50
|
+
return useMemo(() => JSON.parse(json), [json]);
|
|
51
|
+
}
|
|
52
|
+
/** True once flags have loaded (always true when bootstrapped). */
|
|
53
|
+
export function useFlagsReady() {
|
|
54
|
+
const client = useDevPulseClient();
|
|
55
|
+
const subscribe = useMemo(() => (cb) => client.on("ready", cb), [client]);
|
|
56
|
+
return useSyncExternalStore(subscribe, () => client.isInitialized, () => client.isInitialized);
|
|
57
|
+
}
|
|
58
|
+
/** Track a custom event for metrics and experiments. */
|
|
59
|
+
export function useTrack() {
|
|
60
|
+
const client = useDevPulseClient();
|
|
61
|
+
return (key, data, metricValue) => client.track(key, data, metricValue);
|
|
62
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { Daywalker, createDaywalker, daywalker, } from "./client.js";
|
|
2
2
|
export { DaywalkerError, DaywalkerAuthError, DaywalkerRateLimitError, } from "./types.js";
|
|
3
3
|
export type { DaywalkerConfig, RateLimitWindow, TokenPayload, ValidateErrorCode, ValidateResult, RetryOptions, SelfCheckResult, PublicJwk, SigningKey, } from "./types.js";
|
|
4
|
-
export { resolveConfig, publicHostname, } from "./config.js";
|
|
4
|
+
export { resolveConfig, publicHostname, assertSecureUrl, } from "./config.js";
|
|
5
5
|
export type { ResolvedConfig, } from "./config.js";
|
|
6
6
|
export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
|
|
7
7
|
export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, } from "./crypto/index.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { Daywalker, createDaywalker, daywalker, } from "./client.js";
|
|
2
2
|
export { DaywalkerError, DaywalkerAuthError, DaywalkerRateLimitError, } from "./types.js";
|
|
3
|
-
export { resolveConfig, publicHostname, } from "./config.js";
|
|
3
|
+
export { resolveConfig, publicHostname, assertSecureUrl, } from "./config.js";
|
|
4
4
|
export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
|
package/dist/next.d.ts
CHANGED
|
@@ -7,6 +7,13 @@ import type { TokenPayload } from "./types.js";
|
|
|
7
7
|
*/
|
|
8
8
|
export declare const SERVER_ACTION_ID_PATTERN: RegExp;
|
|
9
9
|
export declare const DEFAULT_BYPASS_PATHS: string[];
|
|
10
|
+
/** File extensions treated as static assets (outside /api/) and passed through without auth. */
|
|
11
|
+
export declare const STATIC_FILE_EXTENSIONS: RegExp;
|
|
12
|
+
/**
|
|
13
|
+
* Static assets that skip auth: Next's build output and image optimizer, the favicon, and files with
|
|
14
|
+
* an allowlisted extension. `/_next/data/` (getServerSideProps JSON) and `/api/*` are never static.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isStaticAssetPath(pathname: string): boolean;
|
|
10
17
|
export interface NextMiddlewareOptions {
|
|
11
18
|
client?: Daywalker;
|
|
12
19
|
serviceSlug?: string;
|
|
@@ -28,8 +35,8 @@ export declare function sanitizeRedirectPath(path: string): string | null;
|
|
|
28
35
|
* Implements:
|
|
29
36
|
* 1. Server Action probe defense (CVE-2025-55182 scanner mitigation)
|
|
30
37
|
* 2. Static asset and health check bypass
|
|
31
|
-
* 3.
|
|
32
|
-
* 4. Distinct handling for API routes (JSON + CORS) vs UI pages (redirect)
|
|
38
|
+
* 3. Per-request token auth: requests without `Authorization: Bearer` / `x-api-token` get 401
|
|
39
|
+
* 4. Distinct handling for API routes (JSON + CORS) vs UI pages (redirect to `errorPath` with `from`)
|
|
33
40
|
*
|
|
34
41
|
* Example:
|
|
35
42
|
* // middleware.ts
|
|
@@ -42,6 +49,12 @@ export declare function sanitizeRedirectPath(path: string): string | null;
|
|
|
42
49
|
* };
|
|
43
50
|
*/
|
|
44
51
|
export declare function createDaywalkerMiddleware(options?: NextMiddlewareOptions): (request: Request) => Promise<Response>;
|
|
52
|
+
/**
|
|
53
|
+
* Service health gate: checks this service's own DAYWALKER_API_KEY (not the caller's token) and
|
|
54
|
+
* routes every request to `errorPath` while it is invalid, redirecting back once it heals.
|
|
55
|
+
* It does NOT authenticate callers; use `createDaywalkerMiddleware` or `withDaywalkerAuth` for that.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createDaywalkerServiceHealthMiddleware(options?: NextMiddlewareOptions): (request: Request) => Promise<Response>;
|
|
45
58
|
/**
|
|
46
59
|
* Next.js App Router route handler wrapper with Daywalker token validation.
|
|
47
60
|
* Extracts `Authorization: Bearer <token>` or `x-api-token`, validates via client,
|
package/dist/next.js
CHANGED
|
@@ -12,6 +12,23 @@ export const DEFAULT_BYPASS_PATHS = [
|
|
|
12
12
|
"/robots.txt",
|
|
13
13
|
"/sitemap.xml",
|
|
14
14
|
];
|
|
15
|
+
/** File extensions treated as static assets (outside /api/) and passed through without auth. */
|
|
16
|
+
export const STATIC_FILE_EXTENSIONS = /\.(?:js|css|png|jpe?g|gif|webp|avif|svg|ico|woff2?|map|txt)$/i;
|
|
17
|
+
/**
|
|
18
|
+
* Static assets that skip auth: Next's build output and image optimizer, the favicon, and files with
|
|
19
|
+
* an allowlisted extension. `/_next/data/` (getServerSideProps JSON) and `/api/*` are never static.
|
|
20
|
+
*/
|
|
21
|
+
export function isStaticAssetPath(pathname) {
|
|
22
|
+
if (pathname === "/api" || pathname.startsWith("/api/"))
|
|
23
|
+
return false;
|
|
24
|
+
if (pathname.startsWith("/_next/static/") || pathname === "/_next/image" || pathname.startsWith("/_next/image/"))
|
|
25
|
+
return true;
|
|
26
|
+
if (pathname === "/favicon.ico")
|
|
27
|
+
return true;
|
|
28
|
+
if (pathname.startsWith("/_next/"))
|
|
29
|
+
return false;
|
|
30
|
+
return STATIC_FILE_EXTENSIONS.test(pathname);
|
|
31
|
+
}
|
|
15
32
|
/**
|
|
16
33
|
* Sanitizes open redirect path parameter (`from`).
|
|
17
34
|
* Prevents protocol-relative URLs (`//evil.com`), backslashes (`/\evil.com`), and control characters.
|
|
@@ -42,8 +59,8 @@ function extractToken(req) {
|
|
|
42
59
|
* Implements:
|
|
43
60
|
* 1. Server Action probe defense (CVE-2025-55182 scanner mitigation)
|
|
44
61
|
* 2. Static asset and health check bypass
|
|
45
|
-
* 3.
|
|
46
|
-
* 4. Distinct handling for API routes (JSON + CORS) vs UI pages (redirect)
|
|
62
|
+
* 3. Per-request token auth: requests without `Authorization: Bearer` / `x-api-token` get 401
|
|
63
|
+
* 4. Distinct handling for API routes (JSON + CORS) vs UI pages (redirect to `errorPath` with `from`)
|
|
47
64
|
*
|
|
48
65
|
* Example:
|
|
49
66
|
* // middleware.ts
|
|
@@ -57,6 +74,24 @@ function extractToken(req) {
|
|
|
57
74
|
*/
|
|
58
75
|
export function createDaywalkerMiddleware(options = {}) {
|
|
59
76
|
const client = options.client ?? daywalker;
|
|
77
|
+
return buildMiddleware(options, async (request) => {
|
|
78
|
+
const token = extractToken(request);
|
|
79
|
+
if (!token) {
|
|
80
|
+
return { ok: false, error: "INVALID_TOKEN", status: 401, message: "Missing Authorization header or x-api-token" };
|
|
81
|
+
}
|
|
82
|
+
return client.validateToken(token, { serviceSlug: options.serviceSlug });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Service health gate: checks this service's own DAYWALKER_API_KEY (not the caller's token) and
|
|
87
|
+
* routes every request to `errorPath` while it is invalid, redirecting back once it heals.
|
|
88
|
+
* It does NOT authenticate callers; use `createDaywalkerMiddleware` or `withDaywalkerAuth` for that.
|
|
89
|
+
*/
|
|
90
|
+
export function createDaywalkerServiceHealthMiddleware(options = {}) {
|
|
91
|
+
const client = options.client ?? daywalker;
|
|
92
|
+
return buildMiddleware(options, () => client.validateServiceKey({ serviceSlug: options.serviceSlug }));
|
|
93
|
+
}
|
|
94
|
+
function buildMiddleware(options, authenticate) {
|
|
60
95
|
const bypassPaths = options.bypassPaths ?? DEFAULT_BYPASS_PATHS;
|
|
61
96
|
const errorPath = options.errorPath ?? "/service-error";
|
|
62
97
|
const homePath = options.homePath ?? "/";
|
|
@@ -80,20 +115,17 @@ export function createDaywalkerMiddleware(options = {}) {
|
|
|
80
115
|
}
|
|
81
116
|
}
|
|
82
117
|
// 1. Bypass static files and health check probes
|
|
83
|
-
if (bypassPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`)) ||
|
|
84
|
-
pathname.startsWith("/_next") ||
|
|
85
|
-
(pathname.includes(".") && !pathname.endsWith(".json"))) {
|
|
118
|
+
if (bypassPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`)) || isStaticAssetPath(pathname)) {
|
|
86
119
|
return globalThis.NextResponse?.next
|
|
87
120
|
? globalThis.NextResponse.next()
|
|
88
121
|
: new Response(null, { headers: { "x-middleware-next": "1" } });
|
|
89
122
|
}
|
|
90
|
-
// 2.
|
|
91
|
-
const
|
|
92
|
-
const authResult = await client.validateToken(incomingToken ? { token: incomingToken, serviceSlug: options.serviceSlug } : { serviceSlug: options.serviceSlug });
|
|
123
|
+
// 2. Authenticate (caller's token, or the service key for the health gate)
|
|
124
|
+
const authResult = await authenticate(request);
|
|
93
125
|
// 3. Handle service error page route
|
|
94
126
|
if (pathname === errorPath) {
|
|
95
127
|
if (authResult.ok) {
|
|
96
|
-
//
|
|
128
|
+
// Authorized again: heal/redirect back to home
|
|
97
129
|
const redirectUrl = new URL(homePath, request.url);
|
|
98
130
|
return globalThis.NextResponse?.redirect
|
|
99
131
|
? globalThis.NextResponse.redirect(redirectUrl)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { EvaluationContext, JsonValue, Logger as OFLogger, Provider, ResolutionDetails } from "@openfeature/server-sdk";
|
|
2
|
+
import { OpenFeatureEventEmitter } from "@openfeature/server-sdk";
|
|
3
|
+
import { type DevPulseNodeClient, type NodeClientOptions } from "../flags/node.js";
|
|
4
|
+
/**
|
|
5
|
+
* OpenFeature provider for servers, backed by @shelbyb/daywalker-sdk/flags/node (local evaluation,
|
|
6
|
+
* real-time updates).
|
|
7
|
+
*
|
|
8
|
+
* await OpenFeature.setProviderAndWait(new DevPulseServerProvider(sdkKey, { baseUrl }));
|
|
9
|
+
* const enabled = await OpenFeature.getClient().getBooleanValue("new-checkout", false, { targetingKey: user.id });
|
|
10
|
+
*/
|
|
11
|
+
export declare class DevPulseServerProvider implements Provider {
|
|
12
|
+
readonly metadata: {
|
|
13
|
+
readonly name: "devpulse";
|
|
14
|
+
};
|
|
15
|
+
readonly runsOn: "server";
|
|
16
|
+
readonly events: OpenFeatureEventEmitter;
|
|
17
|
+
readonly client: DevPulseNodeClient;
|
|
18
|
+
constructor(sdkKeyOrClient: string | DevPulseNodeClient, options?: NodeClientOptions);
|
|
19
|
+
initialize(): Promise<void>;
|
|
20
|
+
onClose(): Promise<void>;
|
|
21
|
+
private resolve;
|
|
22
|
+
resolveBooleanEvaluation(key: string, def: boolean, ctx: EvaluationContext, _logger: OFLogger): Promise<ResolutionDetails<boolean>>;
|
|
23
|
+
resolveStringEvaluation(key: string, def: string, ctx: EvaluationContext, _logger: OFLogger): Promise<ResolutionDetails<string>>;
|
|
24
|
+
resolveNumberEvaluation(key: string, def: number, ctx: EvaluationContext, _logger: OFLogger): Promise<ResolutionDetails<number>>;
|
|
25
|
+
resolveObjectEvaluation<T extends JsonValue>(key: string, def: T, ctx: EvaluationContext, _logger: OFLogger): Promise<ResolutionDetails<T>>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { OpenFeatureEventEmitter, ProviderEvents } from "@openfeature/server-sdk";
|
|
2
|
+
import { init } from "../flags/node.js";
|
|
3
|
+
import { toDevPulseContext, toResolution, typeCheck } from "./shared.js";
|
|
4
|
+
/**
|
|
5
|
+
* OpenFeature provider for servers, backed by @shelbyb/daywalker-sdk/flags/node (local evaluation,
|
|
6
|
+
* real-time updates).
|
|
7
|
+
*
|
|
8
|
+
* await OpenFeature.setProviderAndWait(new DevPulseServerProvider(sdkKey, { baseUrl }));
|
|
9
|
+
* const enabled = await OpenFeature.getClient().getBooleanValue("new-checkout", false, { targetingKey: user.id });
|
|
10
|
+
*/
|
|
11
|
+
export class DevPulseServerProvider {
|
|
12
|
+
metadata = { name: "devpulse" };
|
|
13
|
+
runsOn = "server";
|
|
14
|
+
events = new OpenFeatureEventEmitter();
|
|
15
|
+
client;
|
|
16
|
+
constructor(sdkKeyOrClient, options) {
|
|
17
|
+
this.client = typeof sdkKeyOrClient === "string" ? init(sdkKeyOrClient, options) : sdkKeyOrClient;
|
|
18
|
+
this.client.on("update", ({ key }) => this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged: [key] }));
|
|
19
|
+
}
|
|
20
|
+
async initialize() {
|
|
21
|
+
await this.client.waitForInitialization();
|
|
22
|
+
}
|
|
23
|
+
async onClose() {
|
|
24
|
+
await this.client.close();
|
|
25
|
+
}
|
|
26
|
+
resolve(key, def, ctx, type) {
|
|
27
|
+
const detail = this.client.variationDetailSync(key, toDevPulseContext(ctx), def);
|
|
28
|
+
return toResolution(typeCheck(detail, type, def));
|
|
29
|
+
}
|
|
30
|
+
async resolveBooleanEvaluation(key, def, ctx, _logger) {
|
|
31
|
+
return this.resolve(key, def, ctx, "boolean");
|
|
32
|
+
}
|
|
33
|
+
async resolveStringEvaluation(key, def, ctx, _logger) {
|
|
34
|
+
return this.resolve(key, def, ctx, "string");
|
|
35
|
+
}
|
|
36
|
+
async resolveNumberEvaluation(key, def, ctx, _logger) {
|
|
37
|
+
return this.resolve(key, def, ctx, "number");
|
|
38
|
+
}
|
|
39
|
+
async resolveObjectEvaluation(key, def, ctx, _logger) {
|
|
40
|
+
return this.resolve(key, def, ctx, "object");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { EvaluationContext, ResolutionDetails } from "@openfeature/core";
|
|
2
|
+
type Reason = {
|
|
3
|
+
kind: string;
|
|
4
|
+
errorKind?: string;
|
|
5
|
+
inExperiment?: boolean;
|
|
6
|
+
} | null | undefined;
|
|
7
|
+
type Detail<T> = {
|
|
8
|
+
value: T;
|
|
9
|
+
variationIndex: number | null;
|
|
10
|
+
reason: Reason;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* OpenFeature context → DevPulse context. `targetingKey` becomes the key;
|
|
14
|
+
* `kind` selects the context kind (default "user"); multi-kind contexts pass through.
|
|
15
|
+
*/
|
|
16
|
+
export declare function toDevPulseContext(ctx: EvaluationContext): Record<string, unknown>;
|
|
17
|
+
export declare function toResolution<T>(detail: Detail<T>): ResolutionDetails<T>;
|
|
18
|
+
export declare function typeCheck<T>(detail: Detail<unknown>, expected: string, defaultValue: T): Detail<T>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ErrorCode, StandardResolutionReasons } from "@openfeature/core";
|
|
2
|
+
/**
|
|
3
|
+
* OpenFeature context → DevPulse context. `targetingKey` becomes the key;
|
|
4
|
+
* `kind` selects the context kind (default "user"); multi-kind contexts pass through.
|
|
5
|
+
*/
|
|
6
|
+
export function toDevPulseContext(ctx) {
|
|
7
|
+
const { targetingKey, ...rest } = ctx;
|
|
8
|
+
if (rest.kind === "multi")
|
|
9
|
+
return rest;
|
|
10
|
+
return { ...rest, kind: typeof rest.kind === "string" ? rest.kind : "user", key: targetingKey ?? rest.key };
|
|
11
|
+
}
|
|
12
|
+
const ERRORS = {
|
|
13
|
+
FLAG_NOT_FOUND: ErrorCode.FLAG_NOT_FOUND,
|
|
14
|
+
WRONG_TYPE: ErrorCode.TYPE_MISMATCH,
|
|
15
|
+
USER_NOT_SPECIFIED: ErrorCode.TARGETING_KEY_MISSING,
|
|
16
|
+
CLIENT_NOT_READY: ErrorCode.PROVIDER_NOT_READY,
|
|
17
|
+
MALFORMED_FLAG: ErrorCode.PARSE_ERROR,
|
|
18
|
+
};
|
|
19
|
+
export function toResolution(detail) {
|
|
20
|
+
const r = detail.reason;
|
|
21
|
+
if (r?.kind === "ERROR") {
|
|
22
|
+
return { value: detail.value, reason: StandardResolutionReasons.ERROR, errorCode: ERRORS[r.errorKind ?? ""] ?? ErrorCode.GENERAL, errorMessage: r.errorKind };
|
|
23
|
+
}
|
|
24
|
+
let reason = StandardResolutionReasons.UNKNOWN;
|
|
25
|
+
if (r?.kind === "OFF")
|
|
26
|
+
reason = StandardResolutionReasons.DISABLED;
|
|
27
|
+
else if (r?.kind === "TARGET_MATCH" || r?.kind === "RULE_MATCH")
|
|
28
|
+
reason = r.inExperiment ? StandardResolutionReasons.SPLIT : StandardResolutionReasons.TARGETING_MATCH;
|
|
29
|
+
else if (r?.kind === "FALLTHROUGH")
|
|
30
|
+
reason = r.inExperiment ? StandardResolutionReasons.SPLIT : StandardResolutionReasons.DEFAULT;
|
|
31
|
+
else if (r?.kind === "PREREQUISITE_FAILED")
|
|
32
|
+
reason = StandardResolutionReasons.DISABLED;
|
|
33
|
+
return { value: detail.value, reason, ...(detail.variationIndex !== null ? { variant: String(detail.variationIndex) } : {}), flagMetadata: r ? { devpulseReason: r.kind } : {} };
|
|
34
|
+
}
|
|
35
|
+
export function typeCheck(detail, expected, defaultValue) {
|
|
36
|
+
const actual = Array.isArray(detail.value) ? "object" : typeof detail.value;
|
|
37
|
+
if (detail.reason?.kind !== "ERROR" && actual !== expected)
|
|
38
|
+
return { value: defaultValue, variationIndex: null, reason: { kind: "ERROR", errorKind: "WRONG_TYPE" } };
|
|
39
|
+
return detail;
|
|
40
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { EvaluationContext, JsonValue, Logger as OFLogger, Provider, ResolutionDetails } from "@openfeature/web-sdk";
|
|
2
|
+
import { OpenFeatureEventEmitter } from "@openfeature/web-sdk";
|
|
3
|
+
import { type ClientOptions } from "../flags/client.js";
|
|
4
|
+
/**
|
|
5
|
+
* OpenFeature provider for browsers, backed by @shelbyb/daywalker-sdk/flags. Flag
|
|
6
|
+
* values update in real time and emit PROVIDER_CONFIGURATION_CHANGED.
|
|
7
|
+
*
|
|
8
|
+
* await OpenFeature.setContext({ targetingKey: user.id });
|
|
9
|
+
* await OpenFeature.setProviderAndWait(new DevPulseWebProvider(clientSideId, { baseUrl }));
|
|
10
|
+
*/
|
|
11
|
+
export declare class DevPulseWebProvider implements Provider {
|
|
12
|
+
private readonly clientSideId;
|
|
13
|
+
private readonly options;
|
|
14
|
+
readonly metadata: {
|
|
15
|
+
readonly name: "devpulse";
|
|
16
|
+
};
|
|
17
|
+
readonly runsOn: "client";
|
|
18
|
+
readonly events: OpenFeatureEventEmitter;
|
|
19
|
+
private client;
|
|
20
|
+
constructor(clientSideId: string, options: ClientOptions);
|
|
21
|
+
initialize(context?: EvaluationContext): Promise<void>;
|
|
22
|
+
onContextChange(_old: EvaluationContext, next: EvaluationContext): Promise<void>;
|
|
23
|
+
onClose(): Promise<void>;
|
|
24
|
+
private resolve;
|
|
25
|
+
resolveBooleanEvaluation(key: string, def: boolean, _ctx: EvaluationContext, _logger: OFLogger): ResolutionDetails<boolean>;
|
|
26
|
+
resolveStringEvaluation(key: string, def: string, _ctx: EvaluationContext, _logger: OFLogger): ResolutionDetails<string>;
|
|
27
|
+
resolveNumberEvaluation(key: string, def: number, _ctx: EvaluationContext, _logger: OFLogger): ResolutionDetails<number>;
|
|
28
|
+
resolveObjectEvaluation<T extends JsonValue>(key: string, def: T, _ctx: EvaluationContext, _logger: OFLogger): ResolutionDetails<T>;
|
|
29
|
+
}
|