@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
package/README.md CHANGED
@@ -1,20 +1,24 @@
1
1
  <p align="center">
2
- <img src="./assets/logo-512.png" alt="Daywalker" width="160" />
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
- Zero-dependency, 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**.
7
+ [![npm](https://img.shields.io/npm/v/@shelbyb/daywalker-sdk)](https://www.npmjs.com/package/@shelbyb/daywalker-sdk)
8
+ [![CI](https://github.com/Shelbyb/daywalker-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/Shelbyb/daywalker-sdk/actions/workflows/ci.yml)
9
+ [![license](https://img.shields.io/npm/l/@shelbyb/daywalker-sdk)](./LICENSE)
8
10
 
9
- Runs seamlessly across **Node.js 18+**, **Bun**, **Deno**, **Cloudflare Workers**, **Next.js Edge**, and standard browser runtimes.
11
+ 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**.
10
12
 
11
- - **Zero-Dependency Web Standards Core**: 100% standard `crypto.subtle`, `Uint8Array`, `fetch`, `Request`, and `Response`.
12
- - **RFC 9421 (HTTP Message Signatures)**: Cryptographically binds `@method`, `@path`, `@authority`, and `content-digest` using Ed25519 signatures (`Signature-Input`, `Signature`).
13
+ 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+.
14
+
15
+ - **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.
16
+ - **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
17
  - **RFC 9530 (Digest Fields)**: Computes standard `Content-Digest: sha-256=:<base64>:` headers for payload integrity.
14
18
  - **Class-Based Client Architecture**: Instantiable `Daywalker` client supporting dependency injection, with a lazy default singleton for plug-and-play simplicity.
15
- - **Resilience & Rate Limiting**: Automatic exponential backoff with jitter and `Retry-After` header parsing directly inside client fetch requests.
19
+ - **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
20
  - **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), health check bypass, and self-healing diagnostic error routing.
21
+ - **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
22
  - **Zero-Egress Inbound Verification**: In-memory caching (60–300s TTL) and offline JWT token lease validation using Daywalker's asymmetric public key.
19
23
  - **Decoupled Push Architecture CLI**: `npx daywalker init --json` registers keys directly via `POST /api/v1/keys/register` with zero runtime reverse-HTTP calls.
20
24
 
@@ -34,17 +38,19 @@ npm install @shelbyb/daywalker-sdk
34
38
  # Generate Ed25519 keypair and write .env.local
35
39
  npx daywalker init --service my-service-slug
36
40
 
37
- # Or register the public key immediately with Daywalker Auth (domain is required by the API):
38
- npx daywalker init --service my-service-slug --domain app.example.com --api-key srv_live_...
41
+ # Register the public key with Daywalker Auth (domain is required by the API).
42
+ # The API key comes from DAYWALKER_API_KEY, or from stdin so it stays out of shell history:
43
+ op read op://vault/daywalker/api-key | npx daywalker init --service my-service-slug --domain app.example.com --api-key-stdin
39
44
 
40
- # Pipe JSON secrets directly into Vercel, Fly.io, DigitalOcean, or AWS SSM:
41
- npx daywalker init --service my-service-slug --api-key srv_live_... --json
45
+ # Print JSON for Vercel, Fly.io, DigitalOcean, or AWS SSM (nothing written to disk):
46
+ DAYWALKER_API_KEY=srv_live_... npx daywalker init --service my-service-slug --domain app.example.com --json
42
47
  ```
43
48
 
44
49
  `daywalker init`:
45
50
  - Generates an Ed25519 keypair locally using WebCrypto (`crypto.subtle`)
46
51
  - 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`
52
+ - 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.
53
+ - `--force` replaces an existing key and keeps the old one as `DAYWALKER_SIGNING_KEY_PREVIOUS`
48
54
  - No route injectors or public `/.well-known/` endpoints needed
49
55
 
50
56
  The private key is never printed unless you pass `--print-key`.
@@ -79,6 +85,7 @@ const client = new Daywalker({
79
85
  apiKey: process.env.DAYWALKER_API_KEY,
80
86
  serviceSlug: "sonalyze",
81
87
  maxRetries: 3,
88
+ timeoutMs: 5000, // per attempt (default)
82
89
  });
83
90
 
84
91
  // Option 2: Default lazy singleton (reads process.env automatically)
@@ -89,6 +96,7 @@ if (result.ok) {
89
96
  console.log("Validated tenant:", result.data.tenantId);
90
97
  console.log("Rate limit remaining:", result.data.rateLimit.remaining);
91
98
  } else {
99
+ // SERVICE_UNAVAILABLE (503) when Daywalker can't be reached: the token is NOT treated as valid.
92
100
  console.error(`Validation failed (${result.status} ${result.error}): ${result.message}`);
93
101
  }
94
102
  ```
@@ -96,6 +104,8 @@ if (result.ok) {
96
104
  ### Type-Safe Verification & Error Handling
97
105
 
98
106
  ```ts
107
+ import { DaywalkerRateLimitError } from "@shelbyb/daywalker-sdk";
108
+
99
109
  // requireValidToken returns data or throws DaywalkerAuthError / DaywalkerRateLimitError
100
110
  try {
101
111
  const auth = await client.requireValidToken(req.headers.get("x-api-token"));
@@ -115,6 +125,7 @@ try {
115
125
  const res = await client.fetch("/api/v1/tokens/validate", {
116
126
  method: "POST",
117
127
  body: { token: "user_token", serviceSlug: "other-service" },
128
+ idempotencyKey: crypto.randomUUID(), // optional: lets a POST be retried after a network error
118
129
  });
119
130
  ```
120
131
 
@@ -123,21 +134,22 @@ const res = await client.fetch("/api/v1/tokens/validate", {
123
134
  ## Hardened Next.js Middleware (`@shelbyb/daywalker-sdk/next`)
124
135
 
125
136
  ```ts
126
- // middleware.ts
127
- import { createDaywalkerMiddleware } from "@shelbyb/daywalker-sdk/next";
137
+ // proxy.ts (Next.js 16; middleware.ts with `export const middleware = createDaywalkerMiddleware(...)` on 15)
138
+ import { createDaywalkerProxy } from "@shelbyb/daywalker-sdk/next";
128
139
 
129
- export const middleware = createDaywalkerMiddleware({
140
+ export const proxy = createDaywalkerProxy({
130
141
  serviceSlug: "sonalyze",
131
142
  errorPath: "/service-error", // redirect unauthorized web visitors
132
143
  validateServerActions: true, // drops malformed CVE-2025-55182 scanner probes
133
144
  });
134
145
 
135
146
  export const config = {
136
- matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
147
+ // Keep public files out with the matcher: list what is public, not what isn't.
148
+ matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|images/).*)"],
137
149
  };
138
150
  ```
139
151
 
140
- Each request must carry `Authorization: Bearer <token>` or `x-api-token`; without one, API routes get `401` and pages redirect to `errorPath`. Only `/_next/static/`, `/_next/image`, the favicon and static file extensions outside `/api/` skip auth. To gate the app on this service's own API key instead (the self-healing error page), use `createDaywalkerServiceHealthMiddleware` with the same options.
152
+ 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.
141
153
 
142
154
  ### Route Handler Wrapper
143
155
 
@@ -169,6 +181,23 @@ app.get("/api/me", (c) => {
169
181
 
170
182
  ---
171
183
 
184
+ ## Express / Connect / Node `http` (`@shelbyb/daywalker-sdk/express`)
185
+
186
+ ```ts
187
+ import express from "express";
188
+ import { daywalkerAuth, daywalkerKeys } from "@shelbyb/daywalker-sdk/express";
189
+
190
+ const app = express();
191
+ app.use(daywalkerKeys()); // serves GET /.well-known/daywalker-keys.json
192
+ app.use("/api", daywalkerAuth({ serviceSlug: "sonalyze" }));
193
+
194
+ app.get("/api/me", (req, res) => res.json({ auth: req.auth }));
195
+ ```
196
+
197
+ `daywalkerAuth` reads `Authorization: Bearer <token>` or `x-api-token`, answers `401` when neither is present, and otherwise returns the same status codes as the other middleware (`429` with `Retry-After`, `503` when Daywalker is unreachable). On success the validated payload is on `req.auth`. Both functions also work with plain `http.createServer` handlers.
198
+
199
+ ---
200
+
172
201
  ## Zero-Egress Offline Verification
173
202
 
174
203
  When high-throughput internal microservices need zero latency and zero external egress, Daywalker Auth can issue asymmetric JWT leases. Configure `DAYWALKER_PUBLIC_KEY` (PEM or standard JWK) and token validation runs entirely in memory without network calls:
@@ -193,9 +222,9 @@ Leases must have a numeric `exp` and an `aud` (or `serviceSlug`) equal to your s
193
222
 
194
223
  | Command | Description |
195
224
  |---|---|
196
- | `npx daywalker init --service <slug> [--api-key <key>] [--json]` | Generate Ed25519 key, register with Daywalker Auth, and write `.env.local` or output JSON |
225
+ | `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 |
197
226
  | `npx daywalker check` | Validate configuration, signature readiness, and connection |
198
- | `npx daywalker rotate [--api-key <key>] [--json]` | Generate and register a new key while preserving the previous key in `.env.local` |
227
+ | `npx daywalker rotate [--api-key-stdin] [--skip-register] [--json]` | Generate and register a new key while preserving the previous key in `.env.local` |
199
228
 
200
229
  ---
201
230
 
@@ -215,20 +244,20 @@ Inbound tokens may be supplied as `x-api-token: srv_live_...` or `Authorization:
215
244
  ## Request Signing Specification (RFC 9421 & RFC 9530)
216
245
 
217
246
  Each outbound request signed by `@shelbyb/daywalker-sdk` includes:
218
- - `Content-Digest: sha-256=:<base64-encoded-hash>:`
219
- - `Signature-Input: sig1=("@method" "@path" "@authority" "content-digest");created=<unix_seconds>;keyid="<kid>";alg="ed25519";nonce="<random>"`
247
+ - `Content-Digest: sha-256=:<base64-encoded-hash>:` (POST/PUT/PATCH, and any request with a body)
248
+ - `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.
220
249
  - `Signature: sig1=:<base64-encoded-signature>:`
221
250
  - `Authorization: Bearer <DAYWALKER_API_KEY>`
222
251
 
223
252
  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).
224
253
 
225
- The exact signature base and test cases are verified against [`test-vectors/signing-rfc9421.json`](./test-vectors/signing-rfc9421.json).
254
+ 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).
226
255
 
227
256
  ---
228
257
 
229
258
  ## DevPulse SDKs (one package)
230
259
 
231
- 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.
260
+ 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.
232
261
 
233
262
  | Import | What it is | Docs |
234
263
  |---|---|---|
@@ -249,6 +278,18 @@ const analytics = createAnalytics({ writeKey: "dpa_…", host: "https://devpulse
249
278
 
250
279
  ---
251
280
 
281
+ ## Provenance & Releases
282
+
283
+ Releases are published from GitHub Actions ([`publish.yml`](./.github/workflows/publish.yml)) with [npm trusted publishing](https://docs.npmjs.com/trusted-publishers): npm accepts a short-lived OIDC token from this repository's workflow, so there is no long-lived npm token anywhere. Each version carries a signed [provenance attestation](https://docs.npmjs.com/generating-provenance-statements) linking it to the commit and workflow run that built it. Check it on the npm page, or after installing:
284
+
285
+ ```bash
286
+ npm audit signatures
287
+ ```
288
+
289
+ To cut a release (maintainers): bump `version` in `package.json`, add a `CHANGELOG.md` entry, merge to `main`, then either push a matching tag (`git tag v0.5.0 && git push origin v0.5.0`) or run the **Publish** workflow from `main` with the version. The workflow refuses tags not on `main`, versions that don't match `package.json`, and versions already on npm. Tests and packing run in a job without the OIDC token; only the final job, which uploads the tested tarball, can publish.
290
+
291
+ ---
292
+
252
293
  ## License
253
294
 
254
295
  MIT
@@ -37,6 +37,14 @@ export type AnalyticsOptions = {
37
37
  * so OAuth codes, reset tokens and the like never leave the page.
38
38
  */
39
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;
40
48
  fetch?: typeof fetch;
41
49
  onError?: (err: Error) => void;
42
50
  };
@@ -47,7 +55,7 @@ export type CaptureOptions = {
47
55
  $set?: Properties;
48
56
  $set_once?: Properties;
49
57
  };
50
- export declare const VERSION = "0.1.0";
58
+ export declare const VERSION = "0.4.0";
51
59
  /** Drop the fragment and every query parameter outside the campaign allowlist. */
52
60
  export declare function sanitizeUrl(raw: string): string;
53
61
  /** Time-ordered UUID (v7). */
@@ -94,14 +102,20 @@ export declare class Analytics {
94
102
  private disabled;
95
103
  private context;
96
104
  private url;
105
+ private path;
97
106
  private flagProps;
98
107
  /** Rolling session: renewed after inactivity or at most every 24 hours. */
99
108
  private session;
100
109
  private enqueue;
101
110
  private schedule;
111
+ /** Take up to MAX_BATCH_EVENTS queued events whose body stays under maxBytes (always at least one). */
112
+ private takeBatch;
102
113
  /** Send one batch; on failure requeue it and back off. Resolves true when sent (or dropped as invalid). */
103
114
  private send;
104
- /** On page hide, hand the queue to sendBeacon so it survives navigation. */
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
+ */
105
119
  private beacon;
106
120
  private attachBrowser;
107
121
  }
@@ -1,5 +1,13 @@
1
1
  import { assertSecureUrl } from "../config.js";
2
- export const VERSION = "0.1.0";
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;
3
11
  const KEPT_QUERY_PARAMS = new Set(["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "gclid", "fbclid", "ref"]);
4
12
  /** Drop the fragment and every query parameter outside the campaign allowlist. */
5
13
  export function sanitizeUrl(raw) {
@@ -128,7 +136,7 @@ export class Analytics {
128
136
  $initial_referrer: document.referrer ? this.url(document.referrer) : "$direct",
129
137
  $initial_referring_domain: referringDomain(document.referrer) ?? "$direct",
130
138
  $initial_current_url: url,
131
- $initial_pathname: location.pathname,
139
+ $initial_pathname: this.path(location.pathname),
132
140
  ...Object.fromEntries(Object.entries(utm).map(([k, v]) => [`$initial_${k}`, v])),
133
141
  };
134
142
  this.state.initialSent = true;
@@ -196,6 +204,10 @@ export class Analytics {
196
204
  clearTimeout(this.timer);
197
205
  this.timer = undefined;
198
206
  while (this.queue.length) {
207
+ if (this.state.optedOut) {
208
+ this.queue = [];
209
+ return;
210
+ }
199
211
  if (this.sending)
200
212
  await this.sending;
201
213
  else {
@@ -230,7 +242,7 @@ export class Analytics {
230
242
  ...base,
231
243
  $current_url: this.url(location.href),
232
244
  $host: location.host,
233
- $pathname: location.pathname,
245
+ $pathname: this.path(location.pathname),
234
246
  $referrer: document.referrer ? this.url(document.referrer) : "$direct",
235
247
  $referring_domain: referringDomain(document.referrer) ?? "$direct",
236
248
  $screen_width: screen.width,
@@ -242,7 +254,28 @@ export class Analytics {
242
254
  };
243
255
  }
244
256
  url(raw) {
245
- return this.opts.captureFullUrls ? raw : sanitizeUrl(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
+ }
246
279
  }
247
280
  flagProps() {
248
281
  if (!this.opts.flags)
@@ -284,9 +317,29 @@ export class Analytics {
284
317
  }, this.backoff || this.opts.flushInterval);
285
318
  this.timer.unref?.();
286
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
+ }
287
336
  /** Send one batch; on failure requeue it and back off. Resolves true when sent (or dropped as invalid). */
288
337
  async send() {
289
- const batch = this.queue.splice(0, 100);
338
+ if (this.state.optedOut) {
339
+ this.queue = [];
340
+ return true;
341
+ }
342
+ const { batch, body } = this.takeBatch(MAX_BATCH_BYTES);
290
343
  if (!batch.length)
291
344
  return true;
292
345
  const f = this.opts.fetch ?? globalThis.fetch;
@@ -294,8 +347,9 @@ export class Analytics {
294
347
  const res = await f(this.endpoint, {
295
348
  method: "POST",
296
349
  headers: { "content-type": "application/json" },
297
- body: JSON.stringify({ api_key: this.opts.writeKey, batch, sent_at: new Date().toISOString() }),
298
- keepalive: isBrowser,
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,
299
353
  });
300
354
  if (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429)) {
301
355
  if (!res.ok)
@@ -306,6 +360,11 @@ export class Analytics {
306
360
  throw new Error(`HTTP ${res.status}`);
307
361
  }
308
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
+ }
309
368
  this.queue.unshift(...batch);
310
369
  this.backoff = Math.min(60_000, (this.backoff || 1000) * 2);
311
370
  this.opts.onError?.(err);
@@ -313,15 +372,21 @@ export class Analytics {
313
372
  return false;
314
373
  }
315
374
  }
316
- /** On page hide, hand the queue to sendBeacon so it survives navigation. */
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
+ */
317
379
  beacon() {
318
- if (!this.queue.length || typeof navigator.sendBeacon !== "function")
380
+ if (this.state.optedOut || typeof navigator.sendBeacon !== "function")
319
381
  return;
320
- const batch = this.queue.splice(0, 100);
321
- const body = JSON.stringify({ api_key: this.opts.writeKey, batch, sent_at: new Date().toISOString() });
322
- // text/plain avoids a CORS preflight, which beacons can't do.
323
- if (!navigator.sendBeacon(this.endpoint, new Blob([body], { type: "text/plain" })))
324
- this.queue.unshift(...batch);
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
+ }
325
390
  }
326
391
  attachBrowser() {
327
392
  const onHide = () => document.visibilityState === "hidden" && this.beacon();