@shelbyb/daywalker-sdk 0.3.0 → 0.4.1

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 +64 -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
@@ -10,7 +10,7 @@ type Detail<T> = {
10
10
  reason: Reason;
11
11
  };
12
12
  /**
13
- * OpenFeature context → DevPulse context. `targetingKey` becomes the key;
13
+ * OpenFeature context → DevPulse context. Dates become ISO 8601 strings. `targetingKey` becomes the key;
14
14
  * `kind` selects the context kind (default "user"); multi-kind contexts pass through.
15
15
  */
16
16
  export declare function toDevPulseContext(ctx: EvaluationContext): Record<string, unknown>;
@@ -1,10 +1,30 @@
1
1
  import { ErrorCode, StandardResolutionReasons } from "@openfeature/core";
2
2
  /**
3
- * OpenFeature context → DevPulse context. `targetingKey` becomes the key;
3
+ * OpenFeature context values may be Dates; DevPulse compares RFC 3339 strings (or epoch ms) for
4
+ * `before`/`after`, so Dates become ISO strings at any depth (invalid Dates are dropped).
5
+ */
6
+ function toJsonValue(v) {
7
+ if (v instanceof Date)
8
+ return Number.isNaN(v.getTime()) ? undefined : v.toISOString();
9
+ if (Array.isArray(v))
10
+ return v.map(toJsonValue);
11
+ if (v && typeof v === "object") {
12
+ const out = {};
13
+ for (const [k, x] of Object.entries(v)) {
14
+ const j = toJsonValue(x);
15
+ if (j !== undefined)
16
+ out[k] = j;
17
+ }
18
+ return out;
19
+ }
20
+ return v;
21
+ }
22
+ /**
23
+ * OpenFeature context → DevPulse context. Dates become ISO 8601 strings. `targetingKey` becomes the key;
4
24
  * `kind` selects the context kind (default "user"); multi-kind contexts pass through.
5
25
  */
6
26
  export function toDevPulseContext(ctx) {
7
- const { targetingKey, ...rest } = ctx;
27
+ const { targetingKey, ...rest } = toJsonValue(ctx);
8
28
  if (rest.kind === "multi")
9
29
  return rest;
10
30
  return { ...rest, kind: typeof rest.kind === "string" ? rest.kind : "user", key: targetingKey ?? rest.key };
@@ -20,9 +20,15 @@ export class DevPulseWebProvider {
20
20
  this.options = options;
21
21
  }
22
22
  async initialize(context) {
23
- this.client = createClient(this.clientSideId, toDevPulseContext(context ?? {}), { ...this.options, withReasons: true });
24
- this.client.on("change", (changes) => this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged: Object.keys(changes) }));
25
- await this.client.waitForInitialization();
23
+ void this.client?.close(); // re-initialized: don't leak the previous client
24
+ const client = createClient(this.clientSideId, toDevPulseContext(context ?? {}), { ...this.options, withReasons: true });
25
+ this.client = client;
26
+ client.on("change", (changes) => this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged: Object.keys(changes) }));
27
+ // Before initialization a failure rejects initialize() instead, which OpenFeature reports itself.
28
+ client.on("failed", (err) => client.isInitialized && this.events.emit(ProviderEvents.Error, { message: err?.message ?? "DevPulse client failed" }));
29
+ client.on("stale", (err) => this.events.emit(ProviderEvents.Stale, { message: err?.message ?? "DevPulse stream lost; serving last known flags" }));
30
+ client.on("recovered", () => this.events.emit(ProviderEvents.Ready));
31
+ await client.waitForInitialization();
26
32
  }
