@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.
Files changed (54) hide show
  1. package/README.md +31 -23
  2. package/dist/analytics/index.d.ts +16 -2
  3. package/dist/analytics/index.js +79 -14
  4. package/dist/cli.js +133 -31
  5. package/dist/client.d.ts +29 -2
  6. package/dist/client.js +314 -62
  7. package/dist/config.d.ts +8 -2
  8. package/dist/config.js +14 -0
  9. package/dist/crypto/encoding.d.ts +5 -0
  10. package/dist/crypto/encoding.js +14 -0
  11. package/dist/crypto/index.d.ts +3 -3
  12. package/dist/crypto/index.js +2 -2
  13. package/dist/crypto/keys.d.ts +3 -3
  14. package/dist/crypto/keys.js +28 -7
  15. package/dist/crypto/signatures.d.ts +42 -12
  16. package/dist/crypto/signatures.js +236 -79
  17. package/dist/express.d.ts +1 -0
  18. package/dist/express.js +8 -6
  19. package/dist/flags/client.d.ts +32 -4
  20. package/dist/flags/client.js +74 -12
  21. package/dist/flags/common/events.d.ts +9 -0
  22. package/dist/flags/common/events.js +51 -6
  23. package/dist/flags/common/index.d.ts +3 -3
  24. package/dist/flags/common/index.js +3 -3
  25. package/dist/flags/common/sse.d.ts +24 -3
  26. package/dist/flags/common/sse.js +85 -15
  27. package/dist/flags/common/version.d.ts +2 -0
  28. package/dist/flags/common/version.js +2 -0
  29. package/dist/flags/engine/index.d.ts +1 -1
  30. package/dist/flags/engine/index.js +1 -1
  31. package/dist/flags/engine/operators.d.ts +20 -3
  32. package/dist/flags/engine/operators.js +38 -32
  33. package/dist/flags/node.d.ts +26 -14
  34. package/dist/flags/node.js +51 -7
  35. package/dist/flags/react.d.ts +6 -4
  36. package/dist/flags/react.js +47 -17
  37. package/dist/index.d.ts +7 -6
  38. package/dist/index.js +4 -4
  39. package/dist/middleware.d.ts +18 -0
  40. package/dist/middleware.js +28 -7
  41. package/dist/next.d.ts +52 -8
  42. package/dist/next.js +151 -37
  43. package/dist/openfeature/server.d.ts +3 -0
  44. package/dist/openfeature/server.js +14 -2
  45. package/dist/openfeature/shared.d.ts +1 -1
  46. package/dist/openfeature/shared.js +22 -2
  47. package/dist/openfeature/web.js +9 -3
  48. package/dist/types.d.ts +56 -4
  49. package/dist/types.js +6 -0
  50. package/docs/analytics.md +1 -1
  51. package/docs/flags-node.md +1 -1
  52. package/docs/flags.md +2 -1
  53. package/package.json +32 -14
  54. 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
- const regexCache = new Map();
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
- let warnedUnsafePattern = false;
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
- * Conservative ReDoS heuristic: a group that contains a quantifier or alternation and is itself
9
- * repeated (`(a+)+`, `(a*)*`, `(a|aa)+`, `(?:x+y)*`, `(a+){2,}`). Character classes and escapes are skipped.
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 regex(expected)?.test(actual) ?? false;
110
+ return compilePattern(expected)?.test(actual) ?? false;
105
111
  }
106
112
  case "lessThan":
107
113
  case "lessThanOrEqual":
@@ -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
- export type { ContextInput, EvaluationDetail, FlagValue, Logger };
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(): Record<string, {
31
- value: FlagValue;
32
- variation: number | null;
33
- version: number;
34
- reason?: unknown;
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;
@@ -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": "DevPulseNode/0.1.0" };
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
- return Promise.race([ready, new Promise((_, rej) => setTimeout(() => rej(new Error("DevPulse initialization timed out")), opts.timeoutMs).unref?.())]);
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 (opts.clientSideOnly && !f.clientSide)
269
+ if (f.deleted || (clientSideOnly && !f.clientSide))
235
270
  continue;
236
271
  const { detail } = evaluator.evaluate(f, ctx);
237
- state[f.key] = { value: detail.value, variation: detail.variationIndex, version: f.version, ...(opts.withReasons ? { reason: detail.reason } : {}) };
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
  }
@@ -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
- * no network connection is opened; flags come from `options.bootstrap`, so the
15
- * first client render matches the server HTML. Changing `context` calls identify().
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;
@@ -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
- * no network connection is opened; flags come from `options.bootstrap`, so the
9
- * first client render matches the server HTML. Changing `context` calls identify().
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
- const first = useRef(true);
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 (first.current) {
17
- first.current = false;
23
+ if (external || !isBrowser)
18
24
  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 });
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
- // 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
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 { DaywalkerError, DaywalkerAuthError, DaywalkerRateLimitError, } from "./types.js";
3
- export type { DaywalkerConfig, RateLimitWindow, TokenPayload, ValidateErrorCode, ValidateResult, RetryOptions, SelfCheckResult, PublicJwk, SigningKey, } from "./types.js";
4
- export { resolveConfig, publicHostname, assertSecureUrl, } from "./config.js";
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";
@@ -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;
@@ -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 status = result.status === 429 ? 429 : 401;
50
- const body = {
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
- /** File extensions treated as static assets (outside /api/) and passed through without auth. */
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
- * 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.
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
- * Prevents protocol-relative URLs (`//evil.com`), backslashes (`/\evil.com`), and control characters.
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 401/429 JSON response on error, or passes validated `auth` payload in context.
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;