@shelbyb/daywalker-sdk 0.3.0 → 0.4.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 -23
- package/dist/analytics/index.d.ts +16 -2
- package/dist/analytics/index.js +79 -14
- package/dist/cli.js +133 -31
- package/dist/client.d.ts +29 -2
- package/dist/client.js +314 -62
- package/dist/config.d.ts +8 -2
- package/dist/config.js +14 -0
- package/dist/crypto/encoding.d.ts +5 -0
- package/dist/crypto/encoding.js +14 -0
- package/dist/crypto/index.d.ts +3 -3
- package/dist/crypto/index.js +2 -2
- package/dist/crypto/keys.d.ts +3 -3
- package/dist/crypto/keys.js +28 -7
- package/dist/crypto/signatures.d.ts +42 -12
- package/dist/crypto/signatures.js +236 -79
- package/dist/express.d.ts +1 -0
- package/dist/express.js +8 -6
- package/dist/flags/client.d.ts +32 -4
- package/dist/flags/client.js +74 -12
- package/dist/flags/common/events.d.ts +9 -0
- package/dist/flags/common/events.js +51 -6
- package/dist/flags/common/index.d.ts +3 -3
- package/dist/flags/common/index.js +3 -3
- package/dist/flags/common/sse.d.ts +24 -3
- package/dist/flags/common/sse.js +85 -15
- package/dist/flags/common/version.d.ts +2 -0
- package/dist/flags/common/version.js +2 -0
- package/dist/flags/engine/index.d.ts +1 -1
- package/dist/flags/engine/index.js +1 -1
- package/dist/flags/engine/operators.d.ts +20 -3
- package/dist/flags/engine/operators.js +38 -32
- package/dist/flags/node.d.ts +26 -14
- package/dist/flags/node.js +51 -7
- package/dist/flags/react.d.ts +6 -4
- package/dist/flags/react.js +47 -17
- package/dist/index.d.ts +7 -6
- package/dist/index.js +4 -4
- package/dist/middleware.d.ts +18 -0
- package/dist/middleware.js +28 -7
- package/dist/next.d.ts +52 -8
- package/dist/next.js +151 -37
- package/dist/openfeature/server.d.ts +3 -0
- package/dist/openfeature/server.js +14 -2
- package/dist/openfeature/shared.d.ts +1 -1
- package/dist/openfeature/shared.js +22 -2
- package/dist/openfeature/web.js +9 -3
- package/dist/types.d.ts +56 -4
- package/dist/types.js +6 -0
- package/docs/analytics.md +1 -1
- package/docs/flags-node.md +1 -1
- package/docs/flags.md +2 -1
- package/package.json +32 -14
- package/assets/logo-512.png +0 -0
|
@@ -1,12 +1,43 @@
|
|
|
1
|
+
import { RE2JS } from "re2js";
|
|
1
2
|
import { compareSemVer, parseSemVer } from "./semver.js";
|
|
2
|
-
|
|
3
|
-
/** `matches` limits: longer inputs or patterns never match. */
|
|
3
|
+
/** `matches` limits: longer inputs or patterns never match (same limits as the DevPulse server). */
|
|
4
4
|
export const MAX_MATCH_INPUT_LENGTH = 1024;
|
|
5
5
|
export const MAX_MATCH_PATTERN_LENGTH = 500;
|
|
6
|
-
|
|
6
|
+
/** Server-parity aliases (`@devpulse/flag-engine` names). */
|
|
7
|
+
export const MAX_PATTERN_LENGTH = MAX_MATCH_PATTERN_LENGTH;
|
|
8
|
+
export const MAX_MATCH_INPUT = MAX_MATCH_INPUT_LENGTH;
|
|
9
|
+
const regexCache = new Map();
|
|
10
|
+
/**
|
|
11
|
+
* Compile a user-supplied pattern with RE2 (linear-time matching, no backtracking), so no
|
|
12
|
+
* pattern can stall the process however it's written (ReDoS), and every SDK agrees with the
|
|
13
|
+
* DevPulse server on what matches. RE2 has no backreferences or lookaround; patterns using
|
|
14
|
+
* them don't compile and return null (never match).
|
|
15
|
+
*/
|
|
16
|
+
export function compilePattern(pattern) {
|
|
17
|
+
if (!regexCache.has(pattern)) {
|
|
18
|
+
let re = null;
|
|
19
|
+
if (pattern.length <= MAX_MATCH_PATTERN_LENGTH) {
|
|
20
|
+
try {
|
|
21
|
+
re = RE2JS.compile(pattern);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
re = null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (regexCache.size > 1000)
|
|
28
|
+
regexCache.clear();
|
|
29
|
+
regexCache.set(pattern, re);
|
|
30
|
+
}
|
|
31
|
+
return regexCache.get(pattern);
|
|
32
|
+
}
|
|
33
|
+
/** Whether a `matches` pattern is accepted: bounded length and valid RE2 syntax. */
|
|
34
|
+
export function isSafePattern(pattern) {
|
|
35
|
+
return compilePattern(pattern) !== null;
|
|
36
|
+
}
|
|
7
37
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
38
|
+
* @deprecated No longer used: `matches` runs on RE2, which is immune to ReDoS. Kept for
|
|
39
|
+
* backwards compatibility. Conservative heuristic for a group that contains a quantifier or
|
|
40
|
+
* alternation and is itself repeated (`(a+)+`, `(a|aa)+`, `(a+){2,}`).
|
|
10
41
|
*/
|
|
11
42
|
export function hasNestedQuantifier(pattern) {
|
|
12
43
|
const stack = []; // per open group: contains a quantifier / alternation
|
|
@@ -40,29 +71,6 @@ export function hasNestedQuantifier(pattern) {
|
|
|
40
71
|
}
|
|
41
72
|
return false;
|
|
42
73
|
}
|
|
43
|
-
function regex(pattern) {
|
|
44
|
-
if (!regexCache.has(pattern)) {
|
|
45
|
-
let re = null;
|
|
46
|
-
if (pattern.length > MAX_MATCH_PATTERN_LENGTH || hasNestedQuantifier(pattern)) {
|
|
47
|
-
if (!warnedUnsafePattern) {
|
|
48
|
-
warnedUnsafePattern = true;
|
|
49
|
-
console.warn("DevPulse: a `matches` pattern was too long or had nested quantifiers and is treated as no match");
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
else {
|
|
53
|
-
try {
|
|
54
|
-
re = new RegExp(pattern);
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
re = null;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
if (regexCache.size > 1000)
|
|
61
|
-
regexCache.clear();
|
|
62
|
-
regexCache.set(pattern, re);
|
|
63
|
-
}
|
|
64
|
-
return regexCache.get(pattern);
|
|
65
|
-
}
|
|
66
74
|
/** RFC 3339 timestamp or Unix milliseconds → epoch ms. */
|
|
67
75
|
export function parseTime(v) {
|
|
68
76
|
if (typeof v === "number" && Number.isFinite(v))
|
|
@@ -97,11 +105,9 @@ export function applyOperator(op, actual, expected) {
|
|
|
97
105
|
case "contains":
|
|
98
106
|
return typeof actual === "string" && typeof expected === "string" && actual.includes(expected);
|
|
99
107
|
case "matches": {
|
|
100
|
-
if (typeof actual !== "string" || typeof expected !== "string")
|
|
101
|
-
return false;
|
|
102
|
-
if (actual.length > MAX_MATCH_INPUT_LENGTH)
|
|
108
|
+
if (typeof actual !== "string" || typeof expected !== "string" || actual.length > MAX_MATCH_INPUT_LENGTH)
|
|
103
109
|
return false;
|
|
104
|
-
return
|
|
110
|
+
return compilePattern(expected)?.test(actual) ?? false;
|
|
105
111
|
}
|
|
106
112
|
case "lessThan":
|
|
107
113
|
case "lessThanOrEqual":
|
package/dist/flags/node.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { type ContextInput, type EvaluationDetail, type FlagConfig, type FlagValue, type SegmentConfig } from "./engine/index.js";
|
|
3
|
-
import { type Logger } from "./common/index.js";
|
|
4
|
-
|
|
3
|
+
import { SDK_VERSION, type Logger } from "./common/index.js";
|
|
4
|
+
import type { FlagSet } from "./client.js";
|
|
5
|
+
export type { ContextInput, EvaluationDetail, FlagSet, FlagValue, Logger };
|
|
6
|
+
export { SDK_VERSION };
|
|
5
7
|
export interface NodeClientOptions {
|
|
6
8
|
/** Base URL of your DevPulse deployment (https required except on localhost). */
|
|
7
9
|
baseUrl: string;
|
|
@@ -26,17 +28,24 @@ export interface NodeClientOptions {
|
|
|
26
28
|
export interface AllFlagsState {
|
|
27
29
|
valid: boolean;
|
|
28
30
|
values: Record<string, FlagValue>;
|
|
29
|
-
/** Pass to the browser SDK's `bootstrap` option for flicker-free SSR. */
|
|
30
|
-
toJSON():
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
/** Pass to the browser SDK's `bootstrap` option for flicker-free SSR (same shape as the client endpoint). */
|
|
32
|
+
toJSON(): FlagSet;
|
|
33
|
+
}
|
|
34
|
+
export interface AllFlagsStateOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Only flags marked "available to client-side SDKs" (default true), so the result is safe to
|
|
37
|
+
* embed in HTML. Pass false to include every flag, e.g. for server-side use only.
|
|
38
|
+
*/
|
|
39
|
+
clientSideOnly?: boolean;
|
|
40
|
+
/** Include evaluation reasons (always included for experiments). */
|
|
41
|
+
withReasons?: boolean;
|
|
36
42
|
}
|
|
37
43
|
/**
|
|
38
44
|
* Server-side client. One instance per process and SDK key.
|
|
39
45
|
*
|
|
46
|
+
* Events: "ready", "failed" (fatal, e.g. an invalid SDK key), "update" / "update:<key>",
|
|
47
|
+
* "stale" (connection lost; the last data keeps serving) and "recovered".
|
|
48
|
+
*
|
|
40
49
|
* const client = init(process.env.DEVPULSE_SDK_KEY!, { baseUrl });
|
|
41
50
|
* await client.waitForInitialization({ timeoutMs: 5000 });
|
|
42
51
|
* const enabled = await client.variation("new-checkout", { key: user.id, email: user.email }, false);
|
|
@@ -54,6 +63,7 @@ export declare class DevPulseNodeClient extends EventEmitter {
|
|
|
54
63
|
private resolveReady;
|
|
55
64
|
private rejectReady;
|
|
56
65
|
private streamFailures;
|
|
66
|
+
private stale;
|
|
57
67
|
private readonly logger;
|
|
58
68
|
private readonly base;
|
|
59
69
|
constructor(sdkKey: string, options: NodeClientOptions);
|
|
@@ -65,6 +75,8 @@ export declare class DevPulseNodeClient extends EventEmitter {
|
|
|
65
75
|
private applyPut;
|
|
66
76
|
private emitUpdate;
|
|
67
77
|
private markReady;
|
|
78
|
+
/** "stale" when updates stop arriving (flags keep serving the last data), "recovered" when they resume. */
|
|
79
|
+
private setStale;
|
|
68
80
|
private fail;
|
|
69
81
|
waitForInitialization(opts?: {
|
|
70
82
|
timeoutMs?: number;
|
|
@@ -80,13 +92,13 @@ export declare class DevPulseNodeClient extends EventEmitter {
|
|
|
80
92
|
numberVariation(key: string, context: ContextInput, defaultValue: number): Promise<number>;
|
|
81
93
|
jsonVariation<T extends FlagValue>(key: string, context: ContextInput, defaultValue: T): Promise<T>;
|
|
82
94
|
/**
|
|
83
|
-
* Evaluate every flag for a context, e.g. to bootstrap a browser client
|
|
95
|
+
* Evaluate every client-side flag for a context, e.g. to bootstrap a browser client
|
|
84
96
|
* during server rendering. Doesn't record analytics.
|
|
97
|
+
*
|
|
98
|
+
* const state = await client.allFlagsState(context);
|
|
99
|
+
* createClient(id, context, { baseUrl, bootstrap: state.toJSON() });
|
|
85
100
|
*/
|
|
86
|
-
allFlagsState(context: ContextInput, opts?:
|
|
87
|
-
clientSideOnly?: boolean;
|
|
88
|
-
withReasons?: boolean;
|
|
89
|
-
}): Promise<AllFlagsState>;
|
|
101
|
+
allFlagsState(context: ContextInput, opts?: AllFlagsStateOptions): Promise<AllFlagsState>;
|
|
90
102
|
/** Secure mode hash for a context, for the browser SDK's `hash` option. */
|
|
91
103
|
secureModeHash(context: ContextInput): string;
|
|
92
104
|
track(key: string, context: ContextInput, data?: unknown, metricValue?: number): void;
|
package/dist/flags/node.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { createHmac } from "node:crypto";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
3
|
import { Evaluator, MemoryStore, evaluateFlag, parseContext } from "./engine/index.js";
|
|
4
|
-
import { EventProcessor, SseClient, noopLogger } from "./common/index.js";
|
|
4
|
+
import { EventProcessor, SDK_VERSION, SseClient, noopLogger } from "./common/index.js";
|
|
5
5
|
import { assertSecureUrl } from "../config.js";
|
|
6
|
+
export { SDK_VERSION };
|
|
6
7
|
/**
|
|
7
8
|
* Server-side client. One instance per process and SDK key.
|
|
8
9
|
*
|
|
10
|
+
* Events: "ready", "failed" (fatal, e.g. an invalid SDK key), "update" / "update:<key>",
|
|
11
|
+
* "stale" (connection lost; the last data keeps serving) and "recovered".
|
|
12
|
+
*
|
|
9
13
|
* const client = init(process.env.DEVPULSE_SDK_KEY!, { baseUrl });
|
|
10
14
|
* await client.waitForInitialization({ timeoutMs: 5000 });
|
|
11
15
|
* const enabled = await client.variation("new-checkout", { key: user.id, email: user.email }, false);
|
|
@@ -23,6 +27,7 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
23
27
|
resolveReady;
|
|
24
28
|
rejectReady;
|
|
25
29
|
streamFailures = 0;
|
|
30
|
+
stale = false;
|
|
26
31
|
logger;
|
|
27
32
|
base;
|
|
28
33
|
constructor(sdkKey, options) {
|
|
@@ -61,16 +66,20 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
61
66
|
this.startStreaming();
|
|
62
67
|
}
|
|
63
68
|
headers() {
|
|
64
|
-
return { authorization: this.sdkKey, "user-agent":
|
|
69
|
+
return { authorization: this.sdkKey, "user-agent": `DevPulseNode/${SDK_VERSION}` };
|
|
65
70
|
}
|
|
66
71
|
startStreaming() {
|
|
67
72
|
this.stream = new SseClient({
|
|
68
73
|
url: `${this.base}/sdk/v1/server/stream`,
|
|
69
74
|
headers: () => this.headers(),
|
|
70
75
|
fetch: this.options.fetch,
|
|
76
|
+
browser: false,
|
|
77
|
+
// Full rulesets (segments with big target lists) can be large.
|
|
78
|
+
maxEventSize: 64 * 1024 * 1024,
|
|
71
79
|
onOpen: () => {
|
|
72
80
|
this.streamFailures = 0;
|
|
73
81
|
this.stopPolling();
|
|
82
|
+
this.setStale(false);
|
|
74
83
|
},
|
|
75
84
|
onEvent: (ev) => this.onStreamEvent(ev.type, ev.data),
|
|
76
85
|
onError: (err) => {
|
|
@@ -79,6 +88,9 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
79
88
|
this.fail(new Error("Invalid SDK key"));
|
|
80
89
|
return false;
|
|
81
90
|
}
|
|
91
|
+
// While polling, the poll result decides whether data is stale.
|
|
92
|
+
if (!this.poller)
|
|
93
|
+
this.setStale(true, err);
|
|
82
94
|
// After repeated failures, poll so data stays fresh while we keep retrying the stream.
|
|
83
95
|
if (++this.streamFailures >= 3)
|
|
84
96
|
this.startPolling();
|
|
@@ -94,17 +106,21 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
94
106
|
const poll = async () => {
|
|
95
107
|
try {
|
|
96
108
|
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)
|
|
109
|
+
if (res.status === 304) {
|
|
110
|
+
this.setStale(false);
|
|
98
111
|
return this.markReady();
|
|
112
|
+
}
|
|
99
113
|
if (res.status === 401 || res.status === 403)
|
|
100
114
|
return this.fail(new Error("Invalid SDK key"));
|
|
101
115
|
if (!res.ok)
|
|
102
116
|
throw new Error(`Poll failed with ${res.status}`);
|
|
103
117
|
this.etag = res.headers.get("etag");
|
|
104
118
|
this.applyPut((await res.json()));
|
|
119
|
+
this.setStale(false);
|
|
105
120
|
}
|
|
106
121
|
catch (err) {
|
|
107
122
|
this.logger.warn("DevPulse poll failed", err);
|
|
123
|
+
this.setStale(true, err);
|
|
108
124
|
}
|
|
109
125
|
};
|
|
110
126
|
this.poller = setInterval(poll, interval);
|
|
@@ -163,6 +179,16 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
163
179
|
this.resolveReady();
|
|
164
180
|
this.emit("ready");
|
|
165
181
|
}
|
|
182
|
+
/** "stale" when updates stop arriving (flags keep serving the last data), "recovered" when they resume. */
|
|
183
|
+
setStale(stale, err) {
|
|
184
|
+
if (!this.initialized || this.stale === stale)
|
|
185
|
+
return;
|
|
186
|
+
this.stale = stale;
|
|
187
|
+
if (stale)
|
|
188
|
+
this.emit("stale", err);
|
|
189
|
+
else
|
|
190
|
+
this.emit("recovered");
|
|
191
|
+
}
|
|
166
192
|
fail(err) {
|
|
167
193
|
if (!this.initialized)
|
|
168
194
|
this.rejectReady(err);
|
|
@@ -173,7 +199,12 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
173
199
|
const ready = this.ready.then(() => this);
|
|
174
200
|
if (!opts.timeoutMs)
|
|
175
201
|
return ready;
|
|
176
|
-
|
|
202
|
+
let timer;
|
|
203
|
+
const timeout = new Promise((_, rej) => {
|
|
204
|
+
// Not unref'd: someone is awaiting it. Cleared as soon as either side settles.
|
|
205
|
+
timer = setTimeout(() => rej(new Error("DevPulse initialization timed out")), opts.timeoutMs);
|
|
206
|
+
});
|
|
207
|
+
return Promise.race([ready, timeout]).finally(() => clearTimeout(timer));
|
|
177
208
|
}
|
|
178
209
|
get isInitialized() {
|
|
179
210
|
return this.initialized;
|
|
@@ -217,10 +248,14 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
217
248
|
return this.variation(key, context, defaultValue);
|
|
218
249
|
}
|
|
219
250
|
/**
|
|
220
|
-
* Evaluate every flag for a context, e.g. to bootstrap a browser client
|
|
251
|
+
* Evaluate every client-side flag for a context, e.g. to bootstrap a browser client
|
|
221
252
|
* during server rendering. Doesn't record analytics.
|
|
253
|
+
*
|
|
254
|
+
* const state = await client.allFlagsState(context);
|
|
255
|
+
* createClient(id, context, { baseUrl, bootstrap: state.toJSON() });
|
|
222
256
|
*/
|
|
223
257
|
async allFlagsState(context, opts = {}) {
|
|
258
|
+
const clientSideOnly = opts.clientSideOnly ?? true;
|
|
224
259
|
let ctx;
|
|
225
260
|
try {
|
|
226
261
|
ctx = parseContext(context);
|
|
@@ -231,10 +266,19 @@ export class DevPulseNodeClient extends EventEmitter {
|
|
|
231
266
|
const evaluator = new Evaluator(this.store);
|
|
232
267
|
const state = {};
|
|
233
268
|
for (const f of this.store.flags.values()) {
|
|
234
|
-
if (
|
|
269
|
+
if (f.deleted || (clientSideOnly && !f.clientSide))
|
|
235
270
|
continue;
|
|
236
271
|
const { detail } = evaluator.evaluate(f, ctx);
|
|
237
|
-
|
|
272
|
+
// Same shape as the DevPulse client endpoint, so client-side analytics and experiments keep working.
|
|
273
|
+
const inExperiment = "inExperiment" in detail.reason && !!detail.reason.inExperiment;
|
|
274
|
+
state[f.key] = {
|
|
275
|
+
value: detail.value,
|
|
276
|
+
variation: detail.variationIndex,
|
|
277
|
+
version: f.version,
|
|
278
|
+
...(opts.withReasons || inExperiment ? { reason: detail.reason } : {}),
|
|
279
|
+
...(f.trackEvents || inExperiment ? { trackEvents: true } : {}),
|
|
280
|
+
...(inExperiment ? { trackReason: true } : {}),
|
|
281
|
+
};
|
|
238
282
|
}
|
|
239
283
|
return { valid: this.initialized, values: Object.fromEntries(Object.entries(state).map(([k, v]) => [k, v.value])), toJSON: () => state };
|
|
240
284
|
}
|
package/dist/flags/react.d.ts
CHANGED
|
@@ -10,9 +10,11 @@ export interface DevPulseProviderProps {
|
|
|
10
10
|
children: ReactNode;
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
* Provides a DevPulse client to the tree. On the server (SSR/RSC streaming)
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Provides a DevPulse client to the tree. On the server (SSR/RSC streaming) and during the
|
|
14
|
+
* first client render, an offline client serves `options.bootstrap` and makes no requests, so
|
|
15
|
+
* hydration matches the server HTML. The live client is created (and closed) in an effect,
|
|
16
|
+
* which keeps StrictMode's simulated unmount from leaving a closed client behind. Changing
|
|
17
|
+
* `context` calls identify().
|
|
16
18
|
*/
|
|
17
19
|
export declare function DevPulseProvider({ clientSideId, context, options, client: external, children }: DevPulseProviderProps): import("react").JSX.Element;
|
|
18
20
|
export declare function useDevPulseClient(): DevPulseClient;
|
|
@@ -23,5 +25,5 @@ export declare function useFlagDetail<T extends FlagValue>(key: string, defaultV
|
|
|
23
25
|
export declare function useFlags(): Record<string, FlagValue>;
|
|
24
26
|
/** True once flags have loaded (always true when bootstrapped). */
|
|
25
27
|
export declare function useFlagsReady(): boolean;
|
|
26
|
-
/** Track a custom event for metrics and experiments. */
|
|
28
|
+
/** Track a custom event for metrics and experiments. Stable across renders (safe in effect deps). */
|
|
27
29
|
export declare function useTrack(): (key: string, data?: unknown, metricValue?: number) => void;
|
package/dist/flags/react.js
CHANGED
|
@@ -1,26 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
4
|
import { createClient } from "./client.js";
|
|
4
5
|
const ClientContext = createContext(null);
|
|
5
6
|
const isBrowser = typeof window !== "undefined";
|
|
6
7
|
/**
|
|
7
|
-
* Provides a DevPulse client to the tree. On the server (SSR/RSC streaming)
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Provides a DevPulse client to the tree. On the server (SSR/RSC streaming) and during the
|
|
9
|
+
* first client render, an offline client serves `options.bootstrap` and makes no requests, so
|
|
10
|
+
* hydration matches the server HTML. The live client is created (and closed) in an effect,
|
|
11
|
+
* which keeps StrictMode's simulated unmount from leaving a closed client behind. Changing
|
|
12
|
+
* `context` calls identify().
|
|
10
13
|
*/
|
|
11
14
|
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
15
|
const contextJson = JSON.stringify(context);
|
|
14
|
-
|
|
16
|
+
// Render-time stand-in: no network, timers or listeners, so creating it during render is safe.
|
|
17
|
+
// No localStorage cache either, so the first client render matches the server HTML.
|
|
18
|
+
const [placeholder] = useState(() => createClient(clientSideId, context, { ...options, offline: true, sendEvents: false, cache: false }));
|
|
19
|
+
const [owned, setOwned] = useState(null);
|
|
20
|
+
const latest = useRef({ contextJson, options });
|
|
21
|
+
latest.current = { contextJson, options };
|
|
15
22
|
useEffect(() => {
|
|
16
|
-
if (
|
|
17
|
-
first.current = false;
|
|
23
|
+
if (external || !isBrowser)
|
|
18
24
|
return;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
const c = createClient(clientSideId, JSON.parse(latest.current.contextJson), latest.current.options);
|
|
26
|
+
setOwned(c);
|
|
27
|
+
return () => {
|
|
28
|
+
void c.close();
|
|
29
|
+
setOwned((cur) => (cur === c ? null : cur));
|
|
30
|
+
};
|
|
31
|
+
}, [external, clientSideId]);
|
|
32
|
+
const live = external ?? owned;
|
|
33
|
+
const seen = useRef({ client: null, contextJson });
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!live || live.isClosed)
|
|
36
|
+
return;
|
|
37
|
+
const prev = seen.current;
|
|
38
|
+
seen.current = { client: live, contextJson };
|
|
39
|
+
// A client we just created already has the current context; an injected one is taken as is.
|
|
40
|
+
const changed = prev.client === live ? prev.contextJson !== contextJson : live === owned && JSON.stringify(live.getContext()) !== contextJson;
|
|
41
|
+
if (changed)
|
|
42
|
+
void live.identify(JSON.parse(contextJson)).catch(() => undefined);
|
|
43
|
+
}, [live, owned, contextJson]);
|
|
44
|
+
return _jsx(ClientContext.Provider, { value: live ?? placeholder, children: children });
|
|
24
45
|
}
|
|
25
46
|
export function useDevPulseClient() {
|
|
26
47
|
const client = useContext(ClientContext);
|
|
@@ -39,8 +60,17 @@ export function useFlagDetail(key, defaultValue) {
|
|
|
39
60
|
// Snapshot is the serialized value so useSyncExternalStore sees stable identities.
|
|
40
61
|
const snapshot = () => JSON.stringify(client.allFlags()[key] ?? null);
|
|
41
62
|
const serialized = useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
42
|
-
|
|
43
|
-
|
|
63
|
+
const detail = useMemo(() => client.peekVariationDetail(key, JSON.parse(defaultJson)), [client, key, defaultJson, serialized]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
64
|
+
// Record the evaluation (analytics) after commit, once per distinct result: renders can be
|
|
65
|
+
// repeated or thrown away (StrictMode, concurrent rendering), effects re-run in StrictMode.
|
|
66
|
+
const recorded = useRef(null);
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (recorded.current === detail)
|
|
69
|
+
return;
|
|
70
|
+
recorded.current = detail;
|
|
71
|
+
client.recordEvaluation(key, JSON.parse(defaultJson), detail);
|
|
72
|
+
}, [client, key, defaultJson, detail]);
|
|
73
|
+
return detail;
|
|
44
74
|
}
|
|
45
75
|
/** All flag values; re-renders when any flag changes. */
|
|
46
76
|
export function useFlags() {
|
|
@@ -55,8 +85,8 @@ export function useFlagsReady() {
|
|
|
55
85
|
const subscribe = useMemo(() => (cb) => client.on("ready", cb), [client]);
|
|
56
86
|
return useSyncExternalStore(subscribe, () => client.isInitialized, () => client.isInitialized);
|
|
57
87
|
}
|
|
58
|
-
/** Track a custom event for metrics and experiments. */
|
|
88
|
+
/** Track a custom event for metrics and experiments. Stable across renders (safe in effect deps). */
|
|
59
89
|
export function useTrack() {
|
|
60
90
|
const client = useDevPulseClient();
|
|
61
|
-
return (key, data, metricValue) => client.track(key, data, metricValue);
|
|
91
|
+
return useCallback((key, data, metricValue) => client.track(key, data, metricValue), [client]);
|
|
62
92
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
export { Daywalker, createDaywalker, daywalker, } from "./client.js";
|
|
2
|
-
export {
|
|
3
|
-
export
|
|
4
|
-
export {
|
|
1
|
+
export { Daywalker, createDaywalker, daywalker, MAX_RETRY_AFTER_MS, MAX_VALIDATION_CACHE_ENTRIES, } from "./client.js";
|
|
2
|
+
export type { DaywalkerFetchInit, } from "./client.js";
|
|
3
|
+
export { DaywalkerError, DaywalkerAuthError, DaywalkerRateLimitError, UNAVAILABLE_ERROR_CODES, } from "./types.js";
|
|
4
|
+
export type { DaywalkerConfig, RateLimitWindow, TokenPayload, ValidateErrorCode, ValidateResult, RetryOptions, SelfCheckResult, PublicJwk, PublicJwks, SigningKey, } from "./types.js";
|
|
5
|
+
export { resolveConfig, publicHostname, assertSecureUrl, DEFAULT_TIMEOUT_MS, } from "./config.js";
|
|
5
6
|
export type { ResolvedConfig, } from "./config.js";
|
|
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
|
-
export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, } from "./crypto/index.js";
|
|
7
|
+
export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
|
|
8
|
+
export type { SignedHeaders, SignRequestParams, VerifySignatureParams, VerifySignatureResult, ReplayStore, } from "./crypto/index.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { Daywalker, createDaywalker, daywalker, } from "./client.js";
|
|
2
|
-
export { DaywalkerError, DaywalkerAuthError, DaywalkerRateLimitError, } from "./types.js";
|
|
3
|
-
export { resolveConfig, publicHostname, assertSecureUrl, } from "./config.js";
|
|
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";
|
|
1
|
+
export { Daywalker, createDaywalker, daywalker, MAX_RETRY_AFTER_MS, MAX_VALIDATION_CACHE_ENTRIES, } from "./client.js";
|
|
2
|
+
export { DaywalkerError, DaywalkerAuthError, DaywalkerRateLimitError, UNAVAILABLE_ERROR_CODES, } from "./types.js";
|
|
3
|
+
export { resolveConfig, publicHostname, assertSecureUrl, DEFAULT_TIMEOUT_MS, } from "./config.js";
|
|
4
|
+
export { SIGNATURE_VERSION, SIGNATURE_LABEL, SIGNATURE_ALG, MAX_CLOCK_SKEW_SECONDS, REPLAY_CACHE_MAX_ENTRIES, SignatureReplayCache, computeContentDigest, sha256Base64, sha256Base64Url, calculateJwkThumbprint, generateSigningKey, loadSigningKey, loadPublicKey, formatSignatureParams, createSignatureBase, signRequest, verifyRequestSignature, verifyJwtLease, registerPublicKey, stringToUtf8, utf8ToString, bytesToBase64, base64ToBytes, bytesToBase64Url, base64UrlToBytes, base64UrlToBytesStrict, bytesToHex, hexToBytes, maskSecret, redactSecrets, } from "./crypto/index.js";
|
package/dist/middleware.d.ts
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
import { Daywalker } from "./client.js";
|
|
2
|
+
import type { ValidateResult } from "./types.js";
|
|
3
|
+
type ValidateFailure = Extract<ValidateResult, {
|
|
4
|
+
ok: false;
|
|
5
|
+
}>;
|
|
6
|
+
export interface AuthFailureResponse {
|
|
7
|
+
/** 401 bad/expired/missing token, 429 rate limited, 500 server misconfiguration, 503 auth service unavailable. */
|
|
8
|
+
status: 401 | 429 | 500 | 503;
|
|
9
|
+
error: string;
|
|
10
|
+
message: string;
|
|
11
|
+
retryAfter?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Maps a failed validation to the HTTP status an adapter should answer with. Outages (5xx,
|
|
15
|
+
* network errors, SERVICE_UNAVAILABLE) become 503 rather than 401 so clients retry instead of
|
|
16
|
+
* discarding a valid token; configuration errors become a 500 without their (internal) details.
|
|
17
|
+
* Shared by the Next, Web/Hono and Express adapters.
|
|
18
|
+
*/
|
|
19
|
+
export declare function toAuthFailureResponse(result: ValidateFailure): AuthFailureResponse;
|
|
2
20
|
export interface DaywalkerMiddlewareOptions {
|
|
3
21
|
client?: Daywalker;
|
|
4
22
|
serviceSlug?: string;
|
package/dist/middleware.js
CHANGED
|
@@ -1,4 +1,30 @@
|
|
|
1
1
|
import { daywalker } from "./client.js";
|
|
2
|
+
/** Error codes that mean Daywalker Auth itself could not be reached or is degraded. */
|
|
3
|
+
const UNAVAILABLE_ERROR_CODES = new Set(["NETWORK_ERROR", "SERVICE_UNAVAILABLE"]);
|
|
4
|
+
/**
|
|
5
|
+
* Maps a failed validation to the HTTP status an adapter should answer with. Outages (5xx,
|
|
6
|
+
* network errors, SERVICE_UNAVAILABLE) become 503 rather than 401 so clients retry instead of
|
|
7
|
+
* discarding a valid token; configuration errors become a 500 without their (internal) details.
|
|
8
|
+
* Shared by the Next, Web/Hono and Express adapters.
|
|
9
|
+
*/
|
|
10
|
+
export function toAuthFailureResponse(result) {
|
|
11
|
+
const retryAfter = result.retryAfter && result.retryAfter > 0 ? result.retryAfter : undefined;
|
|
12
|
+
if (result.error === "CONFIGURATION_ERROR") {
|
|
13
|
+
return { status: 500, error: "CONFIGURATION_ERROR", message: "Authentication is not configured correctly on this server." };
|
|
14
|
+
}
|
|
15
|
+
if (result.status === 429 || result.error === "RATE_LIMITED") {
|
|
16
|
+
return { status: 429, error: "RATE_LIMITED", message: result.message || "Rate limit exceeded", retryAfter };
|
|
17
|
+
}
|
|
18
|
+
if (result.status >= 500 || result.status === 0 || UNAVAILABLE_ERROR_CODES.has(result.error)) {
|
|
19
|
+
return {
|
|
20
|
+
status: 503,
|
|
21
|
+
error: UNAVAILABLE_ERROR_CODES.has(result.error) ? result.error : "SERVICE_UNAVAILABLE",
|
|
22
|
+
message: "Daywalker Auth is temporarily unavailable. Please retry.",
|
|
23
|
+
retryAfter,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return { status: 401, error: result.error || "INVALID_TOKEN", message: result.message || "Unauthorized" };
|
|
27
|
+
}
|
|
2
28
|
/**
|
|
3
29
|
* Standard Web / Hono compatible middleware.
|
|
4
30
|
* Works with Hono, standard Web Request/Response routers, and middleware pipelines.
|
|
@@ -46,13 +72,8 @@ export function daywalkerAuth(options = {}) {
|
|
|
46
72
|
}
|
|
47
73
|
const result = await client.validateToken(token, { serviceSlug });
|
|
48
74
|
if (!result.ok) {
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
error: result.error,
|
|
52
|
-
message: result.message,
|
|
53
|
-
retryAfter: result.retryAfter,
|
|
54
|
-
};
|
|
55
|
-
const headers = result.retryAfter ? { "Retry-After": String(result.retryAfter) } : undefined;
|
|
75
|
+
const { status, ...body } = toAuthFailureResponse(result);
|
|
76
|
+
const headers = body.retryAfter ? { "Retry-After": String(body.retryAfter) } : undefined;
|
|
56
77
|
if (typeof c?.json === "function") {
|
|
57
78
|
return c.json(body, status, headers);
|
|
58
79
|
}
|
package/dist/next.d.ts
CHANGED
|
@@ -7,26 +7,55 @@ 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
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* A suggested value for the opt-in `staticFileExtensions` option. It is NOT applied by default:
|
|
12
|
+
* an extension says nothing about whether a route is public (`/users/123.js` can be a dynamic page).
|
|
13
|
+
* Prefer excluding public files with Next's `matcher` (see `createDaywalkerMiddleware`).
|
|
14
|
+
*/
|
|
11
15
|
export declare const STATIC_FILE_EXTENSIONS: RegExp;
|
|
12
16
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
17
|
+
* Decodes and normalizes a URL pathname the way a router sees it: percent-decoded once, `//`
|
|
18
|
+
* collapsed and `.`/`..` segments resolved. Returns null (never bypass auth) for malformed
|
|
19
|
+
* encoding, encoded slashes/backslashes, raw backslashes or control characters.
|
|
20
|
+
*/
|
|
21
|
+
export declare function normalizePathname(pathname: string): string | null;
|
|
22
|
+
/**
|
|
23
|
+
* Static assets that skip auth: Next's build output (`/_next/static/`), image optimizer
|
|
24
|
+
* (`/_next/image`) and `/favicon.ico`. Files matching `staticFileExtensions` are included only when
|
|
25
|
+
* that list is passed explicitly. The path is decoded and normalized first; `/_next/data/`
|
|
26
|
+
* (getServerSideProps JSON) and `/api` / `/api/*` are never static.
|
|
15
27
|
*/
|
|
16
|
-
export declare function isStaticAssetPath(pathname: string): boolean;
|
|
28
|
+
export declare function isStaticAssetPath(pathname: string, staticFileExtensions?: RegExp | readonly string[]): boolean;
|
|
17
29
|
export interface NextMiddlewareOptions {
|
|
18
30
|
client?: Daywalker;
|
|
19
31
|
serviceSlug?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Public paths that skip auth (exact match or `path/` prefix, compared against the decoded,
|
|
34
|
+
* normalized pathname). Defaults to `DEFAULT_BYPASS_PATHS`. Prefer Next's `matcher` config to keep
|
|
35
|
+
* public routes from reaching the middleware at all.
|
|
36
|
+
*/
|
|
20
37
|
bypassPaths?: string[];
|
|
38
|
+
/**
|
|
39
|
+
* Opt-in: also skip auth for (non-`/api`, non-`/_next/`) paths ending in one of these extensions,
|
|
40
|
+
* e.g. `STATIC_FILE_EXTENSIONS` or `["png", "css"]`. Off by default, because a dynamic route such
|
|
41
|
+
* as `/users/[id]` also answers `/users/123.js`. Prefer a `matcher` that excludes `public/` files.
|
|
42
|
+
*/
|
|
43
|
+
staticFileExtensions?: RegExp | readonly string[];
|
|
21
44
|
errorPath?: string;
|
|
22
45
|
homePath?: string;
|
|
46
|
+
/** Only enforce auth on `/api` and `/api/*`; other routes pass through. */
|
|
23
47
|
protectApiOnly?: boolean;
|
|
24
48
|
validateServerActions?: boolean;
|
|
49
|
+
/** When set, requests whose Host header is missing or not listed get a 403. */
|
|
25
50
|
allowedHosts?: string[];
|
|
26
51
|
}
|
|
52
|
+
/** Options for the Next.js 16+ `proxy.ts` aliases (identical to `NextMiddlewareOptions`). */
|
|
53
|
+
export type NextProxyOptions = NextMiddlewareOptions;
|
|
27
54
|
/**
|
|
28
55
|
* Sanitizes open redirect path parameter (`from`).
|
|
29
|
-
*
|
|
56
|
+
* Accepts only same-origin relative paths: rejects protocol-relative URLs (`//evil.com`), any
|
|
57
|
+
* backslash, whitespace or control character (browsers strip tabs/newlines, so `/\t/evil.com` would
|
|
58
|
+
* become `//evil.com`), and anything that resolves to another origin.
|
|
30
59
|
*/
|
|
31
60
|
export declare function sanitizeRedirectPath(path: string): string | null;
|
|
32
61
|
/**
|
|
@@ -38,14 +67,17 @@ export declare function sanitizeRedirectPath(path: string): string | null;
|
|
|
38
67
|
* 3. Per-request token auth: requests without `Authorization: Bearer` / `x-api-token` get 401
|
|
39
68
|
* 4. Distinct handling for API routes (JSON + CORS) vs UI pages (redirect to `errorPath` with `from`)
|
|
40
69
|
*
|
|
70
|
+
* Only `/_next/static/`, `/_next/image`, `/favicon.ico` and `bypassPaths` skip auth by default.
|
|
71
|
+
* Exclude other public files with Next's `matcher` (recommended) or opt into `staticFileExtensions`.
|
|
72
|
+
*
|
|
41
73
|
* Example:
|
|
42
|
-
* // middleware.ts
|
|
74
|
+
* // middleware.ts (Next.js 16+: proxy.ts, `export const proxy = createDaywalkerProxy();`)
|
|
43
75
|
* import { createDaywalkerMiddleware } from "@shelbyb/daywalker-sdk/next";
|
|
44
76
|
*
|
|
45
77
|
* export const middleware = createDaywalkerMiddleware();
|
|
46
78
|
*
|
|
47
79
|
* export const config = {
|
|
48
|
-
* matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
80
|
+
* matcher: ["/((?!_next/static|_next/image|favicon.ico|images/).*)"],
|
|
49
81
|
* };
|
|
50
82
|
*/
|
|
51
83
|
export declare function createDaywalkerMiddleware(options?: NextMiddlewareOptions): (request: Request) => Promise<Response>;
|
|
@@ -58,7 +90,8 @@ export declare function createDaywalkerServiceHealthMiddleware(options?: NextMid
|
|
|
58
90
|
/**
|
|
59
91
|
* Next.js App Router route handler wrapper with Daywalker token validation.
|
|
60
92
|
* Extracts `Authorization: Bearer <token>` or `x-api-token`, validates via client,
|
|
61
|
-
* returns
|
|
93
|
+
* returns a JSON error on failure (401 bad token, 429 rate limited, 503 Daywalker Auth unavailable,
|
|
94
|
+
* 500 misconfiguration), or passes validated `auth` payload in context.
|
|
62
95
|
*
|
|
63
96
|
* Example:
|
|
64
97
|
* // app/api/protected/route.ts
|
|
@@ -74,3 +107,14 @@ export declare function withDaywalkerAuth<Context extends Record<string, unknown
|
|
|
74
107
|
client?: Daywalker;
|
|
75
108
|
serviceSlug?: string;
|
|
76
109
|
}): (req: Request, context?: Context) => Promise<Response>;
|
|
110
|
+
/**
|
|
111
|
+
* Next.js 16 renamed `middleware.ts` to `proxy.ts` (export `proxy` instead of `middleware`).
|
|
112
|
+
* These aliases are identical to the `*Middleware` factories, which remain supported:
|
|
113
|
+
*
|
|
114
|
+
* // proxy.ts
|
|
115
|
+
* import { createDaywalkerProxy } from "@shelbyb/daywalker-sdk/next";
|
|
116
|
+
* export const proxy = createDaywalkerProxy();
|
|
117
|
+
* export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
|
|
118
|
+
*/
|
|
119
|
+
export declare const createDaywalkerProxy: typeof createDaywalkerMiddleware;
|
|
120
|
+
export declare const createDaywalkerServiceHealthProxy: typeof createDaywalkerServiceHealthMiddleware;
|