27
33
  async onContextChange(_old, next) {
28
34
  await this.client?.identify(toDevPulseContext(next));
package/dist/types.d.ts CHANGED
@@ -15,7 +15,13 @@ export interface TokenPayload {
15
15
  offlineVerified?: boolean;
16
16
  [key: string]: unknown;
17
17
  }
18
- export type ValidateErrorCode = "EXPIRED" | "REVOKED" | "INVALID_TOKEN" | "RATE_LIMITED" | "NETWORK_ERROR" | "CONFIGURATION_ERROR" | string;
18
+ export type ValidateErrorCode = "EXPIRED" | "REVOKED" | "INVALID_TOKEN" | "RATE_LIMITED" | "NETWORK_ERROR" | "SERVICE_UNAVAILABLE" | "CONFIGURATION_ERROR" | string;
19
+ /**
20
+ * Failures that say nothing about the token: Daywalker Auth was unreachable, timed out, answered 5xx
21
+ * or answered with something that isn't a validation result. Both carry status 503 and are never cached;
22
+ * adapters should answer 503 rather than 401 for them.
23
+ */
24
+ export declare const UNAVAILABLE_ERROR_CODES: readonly ValidateErrorCode[];
19
25
  export type ValidateResult = {
20
26
  ok: true;
21
27
  data: TokenPayload;
@@ -26,6 +32,9 @@ export type ValidateResult = {
26
32
  message: string;
27
33
  retryAfter?: number;
28
34
  };
35
+ export interface PublicJwks {
36
+ keys: PublicJwk[];
37
+ }
29
38
  export interface PublicJwk {
30
39
  kty: "OKP";
31
40
  crv: "Ed25519";
@@ -43,19 +52,38 @@ export interface SigningKey {
43
52
  export interface RetryOptions {
44
53
  maxRetries?: number;
45
54
  initialDelayMs?: number;
55
+ /** Cap on the exponential backoff delay. Default 30000ms. */
46
56
  maxDelayMs?: number;
47
57
  backoffFactor?: number;
48
58
  jitter?: boolean;
59
+ /**
60
+ * Longest `Retry-After` honored on a 429 (default 60000ms). A longer one is not waited out:
61
+ * the 429 response is returned to the caller.
62
+ */
63
+ maxRetryAfterMs?: number;
64
+ /**
65
+ * Retry after network errors / timeouts. Defaults to true only for GET, HEAD and OPTIONS (or when an
66
+ * `idempotencyKey` is given), since the server may already have processed a request whose response was lost.
67
+ */
68
+ retryNetworkErrors?: boolean;
49
69
  }
50
70
  export interface DaywalkerConfig {
51
71
  apiKey?: string;
52
72
  signingKey?: string | SigningKey;
73
+ /**
74
+ * Previous private key during rotation (env DAYWALKER_SIGNING_KEY_PREVIOUS). Never used to sign; its public
75
+ * JWK is published by `keys()` next to the current one so requests from instances still on the old key verify.
76
+ */
53
77
  previousSigningKey?: string | SigningKey;
54
78
  keyId?: string;
55
79
  serviceSlug?: string;
56
80
  domain?: string;
57
- daywalkerPublicKey?: string | PublicJwk;
58
- /** Expected `iss` of offline-verified JWT leases (env DAYWALKER_LEASE_ISSUER). Unchecked when unset. */
81
+ /** A JWK, a JWKS (`{ keys: [...] }`, selected by the lease's `kid`), PEM, or Base64 SPKI / raw key. */
82
+ daywalkerPublicKey?: string | PublicJwk | PublicJwks;
83
+ /**
84
+ * Expected `iss` of offline-verified JWT leases (env DAYWALKER_LEASE_ISSUER). Daywalker documents no fixed
85
+ * issuer, so `iss` is unchecked when unset; set it whenever leases from other issuers could share your key.
86
+ */
59
87
  leaseIssuer?: string;
60
88
  /** Must be https:// (http:// only for localhost). */
61
89
  authUrl?: string;
@@ -63,7 +91,10 @@ export interface DaywalkerConfig {
63
91
  /** Short cache for failed validations (not 429/network errors). Default 3000ms; 0 disables. */
64
92
  negativeCacheTtlMs?: number;
65
93
  maxRetries?: number;
94
+ /** Defaults for `fetch()` retries; per-request `init.retry` overrides individual fields. */
66
95
  retry?: RetryOptions;
96
+ /** Per-attempt timeout for requests to Daywalker Auth. Default 5000ms; 0 disables. */
97
+ timeoutMs?: number;
67
98
  fetch?: typeof fetch;
68
99
  env?: Record<string, string | undefined>;
69
100
  }
@@ -85,6 +116,7 @@ export interface SignedHeaders {
85
116
  }
86
117
  export interface SignRequestParams {
87
118
  method: string;
119
+ /** Request target path; a query string (`/a?b=1`) is split off and covered as "@query". */
88
120
  path: string;
89
121
  authority: string;
90
122
  body?: string | Uint8Array | null;
@@ -92,19 +124,39 @@ export interface SignRequestParams {
92
124
  created?: number;
93
125
  keyid?: string;
94
126
  alg?: string;
127
+ /** Sets `Authorization: Bearer <apiKey>` on the returned headers (not covered by the signature). */
95
128
  apiKey?: string;
129
+ /** Must be 1-128 of `[A-Za-z0-9_.-]` so it can't inject Signature-Input parameters. */
96
130
  nonce?: string;
97
131
  }
132
+ /**
133
+ * Remembers verified request signatures until they expire. The default in-memory store is per process:
134
+ * deployments with several instances must pass a shared store (e.g. Redis `SET key 1 NX PX ttl`), or a
135
+ * signature replayed against another instance is accepted there.
136
+ */
137
+ export interface ReplayStore {
138
+ /** True when `key` was recorded and has not expired yet. */
139
+ has(key: string): boolean | Promise<boolean>;
140
+ /**
141
+ * Atomically records `key` for `ttlMs` if it is absent. Returns false when the key is already present or
142
+ * the store can't take it (e.g. full); the signature is then rejected.
143
+ */
144
+ record(key: string, ttlMs: number): boolean | Promise<boolean>;
145
+ }
98
146
  export interface VerifySignatureParams {
99
147
  method: string;
148
+ /** Request target path, including any query string (covered as "@query"). */
100
149
  path: string;
101
150
  authority: string;
102
151
  headers: Record<string, string | undefined> | Headers;
103
152
  body?: string | Uint8Array | null;
104
- publicKey: PublicJwk | CryptoKey | string;
153
+ /** A JWK, a JWKS (selected by the signature's `keyid`), a CryptoKey, PEM or Base64 key. */
154
+ publicKey: PublicJwk | PublicJwks | CryptoKey | string;
105
155
  currentTime?: number;
106
156
  maxClockSkewSeconds?: number;
107
157
  replayCheck?: boolean;
158
+ /** Where verified signatures are remembered; defaults to a per-process in-memory cache. */
159
+ replayStore?: ReplayStore;
108
160
  }
109
161
  export interface VerifySignatureResult {
110
162
  valid: boolean;
package/dist/types.js CHANGED
@@ -1,4 +1,10 @@
1
1
  import { redactSecrets } from "./crypto/encoding.js";
2
+ /**
3
+ * Failures that say nothing about the token: Daywalker Auth was unreachable, timed out, answered 5xx
4
+ * or answered with something that isn't a validation result. Both carry status 503 and are never cached;
5
+ * adapters should answer 503 rather than 401 for them.
6
+ */
7
+ export const UNAVAILABLE_ERROR_CODES = Object.freeze(["SERVICE_UNAVAILABLE", "NETWORK_ERROR"]);
2
8
  export class DaywalkerError extends Error {
3
9
  status;
4
10
  code;
package/docs/analytics.md CHANGED
@@ -27,6 +27,6 @@ await analytics.shutdown();
27
27
 
28
28
  Add each flag's value to events (for breakdowns by variation) with `flags: client` where `client` is a `@shelbyb/daywalker-sdk/flags` instance (anything with `allFlags()`).
29
29
 
30
- Options: `autocapturePageviews` (default true), `flushAt` (20), `flushInterval` (2s browser / 10s Node), `maxQueueSize` (1000), `persistence` (`localStorage` | `memory`), `sessionTimeoutMinutes` (30), `respectDoNotTrack`, `optedOut` (consent), `captureFullUrls` (default false: URLs drop the fragment and all query parameters except `utm_*`, `gclid`, `fbclid`, `ref`), `onError`. `host` must be `https://` except on localhost.
30
+ Options: `autocapturePageviews` (default true), `flushAt` (20), `flushInterval` (2s browser / 10s Node), `maxQueueSize` (1000), `persistence` (`localStorage` | `memory`), `sessionTimeoutMinutes` (30), `respectDoNotTrack`, `optedOut` (consent; opting out also drops events already queued), `captureFullUrls` (default false: URLs drop the fragment and all query parameters except `utm_*`, `gclid`, `fbclid`, `ref`), `sanitizePath` (rewrite paths that carry secrets, e.g. `(p) => p.replace(/^\/reset\/[^/]+/, "/reset/:token")`), `onError`. `host` must be `https://` except on localhost.
31
31
 
32
32
  Events are sent to `POST {host}/a/batch`, which also accepts PostHog's capture format.
@@ -46,4 +46,4 @@ See `@shelbyb/daywalker-sdk/flags/react` for the `Providers` component.
46
46
 
47
47
  ## API
48
48
 
49
- `variation` / `variationDetail` (async) and `variationSync` / `variationDetailSync`, typed helpers (`boolVariation`, …), `allFlagsState(context, { clientSideOnly, withReasons })`, `secureModeHash(context)`, `track`, `identify`, `on("ready" | "failed" | "update" | "update:<key>")`, `flush()`, `close()`.
49
+ `variation` / `variationDetail` (async) and `variationSync` / `variationDetailSync`, typed helpers (`boolVariation`, …), `allFlagsState(context, { clientSideOnly, withReasons })` (`clientSideOnly` defaults to `true`, so server-only flags never reach a browser bootstrap), `secureModeHash(context)`, `track`, `identify`, `on("ready" | "failed" | "update" | "update:<key>" | "stale" | "recovered")`, `flush()`, `close()`.
package/docs/flags.md CHANGED
@@ -43,6 +43,7 @@ Only flags marked **Available to client-side SDKs** are visible.
43
43
  | `sendEvents` | `true` | Analytics for insights and experiments |
44
44
  | `privateAttributes` / `allAttributesPrivate` | — | Attributes never sent to DevPulse |
45
45
  | `cache` | `false` | Persist the last flags in `localStorage` and use them while loading |
46
+ | `offline` | `false` | Serve only `bootstrap`/cached values and open no connection (tests, server rendering) |
46
47
 
47
48
  ## Contexts
48
49
 
@@ -50,4 +51,4 @@ Single kind: `{ kind: "user", key: "u1", plan: "pro" }`. Multi-kind: `{ kind: "m
50
51
 
51
52
  ## API
52
53
 
53
- `variation(key, default)`, `variationDetail(key, default)`, `boolVariation` / `stringVariation` / `numberVariation` / `jsonVariation`, `allFlags()`, `identify(context)`, `track(key, data?, metricValue?)`, `on("ready" | "failed" | "change" | "change:<key>", fn)`, `flush()`, `close()`.
54
+ `variation(key, default)`, `variationDetail(key, default)`, `boolVariation` / `stringVariation` / `numberVariation` / `jsonVariation`, `allFlags()`, `identify(context)`, `track(key, data?, metricValue?)`, `on("ready" | "failed" | "change" | "change:<key>" | "stale" | "recovered", fn)` (`stale`: the stream dropped and the last flags keep serving; `recovered`: it reconnected), `flush()`, `close()`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shelbyb/daywalker-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Daywalker Auth service validation plus the DevPulse client SDKs (feature flags, OpenFeature, product analytics) in one zero-dependency package.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -8,47 +8,58 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
12
13
  },
13
14
  "./next": {
14
15
  "types": "./dist/next.d.ts",
15
- "import": "./dist/next.js"
16
+ "import": "./dist/next.js",
17
+ "default": "./dist/next.js"
16
18
  },
17
19
  "./middleware": {
18
20
  "types": "./dist/middleware.d.ts",
19
- "import": "./dist/middleware.js"
21
+ "import": "./dist/middleware.js",
22
+ "default": "./dist/middleware.js"
20
23
  },
21
24
  "./express": {
22
25
  "types": "./dist/express.d.ts",
23
- "import": "./dist/express.js"
26
+ "import": "./dist/express.js",
27
+ "default": "./dist/express.js"
24
28
  },
25
29
  "./flags": {
26
30
  "types": "./dist/flags/client.d.ts",
27
- "import": "./dist/flags/client.js"
31
+ "import": "./dist/flags/client.js",
32
+ "default": "./dist/flags/client.js"
28
33
  },
29
34
  "./flags/node": {
30
35
  "types": "./dist/flags/node.d.ts",
31
- "import": "./dist/flags/node.js"
36
+ "import": "./dist/flags/node.js",
37
+ "default": "./dist/flags/node.js"
32
38
  },
33
39
  "./flags/react": {
34
40
  "types": "./dist/flags/react.d.ts",
35
- "import": "./dist/flags/react.js"
41
+ "import": "./dist/flags/react.js",
42
+ "default": "./dist/flags/react.js"
36
43
  },
37
44
  "./flags/engine": {
38
45
  "types": "./dist/flags/engine/index.d.ts",
39
- "import": "./dist/flags/engine/index.js"
46
+ "import": "./dist/flags/engine/index.js",
47
+ "default": "./dist/flags/engine/index.js"
40
48
  },
41
49
  "./openfeature/server": {
42
50
  "types": "./dist/openfeature/server.d.ts",
43
- "import": "./dist/openfeature/server.js"
51
+ "import": "./dist/openfeature/server.js",
52
+ "default": "./dist/openfeature/server.js"
44
53
  },
45
54
  "./openfeature/web": {
46
55
  "types": "./dist/openfeature/web.d.ts",
47
- "import": "./dist/openfeature/web.js"
56
+ "import": "./dist/openfeature/web.js",
57
+ "default": "./dist/openfeature/web.js"
48
58
  },
49
59
  "./analytics": {
50
60
  "types": "./dist/analytics/index.d.ts",
51
- "import": "./dist/analytics/index.js"
61
+ "import": "./dist/analytics/index.js",
62
+ "default": "./dist/analytics/index.js"
52
63
  }
53
64
  },
54
65
  "bin": {
@@ -56,7 +67,6 @@
56
67
  },
57
68
  "files": [
58
69
  "dist",
59
- "assets",
60
70
  "docs",
61
71
  "README.md",
62
72
  "LICENSE"
@@ -68,6 +78,7 @@
68
78
  "build": "tsc -p tsconfig.json",
69
79
  "typecheck": "tsc -p tsconfig.json --noEmit",
70
80
  "test": "npm run build && node --import tsx --test test/*.test.ts",
81
+ "lint:package": "publint --strict && attw --pack . --profile esm-only",
71
82
  "prepublishOnly": "npm test"
72
83
  },
73
84
  "publishConfig": {
@@ -81,12 +92,16 @@
81
92
  },
82
93
  "license": "MIT",
83
94
  "devDependencies": {
95
+ "@arethetypeswrong/cli": "^0.18.5",
84
96
  "@openfeature/core": "^1.12.0",
85
97
  "@openfeature/server-sdk": "^1.23.0",
86
98
  "@openfeature/web-sdk": "^1.10.0",
87
99
  "@types/node": "^26.6.4",
88
100
  "@types/react": "^19.3.0",
101
+ "@types/react-dom": "^19.3.0",
102
+ "publint": "^0.3.25",
89
103
  "react": "^19.3.0",
104
+ "react-dom": "^19.3.1",
90
105
  "tsx": "^4.23.15",
91
106
  "typescript": "^7.0.2"
92
107
  },
@@ -121,5 +136,8 @@
121
136
  "optional": true
122
137
  }
123
138
  },
124
- "sideEffects": false
139
+ "sideEffects": false,
140
+ "dependencies": {
141
+ "re2js": "2.8.6"
142
+ }
125
143
  }
Binary file