@shelbyb/daywalker-sdk 0.2.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 +38 -24
- package/dist/analytics/index.d.ts +26 -3
- package/dist/analytics/index.js +107 -23
- package/dist/cli.js +136 -29
- package/dist/client.d.ts +44 -3
- package/dist/client.js +372 -73
- package/dist/config.d.ts +14 -2
- package/dist/config.js +36 -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 +54 -11
- package/dist/crypto/signatures.js +310 -94
- package/dist/express.d.ts +1 -0
- package/dist/express.js +8 -6
- package/dist/flags/client.d.ts +34 -5
- package/dist/flags/client.js +93 -22
- package/dist/flags/common/events.d.ts +9 -0
- package/dist/flags/common/events.js +77 -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/context.js +4 -3
- package/dist/flags/engine/index.d.ts +1 -1
- package/dist/flags/engine/index.js +1 -1
- package/dist/flags/engine/operators.d.ts +25 -0
- package/dist/flags/engine/operators.js +64 -8
- package/dist/flags/node.d.ts +27 -15
- package/dist/flags/node.js +54 -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 +63 -6
- package/dist/next.js +182 -36
- 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 +58 -3
- 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 +34 -16
- package/assets/logo-512.png +0 -0
package/README.md
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="
|
|
2
|
+
<img src="https://raw.githubusercontent.com/Shelbyb/daywalker-sdk/main/assets/logo-512.png" alt="Daywalker" width="160" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# @shelbyb/daywalker-sdk
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Runtime-agnostic client library for [Daywalker Auth](https://auth.daywalker.dev). Supports **RFC 9421 (HTTP Message Signatures)**, **RFC 9530 (Digest Fields)**, **Web Crypto standards**, **Push Architecture key registration**, and **hardened web & Next.js middleware**.
|
|
8
8
|
|
|
9
|
-
Runs
|
|
9
|
+
Runs on **Node.js 20.19+**, **Bun**, **Deno**, **Cloudflare Workers**, **Next.js Edge**, and browsers. ESM only; `require()` works on Node 20.19+ and 22.12+.
|
|
10
10
|
|
|
11
|
-
- **
|
|
12
|
-
- **RFC 9421 (HTTP Message Signatures)**: Cryptographically binds `@method`, `@path`, `@authority`, and `content-digest` using Ed25519 signatures (`Signature-Input`, `Signature`).
|
|
11
|
+
- **Web Standards Core**: standard `crypto.subtle`, `Uint8Array`, `fetch`, `Request`, and `Response`. The one runtime dependency is [`re2js`](https://www.npmjs.com/package/re2js), so flag regex rules run in linear time.
|
|
12
|
+
- **RFC 9421 (HTTP Message Signatures)**: Cryptographically binds `@method`, `@path`, `@query` (when the URL has one), `@authority`, and `content-digest` (POST/PUT/PATCH and any request with a body) using Ed25519 signatures (`Signature-Input`, `Signature`).
|
|
13
13
|
- **RFC 9530 (Digest Fields)**: Computes standard `Content-Digest: sha-256=:<base64>:` headers for payload integrity.
|
|
14
14
|
- **Class-Based Client Architecture**: Instantiable `Daywalker` client supporting dependency injection, with a lazy default singleton for plug-and-play simplicity.
|
|
15
|
-
- **Resilience & Rate Limiting**:
|
|
15
|
+
- **Resilience & Rate Limiting**: 5s per-attempt timeout, exponential backoff with jitter, `Retry-After` honored up to 60s. Network errors are retried only for idempotent requests (or with an `idempotencyKey`), and redirects are never followed, so the API key can't leak to another host.
|
|
16
16
|
- **Deterministic Type-Safe Results**: Discriminated union returns (`Result<T, E>`) for clean handling without try/catch boilerplate.
|
|
17
|
-
- **Security-Hardened Next.js Middleware**: Built-in Server Action probe mitigation (CVE-2025-55182 defense),
|
|
17
|
+
- **Security-Hardened Next.js Middleware**: Built-in Server Action probe mitigation (CVE-2025-55182 defense), normalized paths (no `%2e%2e` / `//` bypasses), open-redirect-safe error routing, and fail-closed validation (503 when Daywalker is unreachable).
|
|
18
18
|
- **Zero-Egress Inbound Verification**: In-memory caching (60–300s TTL) and offline JWT token lease validation using Daywalker's asymmetric public key.
|
|
19
19
|
- **Decoupled Push Architecture CLI**: `npx daywalker init --json` registers keys directly via `POST /api/v1/keys/register` with zero runtime reverse-HTTP calls.
|
|
20
20
|
|
|
@@ -34,17 +34,19 @@ npm install @shelbyb/daywalker-sdk
|
|
|
34
34
|
# Generate Ed25519 keypair and write .env.local
|
|
35
35
|
npx daywalker init --service my-service-slug
|
|
36
36
|
|
|
37
|
-
#
|
|
38
|
-
|
|
37
|
+
# Register the public key with Daywalker Auth (domain is required by the API).
|
|
38
|
+
# The API key comes from DAYWALKER_API_KEY, or from stdin so it stays out of shell history:
|
|
39
|
+
op read op://vault/daywalker/api-key | npx daywalker init --service my-service-slug --domain app.example.com --api-key-stdin
|
|
39
40
|
|
|
40
|
-
#
|
|
41
|
-
npx daywalker init --service my-service-slug --
|
|
41
|
+
# Print JSON for Vercel, Fly.io, DigitalOcean, or AWS SSM (nothing written to disk):
|
|
42
|
+
DAYWALKER_API_KEY=srv_live_... npx daywalker init --service my-service-slug --domain app.example.com --json
|
|
42
43
|
```
|
|
43
44
|
|
|
44
45
|
`daywalker init`:
|
|
45
46
|
- Generates an Ed25519 keypair locally using WebCrypto (`crypto.subtle`)
|
|
46
47
|
- Registers the public JWK directly with `POST https://auth.daywalker.dev/api/v1/keys/register` (`Authorization: Bearer <API key>`, body `{ keyId, domain, publicKey }`; responds `201`)
|
|
47
|
-
- Writes `DAYWALKER_SERVICE_SLUG`, `DAYWALKER_KEY_ID`, `DAYWALKER_SIGNING_KEY`, and `DAYWALKER_AUTH_URL` to `.env.local`
|
|
48
|
+
- Writes `DAYWALKER_SERVICE_SLUG`, `DAYWALKER_KEY_ID`, `DAYWALKER_SIGNING_KEY`, and `DAYWALKER_AUTH_URL` to `.env.local` (atomically, mode `0600`), and only after registration succeeds. `--skip-register` writes without registering.
|
|
49
|
+
- `--force` replaces an existing key and keeps the old one as `DAYWALKER_SIGNING_KEY_PREVIOUS`
|
|
48
50
|
- No route injectors or public `/.well-known/` endpoints needed
|
|
49
51
|
|
|
50
52
|
The private key is never printed unless you pass `--print-key`.
|
|
@@ -62,7 +64,8 @@ The private key is never printed unless you pass `--print-key`.
|
|
|
62
64
|
| `DAYWALKER_SIGNING_KEY_PREVIOUS` | during rotation | Previous private key, maintained during deployment transitions |
|
|
63
65
|
| `DAYWALKER_DOMAIN` | optional | Optional caller identifier or host domain name |
|
|
64
66
|
| `DAYWALKER_PUBLIC_KEY` | optional | Daywalker Auth public key for zero-egress offline JWT lease verification |
|
|
65
|
-
| `DAYWALKER_AUTH_URL` | optional | Defaults to `https://auth.daywalker.dev` |
|
|
67
|
+
| `DAYWALKER_AUTH_URL` | optional | Defaults to `https://auth.daywalker.dev`; must be `https://` (except localhost) |
|
|
68
|
+
| `DAYWALKER_LEASE_ISSUER` | optional | Expected `iss` of offline-verified JWT leases |
|
|
66
69
|
|
|
67
70
|
---
|
|
68
71
|
|
|
@@ -78,15 +81,18 @@ const client = new Daywalker({
|
|
|
78
81
|
apiKey: process.env.DAYWALKER_API_KEY,
|
|
79
82
|
serviceSlug: "sonalyze",
|
|
80
83
|
maxRetries: 3,
|
|
84
|
+
timeoutMs: 5000, // per attempt (default)
|
|
81
85
|
});
|
|
82
86
|
|
|
83
87
|
// Option 2: Default lazy singleton (reads process.env automatically)
|
|
84
|
-
const result = await daywalker.validateToken();
|
|
88
|
+
const result = await daywalker.validateToken(incomingToken);
|
|
89
|
+
// Service health check of this service's own DAYWALKER_API_KEY: daywalker.validateServiceKey()
|
|
85
90
|
|
|
86
91
|
if (result.ok) {
|
|
87
92
|
console.log("Validated tenant:", result.data.tenantId);
|
|
88
93
|
console.log("Rate limit remaining:", result.data.rateLimit.remaining);
|
|
89
94
|
} else {
|
|
95
|
+
// SERVICE_UNAVAILABLE (503) when Daywalker can't be reached: the token is NOT treated as valid.
|
|
90
96
|
console.error(`Validation failed (${result.status} ${result.error}): ${result.message}`);
|
|
91
97
|
}
|
|
92
98
|
```
|
|
@@ -94,6 +100,8 @@ if (result.ok) {
|
|
|
94
100
|
### Type-Safe Verification & Error Handling
|
|
95
101
|
|
|
96
102
|
```ts
|
|
103
|
+
import { DaywalkerRateLimitError } from "@shelbyb/daywalker-sdk";
|
|
104
|
+
|
|
97
105
|
// requireValidToken returns data or throws DaywalkerAuthError / DaywalkerRateLimitError
|
|
98
106
|
try {
|
|
99
107
|
const auth = await client.requireValidToken(req.headers.get("x-api-token"));
|
|
@@ -113,6 +121,7 @@ try {
|
|
|
113
121
|
const res = await client.fetch("/api/v1/tokens/validate", {
|
|
114
122
|
method: "POST",
|
|
115
123
|
body: { token: "user_token", serviceSlug: "other-service" },
|
|
124
|
+
idempotencyKey: crypto.randomUUID(), // optional: lets a POST be retried after a network error
|
|
116
125
|
});
|
|
117
126
|
```
|
|
118
127
|
|
|
@@ -121,20 +130,23 @@ const res = await client.fetch("/api/v1/tokens/validate", {
|
|
|
121
130
|
## Hardened Next.js Middleware (`@shelbyb/daywalker-sdk/next`)
|
|
122
131
|
|
|
123
132
|
```ts
|
|
124
|
-
// middleware.ts
|
|
125
|
-
import {
|
|
133
|
+
// proxy.ts (Next.js 16; middleware.ts with `export const middleware = createDaywalkerMiddleware(...)` on 15)
|
|
134
|
+
import { createDaywalkerProxy } from "@shelbyb/daywalker-sdk/next";
|
|
126
135
|
|
|
127
|
-
export const
|
|
136
|
+
export const proxy = createDaywalkerProxy({
|
|
128
137
|
serviceSlug: "sonalyze",
|
|
129
138
|
errorPath: "/service-error", // redirect unauthorized web visitors
|
|
130
139
|
validateServerActions: true, // drops malformed CVE-2025-55182 scanner probes
|
|
131
140
|
});
|
|
132
141
|
|
|
133
142
|
export const config = {
|
|
134
|
-
matcher:
|
|
143
|
+
// Keep public files out with the matcher: list what is public, not what isn't.
|
|
144
|
+
matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|images/).*)"],
|
|
135
145
|
};
|
|
136
146
|
```
|
|
137
147
|
|
|
148
|
+
Each request must carry `Authorization: Bearer <token>` or `x-api-token`; without one, API routes get `401` and pages redirect to `errorPath` (without an error message in the URL). Paths are decoded and normalized before any check, so `/_next/static/../admin` is authenticated. Only `/_next/static/`, `/_next/image`, `/favicon.ico`, `/robots.txt`, `/sitemap.xml` and the health paths skip auth by default. File extensions say nothing about whether a route is public, so they no longer skip auth; opt in with `staticFileExtensions: STATIC_FILE_EXTENSIONS` if you rely on that. When Daywalker is unreachable the middleware answers `503`, and `500` for a configuration error. To gate the app on this service's own API key instead (the self-healing error page), use `createDaywalkerServiceHealthMiddleware` with the same options.
|
|
149
|
+
|
|
138
150
|
### Route Handler Wrapper
|
|
139
151
|
|
|
140
152
|
```ts
|
|
@@ -181,15 +193,17 @@ const client = new Daywalker({
|
|
|
181
193
|
const result = await client.validateToken(incomingJwt);
|
|
182
194
|
```
|
|
183
195
|
|
|
196
|
+
Leases must have a numeric `exp` and an `aud` (or `serviceSlug`) equal to your service slug, so `serviceSlug` must be configured. Set `leaseIssuer` (or `DAYWALKER_LEASE_ISSUER`) to also check `iss`.
|
|
197
|
+
|
|
184
198
|
---
|
|
185
199
|
|
|
186
200
|
## CLI Reference
|
|
187
201
|
|
|
188
202
|
| Command | Description |
|
|
189
203
|
|---|---|
|
|
190
|
-
| `npx daywalker init --service <slug> [--api-key
|
|
204
|
+
| `npx daywalker init --service <slug> [--domain <host>] [--api-key-stdin] [--skip-register] [--force] [--json]` | Generate Ed25519 key, register with Daywalker Auth, and write `.env.local` or output JSON |
|
|
191
205
|
| `npx daywalker check` | Validate configuration, signature readiness, and connection |
|
|
192
|
-
| `npx daywalker rotate [--api-key
|
|
206
|
+
| `npx daywalker rotate [--api-key-stdin] [--skip-register] [--json]` | Generate and register a new key while preserving the previous key in `.env.local` |
|
|
193
207
|
|
|
194
208
|
---
|
|
195
209
|
|
|
@@ -209,20 +223,20 @@ Inbound tokens may be supplied as `x-api-token: srv_live_...` or `Authorization:
|
|
|
209
223
|
## Request Signing Specification (RFC 9421 & RFC 9530)
|
|
210
224
|
|
|
211
225
|
Each outbound request signed by `@shelbyb/daywalker-sdk` includes:
|
|
212
|
-
- `Content-Digest: sha-256=:<base64-encoded-hash>:`
|
|
213
|
-
- `Signature-Input: sig1=("@method" "@path" "@authority" "content-digest");created=<unix_seconds>;keyid="<kid>";alg="ed25519";nonce="<random>"`
|
|
226
|
+
- `Content-Digest: sha-256=:<base64-encoded-hash>:` (POST/PUT/PATCH, and any request with a body)
|
|
227
|
+
- `Signature-Input: sig1=("@method" "@path" "@authority" "content-digest");created=<unix_seconds>;keyid="<kid>";alg="ed25519";nonce="<random>"`. `"@query"` is added after `"@path"` when the URL has a query string; `"content-digest"` is left out only for bodyless GET/HEAD/DELETE/OPTIONS requests.
|
|
214
228
|
- `Signature: sig1=:<base64-encoded-signature>:`
|
|
215
229
|
- `Authorization: Bearer <DAYWALKER_API_KEY>`
|
|
216
230
|
|
|
217
231
|
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
232
|
|
|
219
|
-
The exact signature base and test cases are verified against [`test-vectors/signing-rfc9421.json`](./test-vectors/signing-rfc9421.json).
|
|
233
|
+
Verification reads the covered components from `Signature-Input`, requires `content-digest` to be covered whenever there is a body, and rejects replays. The default replay cache is per process: with several instances, pass a shared `replayStore` (e.g. Redis `SET NX PX`). The exact signature base and test cases are verified against [`test-vectors/signing-rfc9421.json`](./test-vectors/signing-rfc9421.json).
|
|
220
234
|
|
|
221
235
|
---
|
|
222
236
|
|
|
223
237
|
## DevPulse SDKs (one package)
|
|
224
238
|
|
|
225
|
-
The same package ships the [DevPulse](https://github.com/shelbyb/devpulse) client SDKs as subpath exports. Each subpath is tree-shakeable
|
|
239
|
+
The same package ships the [DevPulse](https://github.com/shelbyb/devpulse) client SDKs as subpath exports. Each subpath is tree-shakeable; `react` and the OpenFeature SDKs are optional peer dependencies used only by their subpaths.
|
|
226
240
|
|
|
227
241
|
| Import | What it is | Docs |
|
|
228
242
|
|---|---|---|
|
|
@@ -11,7 +11,7 @@ export type FlagSource = {
|
|
|
11
11
|
export type AnalyticsOptions = {
|
|
12
12
|
/** The project's public write key (dpa_…). */
|
|
13
13
|
writeKey: string;
|
|
14
|
-
/** Your DevPulse URL, e.g. https://devpulse.example.com */
|
|
14
|
+
/** Your DevPulse URL, e.g. https://devpulse.example.com (https required except on localhost). */
|
|
15
15
|
host: string;
|
|
16
16
|
/** Capture $pageview on load and on client-side navigation (browsers). Default true. */
|
|
17
17
|
autocapturePageviews?: boolean;
|
|
@@ -31,6 +31,20 @@ export type AnalyticsOptions = {
|
|
|
31
31
|
respectDoNotTrack?: boolean;
|
|
32
32
|
/** Start opted out (e.g. until the user accepts a consent banner). */
|
|
33
33
|
optedOut?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Send $current_url / $referrer with their full query string and fragment. Default false:
|
|
36
|
+
* the fragment is dropped and only campaign parameters (utm_*, gclid, fbclid, ref) are kept,
|
|
37
|
+
* so OAuth codes, reset tokens and the like never leave the page.
|
|
38
|
+
*/
|
|
39
|
+
captureFullUrls?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Rewrite URL paths before they leave the page: applied to $pathname, $initial_pathname and the
|
|
42
|
+
* path of every URL sent ($current_url, $referrer, $initial_current_url, $initial_referrer).
|
|
43
|
+
* Use it for secrets embedded in paths, e.g.
|
|
44
|
+
* `sanitizePath: (p) => p.replace(/^\/reset\/[^/]+/, "/reset/:token")`. Default: unchanged.
|
|
45
|
+
* If it throws, the path is sent as "/".
|
|
46
|
+
*/
|
|
47
|
+
sanitizePath?: (path: string) => string;
|
|
34
48
|
fetch?: typeof fetch;
|
|
35
49
|
onError?: (err: Error) => void;
|
|
36
50
|
};
|
|
@@ -41,7 +55,9 @@ export type CaptureOptions = {
|
|
|
41
55
|
$set?: Properties;
|
|
42
56
|
$set_once?: Properties;
|
|
43
57
|
};
|
|
44
|
-
export declare const VERSION = "0.
|
|
58
|
+
export declare const VERSION = "0.4.0";
|
|
59
|
+
/** Drop the fragment and every query parameter outside the campaign allowlist. */
|
|
60
|
+
export declare function sanitizeUrl(raw: string): string;
|
|
45
61
|
/** Time-ordered UUID (v7). */
|
|
46
62
|
export declare function uuidv7(ms?: number): string;
|
|
47
63
|
export declare class Analytics {
|
|
@@ -85,14 +101,21 @@ export declare class Analytics {
|
|
|
85
101
|
shutdown(): Promise<void>;
|
|
86
102
|
private disabled;
|
|
87
103
|
private context;
|
|
104
|
+
private url;
|
|
105
|
+
private path;
|
|
88
106
|
private flagProps;
|
|
89
107
|
/** Rolling session: renewed after inactivity or at most every 24 hours. */
|
|
90
108
|
private session;
|
|
91
109
|
private enqueue;
|
|
92
110
|
private schedule;
|
|
111
|
+
/** Take up to MAX_BATCH_EVENTS queued events whose body stays under maxBytes (always at least one). */
|
|
112
|
+
private takeBatch;
|
|
93
113
|
/** Send one batch; on failure requeue it and back off. Resolves true when sent (or dropped as invalid). */
|
|
94
114
|
private send;
|
|
95
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* On page hide, hand the queue to sendBeacon so it survives navigation, in batches under the
|
|
117
|
+
* browser's 64 KiB beacon budget; whatever doesn't fit stays queued (and persists in memory).
|
|
118
|
+
*/
|
|
96
119
|
private beacon;
|
|
97
120
|
private attachBrowser;
|
|
98
121
|
}
|
package/dist/analytics/index.js
CHANGED
|
@@ -1,9 +1,32 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import { assertSecureUrl } from "../config.js";
|
|
2
|
+
import { SDK_VERSION } from "../flags/common/version.js";
|
|
3
|
+
export const VERSION = SDK_VERSION;
|
|
4
|
+
/** keepalive fetches and sendBeacon share a 64 KiB in-flight budget in browsers. */
|
|
5
|
+
const KEEPALIVE_MAX_BYTES = 60_000;
|
|
6
|
+
/** Stay well under DevPulse's 2 MB capture body limit. */
|
|
7
|
+
const MAX_BATCH_BYTES = 1_000_000;
|
|
8
|
+
const MAX_BATCH_EVENTS = 100;
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
const byteLength = (s) => encoder.encode(s).length;
|
|
11
|
+
const KEPT_QUERY_PARAMS = new Set(["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "gclid", "fbclid", "ref"]);
|
|
12
|
+
/** Drop the fragment and every query parameter outside the campaign allowlist. */
|
|
13
|
+
export function sanitizeUrl(raw) {
|
|
14
|
+
if (!raw)
|
|
15
|
+
return raw;
|
|
16
|
+
let url;
|
|
17
|
+
try {
|
|
18
|
+
url = new URL(raw);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return raw.split(/[?#]/)[0];
|
|
22
|
+
}
|
|
23
|
+
url.hash = "";
|
|
24
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
25
|
+
if (!KEPT_QUERY_PARAMS.has(key))
|
|
26
|
+
url.searchParams.delete(key);
|
|
27
|
+
}
|
|
28
|
+
return url.toString();
|
|
29
|
+
}
|
|
7
30
|
const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
|
|
8
31
|
const MAX_SESSION_MS = 24 * 3_600_000;
|
|
9
32
|
/** Time-ordered UUID (v7). */
|
|
@@ -73,6 +96,7 @@ export class Analytics {
|
|
|
73
96
|
throw new Error("writeKey is required");
|
|
74
97
|
if (!options.host)
|
|
75
98
|
throw new Error("host is required");
|
|
99
|
+
assertSecureUrl("host", options.host);
|
|
76
100
|
this.opts = { flushAt: 20, flushInterval: isBrowser ? 2000 : 10000, maxQueueSize: 1000, sessionTimeoutMinutes: 30, ...options };
|
|
77
101
|
this.endpoint = `${options.host.replace(/\/+$/, "")}/a/batch`;
|
|
78
102
|
this.store = new Store(`dp_analytics_${options.writeKey}`, (options.persistence ?? (isBrowser ? "localStorage" : "memory")) === "localStorage" && isBrowser);
|
|
@@ -103,16 +127,16 @@ export class Analytics {
|
|
|
103
127
|
pageview(properties = {}) {
|
|
104
128
|
if (!isBrowser)
|
|
105
129
|
return this.capture("$pageview", properties);
|
|
106
|
-
|
|
107
|
-
|
|
130
|
+
this.lastUrl = location.href;
|
|
131
|
+
const url = this.url(location.href);
|
|
108
132
|
const utm = Object.fromEntries([...new URLSearchParams(location.search)].filter(([k]) => /^utm_(source|medium|campaign|term|content)$/.test(k)));
|
|
109
133
|
let setOnce;
|
|
110
134
|
if (!this.state.initialSent) {
|
|
111
135
|
setOnce = {
|
|
112
|
-
$initial_referrer: document.referrer
|
|
136
|
+
$initial_referrer: document.referrer ? this.url(document.referrer) : "$direct",
|
|
113
137
|
$initial_referring_domain: referringDomain(document.referrer) ?? "$direct",
|
|
114
138
|
$initial_current_url: url,
|
|
115
|
-
$initial_pathname: location.pathname,
|
|
139
|
+
$initial_pathname: this.path(location.pathname),
|
|
116
140
|
...Object.fromEntries(Object.entries(utm).map(([k, v]) => [`$initial_${k}`, v])),
|
|
117
141
|
};
|
|
118
142
|
this.state.initialSent = true;
|
|
@@ -180,6 +204,10 @@ export class Analytics {
|
|
|
180
204
|
clearTimeout(this.timer);
|
|
181
205
|
this.timer = undefined;
|
|
182
206
|
while (this.queue.length) {
|
|
207
|
+
if (this.state.optedOut) {
|
|
208
|
+
this.queue = [];
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
183
211
|
if (this.sending)
|
|
184
212
|
await this.sending;
|
|
185
213
|
else {
|
|
@@ -212,10 +240,10 @@ export class Analytics {
|
|
|
212
240
|
return base;
|
|
213
241
|
return {
|
|
214
242
|
...base,
|
|
215
|
-
$current_url: location.href,
|
|
243
|
+
$current_url: this.url(location.href),
|
|
216
244
|
$host: location.host,
|
|
217
|
-
$pathname: location.pathname,
|
|
218
|
-
$referrer: document.referrer
|
|
245
|
+
$pathname: this.path(location.pathname),
|
|
246
|
+
$referrer: document.referrer ? this.url(document.referrer) : "$direct",
|
|
219
247
|
$referring_domain: referringDomain(document.referrer) ?? "$direct",
|
|
220
248
|
$screen_width: screen.width,
|
|
221
249
|
$screen_height: screen.height,
|
|
@@ -225,6 +253,30 @@ export class Analytics {
|
|
|
225
253
|
$device_id: this.state.identified ? undefined : this.state.distinctId,
|
|
226
254
|
};
|
|
227
255
|
}
|
|
256
|
+
url(raw) {
|
|
257
|
+
const out = this.opts.captureFullUrls ? raw : sanitizeUrl(raw);
|
|
258
|
+
if (!this.opts.sanitizePath || !out)
|
|
259
|
+
return out;
|
|
260
|
+
try {
|
|
261
|
+
const u = new URL(out);
|
|
262
|
+
u.pathname = this.path(u.pathname);
|
|
263
|
+
return u.toString();
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return this.path(out);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
path(p) {
|
|
270
|
+
if (!this.opts.sanitizePath)
|
|
271
|
+
return p;
|
|
272
|
+
try {
|
|
273
|
+
return this.opts.sanitizePath(p);
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
this.opts.onError?.(err);
|
|
277
|
+
return "/";
|
|
278
|
+
}
|
|
279
|
+
}
|
|
228
280
|
flagProps() {
|
|
229
281
|
if (!this.opts.flags)
|
|
230
282
|
return {};
|
|
@@ -265,9 +317,29 @@ export class Analytics {
|
|
|
265
317
|
}, this.backoff || this.opts.flushInterval);
|
|
266
318
|
this.timer.unref?.();
|
|
267
319
|
}
|
|
320
|
+
/** Take up to MAX_BATCH_EVENTS queued events whose body stays under maxBytes (always at least one). */
|
|
321
|
+
takeBatch(maxBytes) {
|
|
322
|
+
const sentAt = new Date().toISOString();
|
|
323
|
+
const encode = (batch) => JSON.stringify({ api_key: this.opts.writeKey, batch, sent_at: sentAt });
|
|
324
|
+
let size = byteLength(encode([]));
|
|
325
|
+
let n = 0;
|
|
326
|
+
while (n < Math.min(MAX_BATCH_EVENTS, this.queue.length)) {
|
|
327
|
+
const add = byteLength(JSON.stringify(this.queue[n])) + (n ? 1 : 0);
|
|
328
|
+
if (n && size + add > maxBytes)
|
|
329
|
+
break;
|
|
330
|
+
size += add;
|
|
331
|
+
n++;
|
|
332
|
+
}
|
|
333
|
+
const batch = this.queue.splice(0, n);
|
|
334
|
+
return { batch, body: encode(batch) };
|
|
335
|
+
}
|
|
268
336
|
/** Send one batch; on failure requeue it and back off. Resolves true when sent (or dropped as invalid). */
|
|
269
337
|
async send() {
|
|
270
|
-
|
|
338
|
+
if (this.state.optedOut) {
|
|
339
|
+
this.queue = [];
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
const { batch, body } = this.takeBatch(MAX_BATCH_BYTES);
|
|
271
343
|
if (!batch.length)
|
|
272
344
|
return true;
|
|
273
345
|
const f = this.opts.fetch ?? globalThis.fetch;
|
|
@@ -275,8 +347,9 @@ export class Analytics {
|
|
|
275
347
|
const res = await f(this.endpoint, {
|
|
276
348
|
method: "POST",
|
|
277
349
|
headers: { "content-type": "application/json" },
|
|
278
|
-
body
|
|
279
|
-
keepalive
|
|
350
|
+
body,
|
|
351
|
+
// Browsers reject keepalive bodies over 64 KiB outright, so only small ones get it.
|
|
352
|
+
keepalive: isBrowser && byteLength(body) < KEEPALIVE_MAX_BYTES,
|
|
280
353
|
});
|
|
281
354
|
if (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429)) {
|
|
282
355
|
if (!res.ok)
|
|
@@ -287,6 +360,11 @@ export class Analytics {
|
|
|
287
360
|
throw new Error(`HTTP ${res.status}`);
|
|
288
361
|
}
|
|
289
362
|
catch (err) {
|
|
363
|
+
// Opted out while this was in flight: the batch must not be retried.
|
|
364
|
+
if (this.state.optedOut) {
|
|
365
|
+
this.queue = [];
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
290
368
|
this.queue.unshift(...batch);
|
|
291
369
|
this.backoff = Math.min(60_000, (this.backoff || 1000) * 2);
|
|
292
370
|
this.opts.onError?.(err);
|
|
@@ -294,15 +372,21 @@ export class Analytics {
|
|
|
294
372
|
return false;
|
|
295
373
|
}
|
|
296
374
|
}
|
|
297
|
-
/**
|
|
375
|
+
/**
|
|
376
|
+
* On page hide, hand the queue to sendBeacon so it survives navigation, in batches under the
|
|
377
|
+
* browser's 64 KiB beacon budget; whatever doesn't fit stays queued (and persists in memory).
|
|
378
|
+
*/
|
|
298
379
|
beacon() {
|
|
299
|
-
if (
|
|
380
|
+
if (this.state.optedOut || typeof navigator.sendBeacon !== "function")
|
|
300
381
|
return;
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
382
|
+
while (this.queue.length) {
|
|
383
|
+
const { batch, body } = this.takeBatch(KEEPALIVE_MAX_BYTES);
|
|
384
|
+
// text/plain avoids a CORS preflight, which beacons can't do.
|
|
385
|
+
if (byteLength(body) >= KEEPALIVE_MAX_BYTES || !navigator.sendBeacon(this.endpoint, new Blob([body], { type: "text/plain" }))) {
|
|
386
|
+
this.queue.unshift(...batch);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
306
390
|
}
|
|
307
391
|
attachBrowser() {
|
|
308
392
|
const onHide = () => document.visibilityState === "hidden" && this.beacon();
|