@shelbyb/daywalker-sdk 0.1.3 → 0.2.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 +26 -1
- package/dist/analytics/index.d.ts +99 -0
- package/dist/analytics/index.js +352 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +31 -3
- package/dist/config.d.ts +1 -0
- package/dist/config.js +2 -0
- package/dist/flags/client.d.ts +101 -0
- package/dist/flags/client.js +293 -0
- package/dist/flags/common/events.d.ts +59 -0
- package/dist/flags/common/events.js +178 -0
- package/dist/flags/common/index.d.ts +5 -0
- package/dist/flags/common/index.js +12 -0
- package/dist/flags/common/sse.d.ts +62 -0
- package/dist/flags/common/sse.js +183 -0
- package/dist/flags/engine/bucket.d.ts +21 -0
- package/dist/flags/engine/bucket.js +41 -0
- package/dist/flags/engine/context.d.ts +30 -0
- package/dist/flags/engine/context.js +80 -0
- package/dist/flags/engine/evaluate.d.ts +54 -0
- package/dist/flags/engine/evaluate.js +177 -0
- package/dist/flags/engine/index.d.ts +7 -0
- package/dist/flags/engine/index.js +7 -0
- package/dist/flags/engine/operators.d.ts +5 -0
- package/dist/flags/engine/operators.js +89 -0
- package/dist/flags/engine/semver.d.ts +13 -0
- package/dist/flags/engine/semver.js +35 -0
- package/dist/flags/engine/sha1.d.ts +6 -0
- package/dist/flags/engine/sha1.js +43 -0
- package/dist/flags/engine/types.d.ts +120 -0
- package/dist/flags/engine/types.js +17 -0
- package/dist/flags/node.d.ts +97 -0
- package/dist/flags/node.js +260 -0
- package/dist/flags/react.d.ts +27 -0
- package/dist/flags/react.js +62 -0
- package/dist/openfeature/server.d.ts +26 -0
- package/dist/openfeature/server.js +42 -0
- package/dist/openfeature/shared.d.ts +19 -0
- package/dist/openfeature/shared.js +40 -0
- package/dist/openfeature/web.d.ts +29 -0
- package/dist/openfeature/web.js +50 -0
- package/dist/types.d.ts +2 -0
- package/docs/analytics.md +32 -0
- package/docs/flags-node.md +49 -0
- package/docs/flags-react.md +35 -0
- package/docs/flags.md +53 -0
- package/docs/openfeature.md +30 -0
- package/package.json +61 -6
package/README.md
CHANGED
|
@@ -210,14 +210,39 @@ Inbound tokens may be supplied as `x-api-token: srv_live_...` or `Authorization:
|
|
|
210
210
|
|
|
211
211
|
Each outbound request signed by `@shelbyb/daywalker-sdk` includes:
|
|
212
212
|
- `Content-Digest: sha-256=:<base64-encoded-hash>:`
|
|
213
|
-
- `Signature-Input: sig1=("@method" "@path" "@authority" "content-digest");created=<unix_seconds>;keyid="<kid>";alg="ed25519"`
|
|
213
|
+
- `Signature-Input: sig1=("@method" "@path" "@authority" "content-digest");created=<unix_seconds>;keyid="<kid>";alg="ed25519";nonce="<random>"`
|
|
214
214
|
- `Signature: sig1=:<base64-encoded-signature>:`
|
|
215
215
|
- `Authorization: Bearer <DAYWALKER_API_KEY>`
|
|
216
216
|
|
|
217
|
+
A fresh random `nonce` is generated for every attempt (including retries), so identical requests in the same second still produce unique signatures and never trip replay protection. `validateToken()` also shares one in-flight request between concurrent callers for the same token, and briefly caches failures (`negativeCacheTtlMs`, default 3000ms; 429s and network errors are never cached).
|
|
218
|
+
|
|
217
219
|
The exact signature base and test cases are verified against [`test-vectors/signing-rfc9421.json`](./test-vectors/signing-rfc9421.json).
|
|
218
220
|
|
|
219
221
|
---
|
|
220
222
|
|
|
223
|
+
## DevPulse SDKs (one package)
|
|
224
|
+
|
|
225
|
+
The same package ships the [DevPulse](https://github.com/shelbyb/devpulse) client SDKs as subpath exports. Each subpath is tree-shakeable and has zero dependencies; `react` and the OpenFeature SDKs are optional peer dependencies used only by their subpaths.
|
|
226
|
+
|
|
227
|
+
| Import | What it is | Docs |
|
|
228
|
+
|---|---|---|
|
|
229
|
+
| `@shelbyb/daywalker-sdk/flags` | Feature flags for browsers and JS clients, real-time over SSE | [docs/flags.md](./docs/flags.md) |
|
|
230
|
+
| `@shelbyb/daywalker-sdk/flags/node` | Server-side flags with local evaluation and streaming updates | [docs/flags-node.md](./docs/flags-node.md) |
|
|
231
|
+
| `@shelbyb/daywalker-sdk/flags/react` | React provider and hooks (SSR bootstrap, no flicker) | [docs/flags-react.md](./docs/flags-react.md) |
|
|
232
|
+
| `@shelbyb/daywalker-sdk/flags/engine` | The deterministic evaluation engine (advanced / testing) | — |
|
|
233
|
+
| `@shelbyb/daywalker-sdk/openfeature/server`, `/openfeature/web` | OpenFeature providers | [docs/openfeature.md](./docs/openfeature.md) |
|
|
234
|
+
| `@shelbyb/daywalker-sdk/analytics` | Product analytics: events, page views, identify, sessions (browser and Node) | [docs/analytics.md](./docs/analytics.md) |
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import { createClient } from "@shelbyb/daywalker-sdk/flags";
|
|
238
|
+
import { createAnalytics } from "@shelbyb/daywalker-sdk/analytics";
|
|
239
|
+
|
|
240
|
+
const flags = createClient(clientSideId, { kind: "user", key: user.id }, { baseUrl: "https://devpulse.example.com" });
|
|
241
|
+
const analytics = createAnalytics({ writeKey: "dpa_…", host: "https://devpulse.example.com", flags }); // events carry $feature/<key>
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
221
246
|
## License
|
|
222
247
|
|
|
223
248
|
MIT
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevPulse product analytics. Works in browsers (automatic page views,
|
|
3
|
+
* sessions, persistence, sendBeacon on page hide) and in Node (pass a
|
|
4
|
+
* distinctId per call). Events are batched and retried with backoff.
|
|
5
|
+
*/
|
|
6
|
+
export type Properties = Record<string, unknown>;
|
|
7
|
+
/** Anything with `allFlags()`, e.g. a DevPulse feature-flag client. */
|
|
8
|
+
export type FlagSource = {
|
|
9
|
+
allFlags(): Record<string, unknown>;
|
|
10
|
+
};
|
|
11
|
+
export type AnalyticsOptions = {
|
|
12
|
+
/** The project's public write key (dpa_…). */
|
|
13
|
+
writeKey: string;
|
|
14
|
+
/** Your DevPulse URL, e.g. https://devpulse.example.com */
|
|
15
|
+
host: string;
|
|
16
|
+
/** Capture $pageview on load and on client-side navigation (browsers). Default true. */
|
|
17
|
+
autocapturePageviews?: boolean;
|
|
18
|
+
/** Send when this many events are queued. Default 20 (1 in Node is not needed: use flush()). */
|
|
19
|
+
flushAt?: number;
|
|
20
|
+
/** Send queued events this often, in ms. Default 2000 in browsers, 10000 in Node. */
|
|
21
|
+
flushInterval?: number;
|
|
22
|
+
/** Events kept while offline; the oldest are dropped past this. Default 1000. */
|
|
23
|
+
maxQueueSize?: number;
|
|
24
|
+
/** Where the anonymous id, super properties and session live. Default localStorage in browsers. */
|
|
25
|
+
persistence?: "localStorage" | "memory";
|
|
26
|
+
/** A new session starts after this much inactivity. Default 30. */
|
|
27
|
+
sessionTimeoutMinutes?: number;
|
|
28
|
+
/** Adds `$feature/<key>` properties with each flag's current value to every event. */
|
|
29
|
+
flags?: FlagSource;
|
|
30
|
+
/** Don't send anything when the browser's Do Not Track is on. Default false. */
|
|
31
|
+
respectDoNotTrack?: boolean;
|
|
32
|
+
/** Start opted out (e.g. until the user accepts a consent banner). */
|
|
33
|
+
optedOut?: boolean;
|
|
34
|
+
fetch?: typeof fetch;
|
|
35
|
+
onError?: (err: Error) => void;
|
|
36
|
+
};
|
|
37
|
+
export type CaptureOptions = {
|
|
38
|
+
/** Server-side: who did it. Defaults to the current browser user. */
|
|
39
|
+
distinctId?: string;
|
|
40
|
+
timestamp?: Date;
|
|
41
|
+
$set?: Properties;
|
|
42
|
+
$set_once?: Properties;
|
|
43
|
+
};
|
|
44
|
+
export declare const VERSION = "0.1.0";
|
|
45
|
+
/** Time-ordered UUID (v7). */
|
|
46
|
+
export declare function uuidv7(ms?: number): string;
|
|
47
|
+
export declare class Analytics {
|
|
48
|
+
private opts;
|
|
49
|
+
private store;
|
|
50
|
+
private state;
|
|
51
|
+
private queue;
|
|
52
|
+
private timer;
|
|
53
|
+
private sending;
|
|
54
|
+
private backoff;
|
|
55
|
+
private lastUrl;
|
|
56
|
+
private cleanup;
|
|
57
|
+
private endpoint;
|
|
58
|
+
constructor(options: AnalyticsOptions);
|
|
59
|
+
/** Record an event. */
|
|
60
|
+
capture(event: string, properties?: Properties, opts?: CaptureOptions): void;
|
|
61
|
+
/** Record a page view (automatic unless autocapturePageviews is false). */
|
|
62
|
+
pageview(properties?: Properties): void;
|
|
63
|
+
/**
|
|
64
|
+
* Link this browser to a known user. Events from before (the anonymous id)
|
|
65
|
+
* are merged into the user's profile. Optionally set person properties.
|
|
66
|
+
*/
|
|
67
|
+
identify(distinctId: string, set?: Properties, setOnce?: Properties): void;
|
|
68
|
+
/** Update the current person's properties. */
|
|
69
|
+
setPersonProperties(set?: Properties, setOnce?: Properties, opts?: {
|
|
70
|
+
distinctId?: string;
|
|
71
|
+
}): void;
|
|
72
|
+
/** Properties sent with every event from now on (persisted in browsers). */
|
|
73
|
+
register(properties: Properties): void;
|
|
74
|
+
unregister(key: string): void;
|
|
75
|
+
/** On logout: a new anonymous id, no super properties, a new session. */
|
|
76
|
+
reset(): void;
|
|
77
|
+
optOut(): void;
|
|
78
|
+
optIn(): void;
|
|
79
|
+
hasOptedOut(): boolean;
|
|
80
|
+
getDistinctId(): string;
|
|
81
|
+
getSessionId(): string | null;
|
|
82
|
+
/** Send everything queued now. */
|
|
83
|
+
flush(): Promise<void>;
|
|
84
|
+
/** Flush and detach listeners (call before a Node process exits). */
|
|
85
|
+
shutdown(): Promise<void>;
|
|
86
|
+
private disabled;
|
|
87
|
+
private context;
|
|
88
|
+
private flagProps;
|
|
89
|
+
/** Rolling session: renewed after inactivity or at most every 24 hours. */
|
|
90
|
+
private session;
|
|
91
|
+
private enqueue;
|
|
92
|
+
private schedule;
|
|
93
|
+
/** Send one batch; on failure requeue it and back off. Resolves true when sent (or dropped as invalid). */
|
|
94
|
+
private send;
|
|
95
|
+
/** On page hide, hand the queue to sendBeacon so it survives navigation. */
|
|
96
|
+
private beacon;
|
|
97
|
+
private attachBrowser;
|
|
98
|
+
}
|
|
99
|
+
export declare function createAnalytics(options: AnalyticsOptions): Analytics;
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevPulse product analytics. Works in browsers (automatic page views,
|
|
3
|
+
* sessions, persistence, sendBeacon on page hide) and in Node (pass a
|
|
4
|
+
* distinctId per call). Events are batched and retried with backoff.
|
|
5
|
+
*/
|
|
6
|
+
export const VERSION = "0.1.0";
|
|
7
|
+
const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
|
|
8
|
+
const MAX_SESSION_MS = 24 * 3_600_000;
|
|
9
|
+
/** Time-ordered UUID (v7). */
|
|
10
|
+
export function uuidv7(ms = Date.now()) {
|
|
11
|
+
const b = new Uint8Array(16);
|
|
12
|
+
const c = globalThis.crypto;
|
|
13
|
+
if (c?.getRandomValues)
|
|
14
|
+
c.getRandomValues(b);
|
|
15
|
+
else
|
|
16
|
+
for (let i = 0; i < 16; i++)
|
|
17
|
+
b[i] = Math.floor(Math.random() * 256);
|
|
18
|
+
for (let i = 0; i < 6; i++)
|
|
19
|
+
b[i] = Math.floor(ms / 2 ** (8 * (5 - i))) % 256;
|
|
20
|
+
b[6] = (b[6] & 0x0f) | 0x70;
|
|
21
|
+
b[8] = (b[8] & 0x3f) | 0x80;
|
|
22
|
+
const h = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
23
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
24
|
+
}
|
|
25
|
+
class Store {
|
|
26
|
+
key;
|
|
27
|
+
local;
|
|
28
|
+
mem = null;
|
|
29
|
+
constructor(key, local) {
|
|
30
|
+
this.key = key;
|
|
31
|
+
this.local = local;
|
|
32
|
+
}
|
|
33
|
+
load() {
|
|
34
|
+
if (this.mem)
|
|
35
|
+
return this.mem;
|
|
36
|
+
if (this.local) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = localStorage.getItem(this.key);
|
|
39
|
+
if (raw)
|
|
40
|
+
this.mem = JSON.parse(raw);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* storage blocked: memory only */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return this.mem;
|
|
47
|
+
}
|
|
48
|
+
save(s) {
|
|
49
|
+
this.mem = s;
|
|
50
|
+
if (!this.local)
|
|
51
|
+
return;
|
|
52
|
+
try {
|
|
53
|
+
localStorage.setItem(this.key, JSON.stringify(s));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* storage full or blocked */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export class Analytics {
|
|
61
|
+
opts;
|
|
62
|
+
store;
|
|
63
|
+
state;
|
|
64
|
+
queue = [];
|
|
65
|
+
timer;
|
|
66
|
+
sending = null;
|
|
67
|
+
backoff = 0;
|
|
68
|
+
lastUrl = "";
|
|
69
|
+
cleanup = [];
|
|
70
|
+
endpoint;
|
|
71
|
+
constructor(options) {
|
|
72
|
+
if (!options.writeKey)
|
|
73
|
+
throw new Error("writeKey is required");
|
|
74
|
+
if (!options.host)
|
|
75
|
+
throw new Error("host is required");
|
|
76
|
+
this.opts = { flushAt: 20, flushInterval: isBrowser ? 2000 : 10000, maxQueueSize: 1000, sessionTimeoutMinutes: 30, ...options };
|
|
77
|
+
this.endpoint = `${options.host.replace(/\/+$/, "")}/a/batch`;
|
|
78
|
+
this.store = new Store(`dp_analytics_${options.writeKey}`, (options.persistence ?? (isBrowser ? "localStorage" : "memory")) === "localStorage" && isBrowser);
|
|
79
|
+
this.state = this.store.load() ?? { distinctId: uuidv7(), identified: false, props: {} };
|
|
80
|
+
if (options.optedOut !== undefined)
|
|
81
|
+
this.state.optedOut = options.optedOut;
|
|
82
|
+
this.store.save(this.state);
|
|
83
|
+
if (isBrowser)
|
|
84
|
+
this.attachBrowser();
|
|
85
|
+
}
|
|
86
|
+
// ---------- Public API ----------
|
|
87
|
+
/** Record an event. */
|
|
88
|
+
capture(event, properties = {}, opts = {}) {
|
|
89
|
+
if (!event || this.disabled())
|
|
90
|
+
return;
|
|
91
|
+
const now = opts.timestamp ?? new Date();
|
|
92
|
+
const props = { ...this.context(), ...this.state.props, ...this.flagProps(), ...properties };
|
|
93
|
+
if (!opts.distinctId)
|
|
94
|
+
props.$session_id = this.session(now.getTime());
|
|
95
|
+
const e = { uuid: uuidv7(now.getTime()), event, distinct_id: opts.distinctId ?? this.state.distinctId, timestamp: now.toISOString(), properties: props };
|
|
96
|
+
if (opts.$set)
|
|
97
|
+
e.$set = opts.$set;
|
|
98
|
+
if (opts.$set_once)
|
|
99
|
+
e.$set_once = opts.$set_once;
|
|
100
|
+
this.enqueue(e);
|
|
101
|
+
}
|
|
102
|
+
/** Record a page view (automatic unless autocapturePageviews is false). */
|
|
103
|
+
pageview(properties = {}) {
|
|
104
|
+
if (!isBrowser)
|
|
105
|
+
return this.capture("$pageview", properties);
|
|
106
|
+
const url = location.href;
|
|
107
|
+
this.lastUrl = url;
|
|
108
|
+
const utm = Object.fromEntries([...new URLSearchParams(location.search)].filter(([k]) => /^utm_(source|medium|campaign|term|content)$/.test(k)));
|
|
109
|
+
let setOnce;
|
|
110
|
+
if (!this.state.initialSent) {
|
|
111
|
+
setOnce = {
|
|
112
|
+
$initial_referrer: document.referrer || "$direct",
|
|
113
|
+
$initial_referring_domain: referringDomain(document.referrer) ?? "$direct",
|
|
114
|
+
$initial_current_url: url,
|
|
115
|
+
$initial_pathname: location.pathname,
|
|
116
|
+
...Object.fromEntries(Object.entries(utm).map(([k, v]) => [`$initial_${k}`, v])),
|
|
117
|
+
};
|
|
118
|
+
this.state.initialSent = true;
|
|
119
|
+
this.store.save(this.state);
|
|
120
|
+
}
|
|
121
|
+
this.capture("$pageview", { $title: document.title, ...utm, ...properties }, { $set_once: setOnce });
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Link this browser to a known user. Events from before (the anonymous id)
|
|
125
|
+
* are merged into the user's profile. Optionally set person properties.
|
|
126
|
+
*/
|
|
127
|
+
identify(distinctId, set, setOnce) {
|
|
128
|
+
if (!distinctId || this.disabled())
|
|
129
|
+
return;
|
|
130
|
+
const previous = this.state.distinctId;
|
|
131
|
+
if (previous === distinctId) {
|
|
132
|
+
if (set || setOnce)
|
|
133
|
+
this.setPersonProperties(set, setOnce);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.state = { ...this.state, distinctId, identified: true };
|
|
137
|
+
this.store.save(this.state);
|
|
138
|
+
this.capture("$identify", { $anon_distinct_id: previous }, { $set: set, $set_once: setOnce });
|
|
139
|
+
}
|
|
140
|
+
/** Update the current person's properties. */
|
|
141
|
+
setPersonProperties(set, setOnce, opts = {}) {
|
|
142
|
+
this.capture("$set", {}, { distinctId: opts.distinctId, $set: set, $set_once: setOnce });
|
|
143
|
+
}
|
|
144
|
+
/** Properties sent with every event from now on (persisted in browsers). */
|
|
145
|
+
register(properties) {
|
|
146
|
+
this.state = { ...this.state, props: { ...this.state.props, ...properties } };
|
|
147
|
+
this.store.save(this.state);
|
|
148
|
+
}
|
|
149
|
+
unregister(key) {
|
|
150
|
+
const props = { ...this.state.props };
|
|
151
|
+
delete props[key];
|
|
152
|
+
this.state = { ...this.state, props };
|
|
153
|
+
this.store.save(this.state);
|
|
154
|
+
}
|
|
155
|
+
/** On logout: a new anonymous id, no super properties, a new session. */
|
|
156
|
+
reset() {
|
|
157
|
+
this.state = { distinctId: uuidv7(), identified: false, props: {}, optedOut: this.state.optedOut, initialSent: this.state.initialSent };
|
|
158
|
+
this.store.save(this.state);
|
|
159
|
+
}
|
|
160
|
+
optOut() {
|
|
161
|
+
this.state = { ...this.state, optedOut: true };
|
|
162
|
+
this.store.save(this.state);
|
|
163
|
+
this.queue = [];
|
|
164
|
+
}
|
|
165
|
+
optIn() {
|
|
166
|
+
this.state = { ...this.state, optedOut: false };
|
|
167
|
+
this.store.save(this.state);
|
|
168
|
+
}
|
|
169
|
+
hasOptedOut() {
|
|
170
|
+
return !!this.state.optedOut;
|
|
171
|
+
}
|
|
172
|
+
getDistinctId() {
|
|
173
|
+
return this.state.distinctId;
|
|
174
|
+
}
|
|
175
|
+
getSessionId() {
|
|
176
|
+
return this.state.session?.id ?? null;
|
|
177
|
+
}
|
|
178
|
+
/** Send everything queued now. */
|
|
179
|
+
async flush() {
|
|
180
|
+
clearTimeout(this.timer);
|
|
181
|
+
this.timer = undefined;
|
|
182
|
+
while (this.queue.length) {
|
|
183
|
+
if (this.sending)
|
|
184
|
+
await this.sending;
|
|
185
|
+
else {
|
|
186
|
+
this.sending = this.send().finally(() => (this.sending = null));
|
|
187
|
+
const ok = await this.sending;
|
|
188
|
+
if (!ok)
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Flush and detach listeners (call before a Node process exits). */
|
|
194
|
+
async shutdown() {
|
|
195
|
+
for (const fn of this.cleanup.splice(0))
|
|
196
|
+
fn();
|
|
197
|
+
await this.flush();
|
|
198
|
+
}
|
|
199
|
+
// ---------- Internals ----------
|
|
200
|
+
disabled() {
|
|
201
|
+
if (this.state.optedOut)
|
|
202
|
+
return true;
|
|
203
|
+
if (this.opts.respectDoNotTrack && isBrowser) {
|
|
204
|
+
const nav = navigator;
|
|
205
|
+
return nav.doNotTrack === "1" || nav.msDoNotTrack === "1" || window.doNotTrack === "1";
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
context() {
|
|
210
|
+
const base = { $lib: "devpulse-analytics", $lib_version: VERSION };
|
|
211
|
+
if (!isBrowser)
|
|
212
|
+
return base;
|
|
213
|
+
return {
|
|
214
|
+
...base,
|
|
215
|
+
$current_url: location.href,
|
|
216
|
+
$host: location.host,
|
|
217
|
+
$pathname: location.pathname,
|
|
218
|
+
$referrer: document.referrer || "$direct",
|
|
219
|
+
$referring_domain: referringDomain(document.referrer) ?? "$direct",
|
|
220
|
+
$screen_width: screen.width,
|
|
221
|
+
$screen_height: screen.height,
|
|
222
|
+
$viewport_width: window.innerWidth,
|
|
223
|
+
$viewport_height: window.innerHeight,
|
|
224
|
+
$browser_language: navigator.language,
|
|
225
|
+
$device_id: this.state.identified ? undefined : this.state.distinctId,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
flagProps() {
|
|
229
|
+
if (!this.opts.flags)
|
|
230
|
+
return {};
|
|
231
|
+
try {
|
|
232
|
+
const all = this.opts.flags.allFlags();
|
|
233
|
+
return Object.fromEntries(Object.entries(all).map(([k, v]) => [`$feature/${k}`, v]));
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return {};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** Rolling session: renewed after inactivity or at most every 24 hours. */
|
|
240
|
+
session(now) {
|
|
241
|
+
const s = this.state.session;
|
|
242
|
+
const timeout = this.opts.sessionTimeoutMinutes * 60_000;
|
|
243
|
+
if (!s || now - s.last > timeout || now - s.start > MAX_SESSION_MS)
|
|
244
|
+
this.state.session = { id: uuidv7(now), last: now, start: now };
|
|
245
|
+
else
|
|
246
|
+
s.last = now;
|
|
247
|
+
this.store.save(this.state);
|
|
248
|
+
return this.state.session.id;
|
|
249
|
+
}
|
|
250
|
+
enqueue(e) {
|
|
251
|
+
this.queue.push(e);
|
|
252
|
+
if (this.queue.length > this.opts.maxQueueSize)
|
|
253
|
+
this.queue.splice(0, this.queue.length - this.opts.maxQueueSize);
|
|
254
|
+
if (this.queue.length >= this.opts.flushAt && !this.backoff)
|
|
255
|
+
void this.flush();
|
|
256
|
+
else
|
|
257
|
+
this.schedule();
|
|
258
|
+
}
|
|
259
|
+
schedule() {
|
|
260
|
+
if (this.timer)
|
|
261
|
+
return;
|
|
262
|
+
this.timer = setTimeout(() => {
|
|
263
|
+
this.timer = undefined;
|
|
264
|
+
void this.flush();
|
|
265
|
+
}, this.backoff || this.opts.flushInterval);
|
|
266
|
+
this.timer.unref?.();
|
|
267
|
+
}
|
|
268
|
+
/** Send one batch; on failure requeue it and back off. Resolves true when sent (or dropped as invalid). */
|
|
269
|
+
async send() {
|
|
270
|
+
const batch = this.queue.splice(0, 100);
|
|
271
|
+
if (!batch.length)
|
|
272
|
+
return true;
|
|
273
|
+
const f = this.opts.fetch ?? globalThis.fetch;
|
|
274
|
+
try {
|
|
275
|
+
const res = await f(this.endpoint, {
|
|
276
|
+
method: "POST",
|
|
277
|
+
headers: { "content-type": "application/json" },
|
|
278
|
+
body: JSON.stringify({ api_key: this.opts.writeKey, batch, sent_at: new Date().toISOString() }),
|
|
279
|
+
keepalive: isBrowser,
|
|
280
|
+
});
|
|
281
|
+
if (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429)) {
|
|
282
|
+
if (!res.ok)
|
|
283
|
+
this.opts.onError?.(new Error(`DevPulse analytics rejected ${batch.length} events: HTTP ${res.status}`));
|
|
284
|
+
this.backoff = 0;
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
throw new Error(`HTTP ${res.status}`);
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
this.queue.unshift(...batch);
|
|
291
|
+
this.backoff = Math.min(60_000, (this.backoff || 1000) * 2);
|
|
292
|
+
this.opts.onError?.(err);
|
|
293
|
+
this.schedule();
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** On page hide, hand the queue to sendBeacon so it survives navigation. */
|
|
298
|
+
beacon() {
|
|
299
|
+
if (!this.queue.length || typeof navigator.sendBeacon !== "function")
|
|
300
|
+
return;
|
|
301
|
+
const batch = this.queue.splice(0, 100);
|
|
302
|
+
const body = JSON.stringify({ api_key: this.opts.writeKey, batch, sent_at: new Date().toISOString() });
|
|
303
|
+
// text/plain avoids a CORS preflight, which beacons can't do.
|
|
304
|
+
if (!navigator.sendBeacon(this.endpoint, new Blob([body], { type: "text/plain" })))
|
|
305
|
+
this.queue.unshift(...batch);
|
|
306
|
+
}
|
|
307
|
+
attachBrowser() {
|
|
308
|
+
const onHide = () => document.visibilityState === "hidden" && this.beacon();
|
|
309
|
+
const onPageHide = () => this.beacon();
|
|
310
|
+
document.addEventListener("visibilitychange", onHide);
|
|
311
|
+
window.addEventListener("pagehide", onPageHide);
|
|
312
|
+
this.cleanup.push(() => {
|
|
313
|
+
document.removeEventListener("visibilitychange", onHide);
|
|
314
|
+
window.removeEventListener("pagehide", onPageHide);
|
|
315
|
+
});
|
|
316
|
+
if (this.opts.autocapturePageviews === false)
|
|
317
|
+
return;
|
|
318
|
+
const onNav = () => {
|
|
319
|
+
// Route changes fire before the new title is set; give the app a tick.
|
|
320
|
+
setTimeout(() => location.href !== this.lastUrl && this.pageview(), 0);
|
|
321
|
+
};
|
|
322
|
+
const { pushState, replaceState } = history;
|
|
323
|
+
history.pushState = function (...args) {
|
|
324
|
+
pushState.apply(this, args);
|
|
325
|
+
onNav();
|
|
326
|
+
};
|
|
327
|
+
history.replaceState = function (...args) {
|
|
328
|
+
replaceState.apply(this, args);
|
|
329
|
+
onNav();
|
|
330
|
+
};
|
|
331
|
+
window.addEventListener("popstate", onNav);
|
|
332
|
+
this.cleanup.push(() => {
|
|
333
|
+
history.pushState = pushState;
|
|
334
|
+
history.replaceState = replaceState;
|
|
335
|
+
window.removeEventListener("popstate", onNav);
|
|
336
|
+
});
|
|
337
|
+
this.pageview();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function referringDomain(ref) {
|
|
341
|
+
if (!ref)
|
|
342
|
+
return undefined;
|
|
343
|
+
try {
|
|
344
|
+
return new URL(ref).hostname;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
export function createAnalytics(options) {
|
|
351
|
+
return new Analytics(options);
|
|
352
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export declare class Daywalker {
|
|
|
9
9
|
private readonly _rawConfig;
|
|
10
10
|
private _signingKeyPromise?;
|
|
11
11
|
private readonly _cache;
|
|
12
|
+
private readonly _inflight;
|
|
12
13
|
constructor(config?: DaywalkerConfig);
|
|
13
14
|
get config(): ResolvedConfig;
|
|
14
15
|
toJSON(): Record<string, unknown>;
|
|
@@ -31,6 +32,7 @@ export declare class Daywalker {
|
|
|
31
32
|
forceRefresh?: boolean;
|
|
32
33
|
offlineOnly?: boolean;
|
|
33
34
|
}): Promise<ValidateResult>;
|
|
35
|
+
private _validateOnline;
|
|
34
36
|
/**
|
|
35
37
|
* Throws DaywalkerAuthError or DaywalkerRateLimitError if token is invalid or rate limited.
|
|
36
38
|
*/
|
package/dist/client.js
CHANGED
|
@@ -15,11 +15,20 @@ function parseRetryAfter(headerValue) {
|
|
|
15
15
|
}
|
|
16
16
|
return undefined;
|
|
17
17
|
}
|
|
18
|
+
function generateNonce() {
|
|
19
|
+
const bytes = new Uint8Array(16);
|
|
20
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
21
|
+
let bin = "";
|
|
22
|
+
for (const b of bytes)
|
|
23
|
+
bin += String.fromCharCode(b);
|
|
24
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
25
|
+
}
|
|
18
26
|
export class Daywalker {
|
|
19
27
|
_config;
|
|
20
28
|
_rawConfig;
|
|
21
29
|
_signingKeyPromise;
|
|
22
30
|
_cache = new Map();
|
|
31
|
+
_inflight = new Map();
|
|
23
32
|
constructor(config = {}) {
|
|
24
33
|
this._rawConfig = config;
|
|
25
34
|
this._config = resolveConfig(config);
|
|
@@ -104,6 +113,7 @@ export class Daywalker {
|
|
|
104
113
|
key: signingKey,
|
|
105
114
|
keyid: this._config.keyId ?? undefined,
|
|
106
115
|
apiKey: this._config.apiKey ?? undefined,
|
|
116
|
+
nonce: generateNonce(),
|
|
107
117
|
});
|
|
108
118
|
for (const [k, v] of Object.entries(signedHeaders)) {
|
|
109
119
|
if (v !== undefined) {
|
|
@@ -231,7 +241,25 @@ export class Daywalker {
|
|
|
231
241
|
message: "Offline verification requires DAYWALKER_PUBLIC_KEY and a valid JWT token lease",
|
|
232
242
|
};
|
|
233
243
|
}
|
|
234
|
-
// Online verification
|
|
244
|
+
// Online verification, deduplicated across concurrent callers
|
|
245
|
+
const inflightKey = `${cacheKey}`;
|
|
246
|
+
const pending = this._inflight.get(inflightKey);
|
|
247
|
+
if (pending)
|
|
248
|
+
return pending;
|
|
249
|
+
const run = this._validateOnline(token, serviceSlug, cacheKey).finally(() => {
|
|
250
|
+
this._inflight.delete(inflightKey);
|
|
251
|
+
});
|
|
252
|
+
this._inflight.set(inflightKey, run);
|
|
253
|
+
return run;
|
|
254
|
+
}
|
|
255
|
+
async _validateOnline(token, serviceSlug, cacheKey) {
|
|
256
|
+
const negTtl = this._config.negativeCacheTtlMs;
|
|
257
|
+
const fail = (result) => {
|
|
258
|
+
if (negTtl > 0 && result.error !== "RATE_LIMITED" && result.error !== "NETWORK_ERROR") {
|
|
259
|
+
this._cache.set(cacheKey, { result, expiresAt: Date.now() + negTtl });
|
|
260
|
+
}
|
|
261
|
+
return result;
|
|
262
|
+
};
|
|
235
263
|
try {
|
|
236
264
|
const validateBody = { token };
|
|
237
265
|
if (serviceSlug) {
|
|
@@ -282,12 +310,12 @@ export class Daywalker {
|
|
|
282
310
|
else if (json.error === "REVOKED" || /revoked/i.test(errMsg)) {
|
|
283
311
|
error = "REVOKED";
|
|
284
312
|
}
|
|
285
|
-
return {
|
|
313
|
+
return fail({
|
|
286
314
|
ok: false,
|
|
287
315
|
error,
|
|
288
316
|
status: res.status,
|
|
289
317
|
message: redactSecrets(errMsg || `Validation failed with HTTP ${res.status}`),
|
|
290
|
-
};
|
|
318
|
+
});
|
|
291
319
|
}
|
|
292
320
|
catch (err) {
|
|
293
321
|
return {
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -82,6 +82,7 @@ export function resolveConfig(options = {}) {
|
|
|
82
82
|
const daywalkerPublicKey = options.daywalkerPublicKey ?? (env.DAYWALKER_PUBLIC_KEY ? env.DAYWALKER_PUBLIC_KEY.trim() : null);
|
|
83
83
|
const authUrl = (options.authUrl ?? env.DAYWALKER_AUTH_URL ?? "https://auth.daywalker.dev").replace(/\/+$/, "");
|
|
84
84
|
const cacheTtlMs = options.cacheTtlMs ?? 60_000;
|
|
85
|
+
const negativeCacheTtlMs = options.negativeCacheTtlMs ?? 3_000;
|
|
85
86
|
const maxRetries = options.maxRetries ?? (options.retry?.maxRetries ?? 3);
|
|
86
87
|
return {
|
|
87
88
|
apiKey,
|
|
@@ -94,6 +95,7 @@ export function resolveConfig(options = {}) {
|
|
|
94
95
|
daywalkerPublicKey,
|
|
95
96
|
authUrl,
|
|
96
97
|
cacheTtlMs,
|
|
98
|
+
negativeCacheTtlMs,
|
|
97
99
|
maxRetries,
|
|
98
100
|
problems,
|
|
99
101
|
};
|