@hitslop/slurp-sdk 0.1.0 → 0.1.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.
- package/README.md +23 -11
- package/dist/event.d.ts +8 -2
- package/dist/event.js +53 -3
- package/dist/feedback.d.ts +41 -0
- package/dist/feedback.js +170 -0
- package/dist/index.d.ts +4 -6
- package/dist/index.js +98 -39
- package/package.json +11 -6
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Browser and Node event capture for [Slurp](https://slurp.run), with no runtime d
|
|
|
6
6
|
npm install @hitslop/slurp-sdk
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
`slurp init` in your app prints your project's snippet with the real values filled in: your ingest URL (`https://slurp.<your-subdomain>.workers.dev/v1/events`)
|
|
9
|
+
`slurp init` in your app prints your project's snippet with the real values filled in: your ingest URL (`https://slurp.<your-subdomain>.workers.dev/v1/events`) and your publishable key (`slurp_pk_…`, ingest-only, safe in a web page).
|
|
10
10
|
|
|
11
11
|
Browser:
|
|
12
12
|
|
|
@@ -15,7 +15,6 @@ import { init, track } from '@hitslop/slurp-sdk';
|
|
|
15
15
|
|
|
16
16
|
init({
|
|
17
17
|
key: 'slurp_pk_…',
|
|
18
|
-
sourceId: 'src_…',
|
|
19
18
|
endpoint: 'https://slurp.YOUR-SUBDOMAIN.workers.dev/v1/events',
|
|
20
19
|
});
|
|
21
20
|
|
|
@@ -29,7 +28,6 @@ import { init, track, flush } from '@hitslop/slurp-sdk';
|
|
|
29
28
|
|
|
30
29
|
init({
|
|
31
30
|
key: 'slurp_pk_…',
|
|
32
|
-
sourceId: 'src_…',
|
|
33
31
|
endpoint: 'https://slurp.YOUR-SUBDOMAIN.workers.dev/v1/events',
|
|
34
32
|
});
|
|
35
33
|
|
|
@@ -44,22 +42,23 @@ await flush();
|
|
|
44
42
|
Each record is one JSON line. Identify records omit `event` and put trait updates in `data`:
|
|
45
43
|
|
|
46
44
|
```json
|
|
47
|
-
{"id":"019a3cb6-bc00-7000-8000-000000000001","type":"track","event":"signed_up","timestamp":"2025-11-01T00:00:00.000Z","user_id":"user-42","anonymous_id":"…","session_id":"…","data":{"plan":"pro"},"context":{"library":{"name":"@hitslop/slurp-sdk","version":"0.1.
|
|
45
|
+
{"id":"019a3cb6-bc00-7000-8000-000000000001","type":"track","event":"signed_up","timestamp":"2025-11-01T00:00:00.000Z","user_id":"user-42","anonymous_id":"…","session_id":"…","data":{"plan":"pro"},"context":{"library":{"name":"@hitslop/slurp-sdk","version":"0.1.1"}}}
|
|
48
46
|
```
|
|
49
47
|
|
|
50
|
-
`user_id` is omitted until identification. Browser context also contains `page: {url, path, referrer, title}`, `user_agent`, `locale`, and `timezone`; Node context adds `runtime: {name: 'node', version: process.version}`. Event names must be non-empty strings of at most 200 characters. Data must be a plain object containing only JSON values: no cycles, dates, bigint, functions, undefined, symbols or nonfinite numbers. Data and context are snapshotted when captured. `context` accepts a plain JSON object or a function called for each record; use it for app, device, OS and screen metadata. `context.geo` is reserved for backend enrichment. `track(event, data, { userId, anonymousId, sessionId, context })` overrides one record without changing the client identity, for server applications handling multiple users. Events larger than
|
|
48
|
+
`user_id` is omitted until identification. Browser context also contains `page: {url, path, referrer, title}`, `user_agent`, `locale`, and `timezone`; Node context adds `runtime: {name: 'node', version: process.version}`. Event names must be non-empty strings of at most 200 characters. Data must be a plain object containing only JSON values: no cycles, dates, bigint, functions, undefined, symbols or nonfinite numbers. Data and context are snapshotted when captured. `context` accepts a plain JSON object or a function called for each record; use it for app, device, OS and screen metadata. `context.geo` is reserved for backend enrichment. `track(event, data, { userId, anonymousId, sessionId, context })` overrides one record without changing the client identity, for server applications handling multiple users. Events larger than 1 MiB of serialized UTF-8 JSON are dropped and reported; nothing is ever truncated. No server stamp fields are emitted.
|
|
51
49
|
|
|
52
50
|
Options besides the required `key` and full `endpoint`:
|
|
53
51
|
|
|
54
52
|
| Option | Default | Meaning |
|
|
55
53
|
| --- | --- | --- |
|
|
56
|
-
| `sourceId` | None | Public source ID; include it so identity survives publishable-key rotation |
|
|
57
54
|
| `context` | Automatic environment | JSON context or a function producing capture-time context |
|
|
58
55
|
| `flushAt` | 100 | Queue size that triggers a send |
|
|
59
56
|
| `flushIntervalMs` | 5000 | Background flush interval |
|
|
60
57
|
| `maxBatchEvents` | 1000 | Maximum events per request |
|
|
61
58
|
| `maxBatchBytes` | 921600 | UTF-8 body target; an event larger than the target travels alone |
|
|
62
|
-
| `maxQueue` | 10000 | Unbatched queue cap; oldest queued events are dropped and reported
|
|
59
|
+
| `maxQueue` | 10000 | Unbatched queue cap by count; oldest queued events are dropped and reported |
|
|
60
|
+
| `maxQueueBytes` | 4194304 | Most bytes kept offline, counting unbatched events and batches not yet acknowledged; the oldest are dropped and reported (never the batch being sent). Records can be up to 1 MiB, so a count limit alone is not a size limit; 4 MiB also stays under browser localStorage |
|
|
61
|
+
| `requestTimeoutMs` | 30000 | A request with no answer after this long is aborted and retried with the same bytes |
|
|
63
62
|
| `sessionTimeoutMs` | 1800000 | Session inactivity timeout |
|
|
64
63
|
| `storage` | Browser localStorage; Node memory | Adapter described below |
|
|
65
64
|
| `fetch` | Global fetch | Fetch implementation |
|
|
@@ -68,7 +67,7 @@ Options besides the required `key` and full `endpoint`:
|
|
|
68
67
|
|
|
69
68
|
Limits and intervals must be positive safe integers. Browser page hide sends at most 60 KiB; larger bodies stay persisted. Timers are unref'd in Node. `flush()` resolves when queued work is acknowledged, a retry is scheduled, or the client stops. Its result is `{accepted, rejected, pending, retryAt, stopped}`: accepted/rejected are cumulative record counts for this client instance, pending is current retained work, retryAt is an epoch-millisecond deadline or null, and stopped includes closed clients. Acceptance means durable ingest acknowledgment, not publication or read availability. It respects a pending retry deadline, including across reloads; it does not wait for that deadline. `close()` immediately stops timers, listeners and requests and persists outstanding work; call `flush()` first for a normal shutdown. Subsequent `track` calls are dropped and reported.
|
|
70
69
|
|
|
71
|
-
Batches are raw NDJSON with a trailing newline, sent to `endpoint` with `Content-Type: application/x-ndjson` and `Authorization: Bearer <key>`. Before the first send, the SDK persists the exact completed body. Retries and restarts resend those bytes so the gateway can dedupe a committed request whose response was lost.
|
|
70
|
+
Batches are raw NDJSON with a trailing newline, sent to `endpoint` with `Content-Type: application/x-ndjson` and `Authorization: Bearer <key>`. Before the first send, the SDK persists the exact completed body. Retries and restarts resend those bytes so the gateway can dedupe a committed request whose response was lost. Any 2xx acknowledges a batch; 408, 429 and 503 and other 5xx responses, network errors and requests with no answer within `requestTimeoutMs` are retried with the same bytes (429 and 503 honor `Retry-After`, seconds or HTTP date; the rest back off with jitter from 250–500 ms to 30–60 s). A 413 splits existing lines without reserializing them; a rejected singleton is dropped. A 400 or other permanent rejection drops the batch. A 401/403 drops all pending work and permanently stops that client, including when its state is restored. Storage write failures retain work and prevent sending until a snapshot succeeds.
|
|
72
71
|
|
|
73
72
|
Delivery depends on retained storage, gateway dedupe retention, and a running client. Queue overflow, invalid/oversized events, permanent rejections, and failed browser persistence can lose events. Browser localStorage access is guarded; if unavailable it falls back to memory and reports the error. A process exiting before storage writes finish can lose newly queued events; awaiting `flush` or `close` finishes those writes. Neither call guarantees a successful network delivery when a retry is scheduled.
|
|
74
73
|
|
|
@@ -82,7 +81,7 @@ interface StorageAdapter {
|
|
|
82
81
|
}
|
|
83
82
|
```
|
|
84
83
|
|
|
85
|
-
|
|
84
|
+
Saved state is namespaced by endpoint and key, so two senders sharing a storage (two sites on one origin) never send each other's queued events. Replacing a key starts clean: the new key has its own queue and visitor identity (rotating a key gives each visitor a new anonymous id once, and the old key's unsent events stay behind). Replacing the default client with `init` hands its queue to the new client. Saved state is validated when it is read: lines that are not complete JSON records are dropped and reported through `onError` rather than sent (the gateway refuses a request that has one malformed line). If you replace a refused key, the client starts sending again instead of staying stopped. Use one active client per endpoint, key and adapter. **The default Node adapter is in memory, so a Node process that exits loses unsent events; the browser default is `localStorage`.** For restart recovery in Node, use the Node-only entry:
|
|
86
85
|
|
|
87
86
|
```js
|
|
88
87
|
import { createClient } from '@hitslop/slurp-sdk';
|
|
@@ -90,7 +89,6 @@ import { fileStorage } from '@hitslop/slurp-sdk/node';
|
|
|
90
89
|
|
|
91
90
|
const client = createClient({
|
|
92
91
|
key: 'slurp_pk_…',
|
|
93
|
-
sourceId: 'src_…',
|
|
94
92
|
endpoint: 'https://slurp.YOUR-SUBDOMAIN.workers.dev/v1/events',
|
|
95
93
|
storage: fileStorage('/tmp/slurp-sdk.json'),
|
|
96
94
|
});
|
|
@@ -101,13 +99,27 @@ await client.close();
|
|
|
101
99
|
|
|
102
100
|
The parent directory must exist. `fileStorage` replaces the JSON file atomically using a sibling temporary file and rename (mode 0600). Reuse a single adapter for a file within one process; concurrent processes must use separate files. Atomic replacement protects process restart recovery, without a power-loss/fsync guarantee. The main entry never imports Node modules.
|
|
103
101
|
|
|
102
|
+
## Feedback widget
|
|
103
|
+
|
|
104
|
+
A drop-in "Feedback" button for web pages. It opens a small form (message, an optional 1 to 5 rating, an optional email) and sends one `feedback` event, so what people tell you lands next to what they did:
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
import { init } from '@hitslop/slurp-sdk';
|
|
108
|
+
import { mountFeedback } from '@hitslop/slurp-sdk/feedback';
|
|
109
|
+
|
|
110
|
+
init({ key: 'slurp_pk_…', endpoint: 'https://slurp.YOUR-SUBDOMAIN.workers.dev/v1/events' });
|
|
111
|
+
mountFeedback({ askEmail: true, data: () => ({ plan: currentPlan() }) });
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The event is `{ event: 'feedback', data: { message, rating?, email?, ...data() } }` with the usual page context, so in SQL: `SELECT timestamp, data->>'message', data->>'rating' FROM events WHERE event = 'feedback'`. Options: `client` (default: the client from `init`), `label`, `title`, `placeholder`, `ratings` (default true), `askEmail` (default false), `position` (`bottom-right` or `bottom-left`), `accent`, `theme` (`auto` follows the visitor's dark mode; `light` or `dark` match your page), `event`, `data`, `button: false` (open it from your own button with the returned `open()`), `container`. It returns `{ open, close, destroy }`. Text a person types is only ever set as text, never parsed as HTML, and the styles live in a shadow root so they neither affect nor inherit from your page. Browser only.
|
|
115
|
+
|
|
104
116
|
## Contributing
|
|
105
117
|
|
|
106
118
|
From the [repository](https://github.com/hitSlop/slurp) root:
|
|
107
119
|
|
|
108
120
|
```sh
|
|
109
121
|
pnpm --filter @hitslop/slurp-sdk typecheck
|
|
110
|
-
(cd
|
|
122
|
+
(cd sdks/js && node --test test/*.test.ts)
|
|
111
123
|
pnpm --filter @hitslop/slurp-sdk build
|
|
112
124
|
```
|
|
113
125
|
|
package/dist/event.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
export declare const version = "0.1.
|
|
2
|
-
export declare const encoder:
|
|
1
|
+
export declare const version = "0.1.1";
|
|
2
|
+
export declare const encoder: {
|
|
3
|
+
encode(text: string): Uint8Array;
|
|
4
|
+
};
|
|
5
|
+
/** The page address when there is one. React Native defines `window` but not `window.location`. */
|
|
6
|
+
export declare function pageHref(): string | undefined;
|
|
7
|
+
/** An endpoint resolved against the page when it can be, otherwise as written (URL support is partial in some runtimes). */
|
|
8
|
+
export declare function resolveEndpoint(endpoint: string): string;
|
|
3
9
|
export declare function uuidv7(now?: number): string;
|
|
4
10
|
export declare function plainObject(value: unknown): value is Record<string, unknown>;
|
|
5
11
|
export declare function jsonData(data: unknown): Record<string, unknown>;
|
package/dist/event.js
CHANGED
|
@@ -1,7 +1,57 @@
|
|
|
1
|
-
export const version = '0.1.
|
|
2
|
-
|
|
1
|
+
export const version = '0.1.1';
|
|
2
|
+
/** UTF-8 for runtimes without TextEncoder (React Native before Hermes shipped it). */
|
|
3
|
+
function utf8Encode(text) {
|
|
4
|
+
const out = [];
|
|
5
|
+
for (let i = 0; i < text.length; i++) {
|
|
6
|
+
let c = text.charCodeAt(i);
|
|
7
|
+
if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) {
|
|
8
|
+
const next = text.charCodeAt(i + 1);
|
|
9
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
10
|
+
c = 0x10000 + ((c - 0xd800) << 10) + (next - 0xdc00);
|
|
11
|
+
i++;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
if (c >= 0xd800 && c <= 0xdfff)
|
|
15
|
+
c = 0xfffd; // a lone surrogate, as TextEncoder does
|
|
16
|
+
if (c < 0x80)
|
|
17
|
+
out.push(c);
|
|
18
|
+
else if (c < 0x800)
|
|
19
|
+
out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
|
|
20
|
+
else if (c < 0x10000)
|
|
21
|
+
out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
|
|
22
|
+
else
|
|
23
|
+
out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
|
|
24
|
+
}
|
|
25
|
+
return Uint8Array.from(out);
|
|
26
|
+
}
|
|
27
|
+
export const encoder = typeof TextEncoder === 'function' ? new TextEncoder() : { encode: utf8Encode };
|
|
28
|
+
/** Cryptographic randomness where the runtime has it; ids only need to be unique, so Math.random is the fallback. */
|
|
29
|
+
function randomBytes(length) {
|
|
30
|
+
const crypto = globalThis.crypto;
|
|
31
|
+
if (typeof crypto?.getRandomValues === 'function')
|
|
32
|
+
return crypto.getRandomValues(new Uint8Array(length));
|
|
33
|
+
return Uint8Array.from({ length }, () => Math.floor(Math.random() * 256));
|
|
34
|
+
}
|
|
35
|
+
/** The page address when there is one. React Native defines `window` but not `window.location`. */
|
|
36
|
+
export function pageHref() {
|
|
37
|
+
try {
|
|
38
|
+
return typeof window !== 'undefined' ? window.location?.href : undefined;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** An endpoint resolved against the page when it can be, otherwise as written (URL support is partial in some runtimes). */
|
|
45
|
+
export function resolveEndpoint(endpoint) {
|
|
46
|
+
try {
|
|
47
|
+
return new URL(endpoint, pageHref()).toString();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return endpoint;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
3
53
|
export function uuidv7(now = Date.now()) {
|
|
4
|
-
const bytes =
|
|
54
|
+
const bytes = randomBytes(16);
|
|
5
55
|
for (let i = 5; i >= 0; i--) {
|
|
6
56
|
bytes[i] = now % 256;
|
|
7
57
|
now = Math.floor(now / 256);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** What the widget needs from a client: just `track`. Pass the result of `createClient`, or omit it to use the client from `init`. */
|
|
2
|
+
export interface FeedbackClient {
|
|
3
|
+
track(event: string, data?: Record<string, unknown>): string;
|
|
4
|
+
}
|
|
5
|
+
export interface FeedbackOptions {
|
|
6
|
+
/** Where the feedback event goes. Defaults to the client created by `init`. */
|
|
7
|
+
client?: FeedbackClient;
|
|
8
|
+
/** Text of the floating button. */
|
|
9
|
+
label?: string;
|
|
10
|
+
title?: string;
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
/** Show a 1 to 5 rating. Default true. */
|
|
13
|
+
ratings?: boolean;
|
|
14
|
+
/** Ask for an email address (optional for the person). Default false. */
|
|
15
|
+
askEmail?: boolean;
|
|
16
|
+
position?: 'bottom-right' | 'bottom-left';
|
|
17
|
+
/** Accent color for the button and the submit button. */
|
|
18
|
+
accent?: string;
|
|
19
|
+
/** `auto` follows the visitor's system dark mode; `light` or `dark` match your own page. Default `auto`. */
|
|
20
|
+
theme?: 'auto' | 'light' | 'dark';
|
|
21
|
+
/** Event name. Default `feedback`. */
|
|
22
|
+
event?: string;
|
|
23
|
+
/** Extra properties added to every feedback event, evaluated when it is sent (the current screen, plan, release...). */
|
|
24
|
+
data?: () => Record<string, unknown>;
|
|
25
|
+
/** Show the floating button. Default true; set false and call `open()` from your own button. */
|
|
26
|
+
button?: boolean;
|
|
27
|
+
/** Where to attach the widget. Default `document.body`. */
|
|
28
|
+
container?: HTMLElement;
|
|
29
|
+
}
|
|
30
|
+
export interface FeedbackHandle {
|
|
31
|
+
open(): void;
|
|
32
|
+
close(): void;
|
|
33
|
+
/** Remove the widget from the page. */
|
|
34
|
+
destroy(): void;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A floating "Feedback" button that opens a small form and sends the answer as one event (default name `feedback`) with
|
|
38
|
+
* `message`, and `rating` and `email` when asked for. Everything is built with DOM methods and `textContent`, so nothing a person types
|
|
39
|
+
* is ever parsed as HTML, and the styles live in a shadow root so they neither affect nor inherit from your page. Browser only.
|
|
40
|
+
*/
|
|
41
|
+
export declare function mountFeedback(options?: FeedbackOptions): FeedbackHandle;
|
package/dist/feedback.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { track as defaultTrack } from "./index.js";
|
|
2
|
+
const MAX_MESSAGE = 5000;
|
|
3
|
+
const HOST_ATTRIBUTE = 'data-slurp-feedback';
|
|
4
|
+
const STYLE = `
|
|
5
|
+
:host { all: initial; }
|
|
6
|
+
* { box-sizing: border-box; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
7
|
+
.fab { position: fixed; bottom: 20px; z-index: 2147483000; border: 0; border-radius: 999px; padding: 11px 18px; background: var(--accent); color: #fff; font-size: 14px; font-weight: 600; cursor: pointer; box-shadow: 0 6px 20px rgba(0,0,0,.22); }
|
|
8
|
+
.fab:hover { filter: brightness(1.06); }
|
|
9
|
+
.right { right: 20px; } .left { left: 20px; }
|
|
10
|
+
.panel { position: fixed; bottom: 70px; z-index: 2147483001; width: min(340px, calc(100vw - 24px)); background: #fff; color: #17191c; border: 1px solid #dfe3e8; border-radius: 14px; box-shadow: 0 18px 50px rgba(0,0,0,.25); padding: 16px; }
|
|
11
|
+
.panel[hidden] { display: none; }
|
|
12
|
+
h2 { margin: 0 0 10px; font-size: 16px; }
|
|
13
|
+
textarea, input[type=email] { width: 100%; border: 1px solid #c9ced6; border-radius: 8px; padding: 9px 10px; font-size: 14px; color: inherit; background: #fff; }
|
|
14
|
+
textarea { min-height: 96px; resize: vertical; }
|
|
15
|
+
input[type=email] { margin-top: 8px; }
|
|
16
|
+
textarea:focus-visible, input:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
17
|
+
.rating { display: flex; gap: 6px; margin: 0 0 10px; padding: 0; border: 0; }
|
|
18
|
+
.rating legend { font-size: 12px; color: #5b616a; padding: 0; margin-bottom: 4px; }
|
|
19
|
+
.rating button { width: 34px; height: 34px; border-radius: 8px; border: 1px solid #c9ced6; background: #fff; color: #17191c; font-size: 14px; font-weight: 600; cursor: pointer; }
|
|
20
|
+
.rating button[aria-pressed=true] { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
21
|
+
.row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
|
|
22
|
+
.row button { border-radius: 8px; padding: 8px 14px; font-size: 14px; font-weight: 600; cursor: pointer; border: 1px solid #c9ced6; background: #fff; color: #17191c; }
|
|
23
|
+
.row .send { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
24
|
+
.error { margin: 8px 0 0; font-size: 13px; color: #b3001f; }
|
|
25
|
+
.thanks { margin: 6px 0 2px; font-size: 15px; }
|
|
26
|
+
@media (prefers-color-scheme: dark) {
|
|
27
|
+
:host([data-theme=auto]) .panel { background: #1f2327; color: #f2f4f6; border-color: #3a4048; }
|
|
28
|
+
:host([data-theme=auto]) textarea, :host([data-theme=auto]) input[type=email] { background: #15181b; border-color: #3a4048; }
|
|
29
|
+
:host([data-theme=auto]) .rating button, :host([data-theme=auto]) .row button { background: #15181b; border-color: #3a4048; color: #f2f4f6; }
|
|
30
|
+
:host([data-theme=auto]) .rating legend { color: #9aa1aa; }
|
|
31
|
+
}
|
|
32
|
+
:host([data-theme=dark]) .panel { background: #1f2327; color: #f2f4f6; border-color: #3a4048; }
|
|
33
|
+
:host([data-theme=dark]) textarea, :host([data-theme=dark]) input[type=email] { background: #15181b; border-color: #3a4048; }
|
|
34
|
+
:host([data-theme=dark]) .rating button, :host([data-theme=dark]) .row button { background: #15181b; border-color: #3a4048; color: #f2f4f6; }
|
|
35
|
+
:host([data-theme=dark]) .rating legend { color: #9aa1aa; }
|
|
36
|
+
`;
|
|
37
|
+
function element(tag, attributes = {}, text) {
|
|
38
|
+
const node = document.createElement(tag);
|
|
39
|
+
for (const [name, value] of Object.entries(attributes))
|
|
40
|
+
node.setAttribute(name, value);
|
|
41
|
+
if (text !== undefined)
|
|
42
|
+
node.textContent = text;
|
|
43
|
+
return node;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A floating "Feedback" button that opens a small form and sends the answer as one event (default name `feedback`) with
|
|
47
|
+
* `message`, and `rating` and `email` when asked for. Everything is built with DOM methods and `textContent`, so nothing a person types
|
|
48
|
+
* is ever parsed as HTML, and the styles live in a shadow root so they neither affect nor inherit from your page. Browser only.
|
|
49
|
+
*/
|
|
50
|
+
export function mountFeedback(options = {}) {
|
|
51
|
+
if (typeof document === 'undefined')
|
|
52
|
+
throw new Error('mountFeedback needs a browser document');
|
|
53
|
+
const { label = 'Feedback', title = 'Send feedback', placeholder = 'What is working, and what is not?', ratings = true, askEmail = false, position = 'bottom-right', accent = '#e8002a', event = 'feedback', button = true, theme = 'auto', } = options;
|
|
54
|
+
const container = options.container ?? document.body;
|
|
55
|
+
for (const old of container.querySelectorAll(`[${HOST_ATTRIBUTE}]`))
|
|
56
|
+
old.remove(); // mounting twice replaces, never stacks
|
|
57
|
+
const host = element('div', { [HOST_ATTRIBUTE]: '', 'data-theme': theme });
|
|
58
|
+
const root = host.attachShadow({ mode: 'open' });
|
|
59
|
+
root.appendChild(element('style', {}, STYLE));
|
|
60
|
+
host.style.setProperty('--accent', accent);
|
|
61
|
+
const fab = element('button', { type: 'button', class: `fab ${position === 'bottom-left' ? 'left' : 'right'}`, 'aria-haspopup': 'dialog', 'aria-expanded': 'false' }, label);
|
|
62
|
+
const panel = element('div', { class: `panel ${position === 'bottom-left' ? 'left' : 'right'}`, role: 'dialog', 'aria-modal': 'false', 'aria-labelledby': 'slurp-feedback-title', hidden: '' });
|
|
63
|
+
const heading = element('h2', { id: 'slurp-feedback-title' }, title);
|
|
64
|
+
const form = element('form', { novalidate: '' });
|
|
65
|
+
const message = element('textarea', { name: 'message', placeholder, 'aria-label': title, maxlength: String(MAX_MESSAGE), required: '' });
|
|
66
|
+
let rating;
|
|
67
|
+
const ratingButtons = [];
|
|
68
|
+
let ratingGroup;
|
|
69
|
+
if (ratings) {
|
|
70
|
+
ratingGroup = element('fieldset', { class: 'rating' });
|
|
71
|
+
ratingGroup.appendChild(element('legend', {}, 'How would you rate it?'));
|
|
72
|
+
for (let value = 1; value <= 5; value++) {
|
|
73
|
+
const choice = element('button', { type: 'button', 'aria-pressed': 'false', 'aria-label': `${value} out of 5` }, String(value));
|
|
74
|
+
choice.addEventListener('click', () => {
|
|
75
|
+
rating = rating === value ? undefined : value;
|
|
76
|
+
for (const [index, other] of ratingButtons.entries())
|
|
77
|
+
other.setAttribute('aria-pressed', String(rating === index + 1));
|
|
78
|
+
});
|
|
79
|
+
ratingButtons.push(choice);
|
|
80
|
+
ratingGroup.appendChild(choice);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const email = askEmail ? element('input', { type: 'email', name: 'email', placeholder: 'Email (optional, if you want a reply)', autocomplete: 'email', 'aria-label': 'Email (optional)' }) : undefined;
|
|
84
|
+
const error = element('p', { class: 'error', role: 'alert', hidden: '' });
|
|
85
|
+
const cancel = element('button', { type: 'button', class: 'cancel' }, 'Cancel');
|
|
86
|
+
const send = element('button', { type: 'submit', class: 'send' }, 'Send');
|
|
87
|
+
const row = element('div', { class: 'row' });
|
|
88
|
+
row.append(cancel, send);
|
|
89
|
+
form.append(...[ratingGroup, message, email, error, row].filter((node) => node !== undefined));
|
|
90
|
+
panel.append(heading, form);
|
|
91
|
+
root.append(...(button ? [fab, panel] : [panel]));
|
|
92
|
+
let timer;
|
|
93
|
+
let thanks;
|
|
94
|
+
const show = (visible) => {
|
|
95
|
+
if (visible)
|
|
96
|
+
panel.removeAttribute('hidden');
|
|
97
|
+
else
|
|
98
|
+
panel.setAttribute('hidden', '');
|
|
99
|
+
fab.setAttribute('aria-expanded', String(visible));
|
|
100
|
+
};
|
|
101
|
+
const reset = () => {
|
|
102
|
+
message.value = '';
|
|
103
|
+
if (email)
|
|
104
|
+
email.value = '';
|
|
105
|
+
rating = undefined;
|
|
106
|
+
for (const choice of ratingButtons)
|
|
107
|
+
choice.setAttribute('aria-pressed', 'false');
|
|
108
|
+
error.setAttribute('hidden', '');
|
|
109
|
+
thanks?.remove();
|
|
110
|
+
thanks = undefined;
|
|
111
|
+
form.removeAttribute('hidden');
|
|
112
|
+
};
|
|
113
|
+
const open = () => {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
if (thanks)
|
|
116
|
+
reset();
|
|
117
|
+
show(true);
|
|
118
|
+
message.focus();
|
|
119
|
+
};
|
|
120
|
+
const close = () => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
show(false);
|
|
123
|
+
fab.focus?.();
|
|
124
|
+
};
|
|
125
|
+
fab.addEventListener('click', () => (panel.hasAttribute('hidden') ? open() : close()));
|
|
126
|
+
cancel.addEventListener('click', close);
|
|
127
|
+
panel.addEventListener('keydown', (e) => { if (e.key === 'Escape')
|
|
128
|
+
close(); });
|
|
129
|
+
form.addEventListener('submit', (e) => {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
const text = message.value.trim();
|
|
132
|
+
if (!text) {
|
|
133
|
+
error.textContent = 'Please write a few words first.';
|
|
134
|
+
error.removeAttribute('hidden');
|
|
135
|
+
message.focus();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const address = email?.value.trim();
|
|
139
|
+
if (address && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)) {
|
|
140
|
+
error.textContent = 'That email address does not look right.';
|
|
141
|
+
error.removeAttribute('hidden');
|
|
142
|
+
email?.focus();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const properties = { ...(options.data?.() ?? {}), message: text.slice(0, MAX_MESSAGE) };
|
|
146
|
+
if (rating !== undefined)
|
|
147
|
+
properties.rating = rating;
|
|
148
|
+
if (address)
|
|
149
|
+
properties.email = address;
|
|
150
|
+
try {
|
|
151
|
+
;
|
|
152
|
+
(options.client ?? { track: defaultTrack }).track(event, properties);
|
|
153
|
+
}
|
|
154
|
+
catch (failure) {
|
|
155
|
+
error.textContent = failure instanceof Error && /call init/.test(failure.message) ? 'Feedback is not set up on this page yet.' : 'Could not send. Please try again.';
|
|
156
|
+
error.removeAttribute('hidden');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
form.setAttribute('hidden', '');
|
|
160
|
+
thanks = element('p', { class: 'thanks', role: 'status' }, 'Thank you! We read every message.');
|
|
161
|
+
panel.appendChild(thanks);
|
|
162
|
+
timer = setTimeout(() => { show(false); reset(); }, 2500);
|
|
163
|
+
});
|
|
164
|
+
container.appendChild(host);
|
|
165
|
+
return {
|
|
166
|
+
open,
|
|
167
|
+
close,
|
|
168
|
+
destroy() { clearTimeout(timer); host.remove(); },
|
|
169
|
+
};
|
|
170
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,21 +2,19 @@ import type { StorageAdapter } from './storage.ts';
|
|
|
2
2
|
export type { StorageAdapter } from './storage.ts';
|
|
3
3
|
export { memoryStorage } from './storage.ts';
|
|
4
4
|
export { version } from './event.ts';
|
|
5
|
-
/** A terminal ingestion refusal; billing changes require the project owner. */
|
|
6
|
-
export declare class SlurpQuotaError extends Error {
|
|
7
|
-
readonly status = 402;
|
|
8
|
-
constructor();
|
|
9
|
-
}
|
|
10
5
|
export interface SlurpOptions {
|
|
11
6
|
key: string;
|
|
12
7
|
endpoint: string;
|
|
13
|
-
sourceId?: string;
|
|
14
8
|
context?: Record<string, unknown> | (() => Record<string, unknown>);
|
|
15
9
|
flushAt?: number;
|
|
16
10
|
flushIntervalMs?: number;
|
|
17
11
|
maxBatchEvents?: number;
|
|
18
12
|
maxBatchBytes?: number;
|
|
19
13
|
maxQueue?: number;
|
|
14
|
+
/** Most bytes kept while offline, counting events not yet batched and batches not yet acknowledged; the oldest are dropped (and reported) beyond it. Records can be up to 1 MiB each, so a count limit alone is not a size limit. Default 4 MiB, which also stays under the browser's roughly 5 MB localStorage. */
|
|
15
|
+
maxQueueBytes?: number;
|
|
16
|
+
/** A request with no answer after this many milliseconds is aborted and retried with the same bytes. Default 30000. */
|
|
17
|
+
requestTimeoutMs?: number;
|
|
20
18
|
sessionTimeoutMs?: number;
|
|
21
19
|
storage?: StorageAdapter;
|
|
22
20
|
fetch?: typeof globalThis.fetch;
|
package/dist/index.js
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
|
-
import { context, encoder, jsonData, uuidv7 } from "./event.js";
|
|
1
|
+
import { context, encoder, jsonData, pageHref, resolveEndpoint, uuidv7 } from "./event.js";
|
|
2
2
|
import { defaultStorage } from "./storage.js";
|
|
3
3
|
export { memoryStorage } from "./storage.js";
|
|
4
4
|
export { version } from "./event.js";
|
|
5
|
-
/** A terminal ingestion refusal; billing changes require the project owner. */
|
|
6
|
-
export class SlurpQuotaError extends Error {
|
|
7
|
-
status = 402;
|
|
8
|
-
constructor() {
|
|
9
|
-
super('Slurp ingestion stopped: trial, spending limit, or payment needs attention. Ask the owner to run slurp billing status.');
|
|
10
|
-
this.name = 'SlurpQuotaError';
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
5
|
const count = (body) => body.split('\n').length - 1;
|
|
6
|
+
/** Saves still in flight for a closed client, by saved-state key. A replacement client waits for its own key here before it reads. */
|
|
7
|
+
const pendingCloses = new Map();
|
|
14
8
|
const bytes = (body) => encoder.encode(body).byteLength;
|
|
15
9
|
const KEEPALIVE_BYTES = 60 * 1024;
|
|
10
|
+
/** Largest single record the gateway accepts; records are stored whole, never truncated. */
|
|
11
|
+
const MAX_EVENT_BYTES = 1024 * 1024;
|
|
16
12
|
function positive(value, fallback) {
|
|
17
13
|
if (value === undefined)
|
|
18
14
|
return fallback;
|
|
@@ -20,16 +16,32 @@ function positive(value, fallback) {
|
|
|
20
16
|
throw new Error('limits and intervals must be positive safe integers');
|
|
21
17
|
return value;
|
|
22
18
|
}
|
|
19
|
+
/** True for one complete JSON object. The gateway validates a request as a whole, so one damaged line costs every event batched with it. */
|
|
20
|
+
function isRecord(line) {
|
|
21
|
+
try {
|
|
22
|
+
const value = JSON.parse(line);
|
|
23
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Read saved state, keeping only complete records; `damaged` counts the lines that were thrown away. */
|
|
23
30
|
function restore(raw) {
|
|
24
31
|
const state = JSON.parse(raw);
|
|
25
|
-
if (state.version !==
|
|
32
|
+
if (state.version !== 1 || typeof state.anonymousId !== 'string' || typeof state.sessionId !== 'string' ||
|
|
26
33
|
!Number.isFinite(state.lastActivity) || (state.userId !== undefined && typeof state.userId !== 'string') ||
|
|
27
|
-
!Array.isArray(state.queue) || !state.queue.every(line => typeof line === 'string'
|
|
28
|
-
!Array.isArray(state.batches) || !state.batches.every(body => typeof body === 'string'
|
|
34
|
+
!Array.isArray(state.queue) || !state.queue.every(line => typeof line === 'string') ||
|
|
35
|
+
!Array.isArray(state.batches) || !state.batches.every(body => typeof body === 'string') ||
|
|
29
36
|
!Number.isFinite(state.retryAt) || !Number.isSafeInteger(state.attempts) || typeof state.stopped !== 'boolean') {
|
|
30
37
|
throw new Error('invalid stored SDK state');
|
|
31
38
|
}
|
|
32
|
-
|
|
39
|
+
let damaged = 0;
|
|
40
|
+
const keep = (lines) => lines.filter(line => { const ok = line !== '' && !line.includes('\n') && isRecord(line); if (!ok && line !== '')
|
|
41
|
+
damaged++; return ok; });
|
|
42
|
+
state.queue = keep(state.queue);
|
|
43
|
+
state.batches = state.batches.map(body => keep(body.split('\n').slice(0, -1)).join('\n')).filter(joined => joined !== '').map(joined => joined + '\n');
|
|
44
|
+
return { state, damaged };
|
|
33
45
|
}
|
|
34
46
|
export function createClient(options) {
|
|
35
47
|
if (!options.key || !options.endpoint)
|
|
@@ -39,6 +51,8 @@ export function createClient(options) {
|
|
|
39
51
|
const maxBatchEvents = positive(options.maxBatchEvents, 1000);
|
|
40
52
|
const maxBatchBytes = positive(options.maxBatchBytes, 900 * 1024);
|
|
41
53
|
const maxQueue = positive(options.maxQueue, 10_000);
|
|
54
|
+
const maxQueueBytes = positive(options.maxQueueBytes, 4 * 1024 * 1024);
|
|
55
|
+
const requestTimeoutMs = positive(options.requestTimeoutMs, 30_000);
|
|
42
56
|
const sessionTimeout = positive(options.sessionTimeoutMs, 30 * 60 * 1000);
|
|
43
57
|
let accepted = 0;
|
|
44
58
|
let rejected = 0;
|
|
@@ -50,16 +64,15 @@ export function createClient(options) {
|
|
|
50
64
|
catch { /* callbacks cannot break delivery */ }
|
|
51
65
|
};
|
|
52
66
|
const storage = options.storage ?? defaultStorage(report);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
67
|
+
// Saved state belongs to one key: two senders sharing a storage (two sites on one origin) never send each other's queued events,
|
|
68
|
+
// and a replaced key starts clean. Replacing a key therefore starts a new visitor identity.
|
|
69
|
+
const storageKey = `@hitslop/slurp-sdk:v1:${resolveEndpoint(options.endpoint).replace(/[?#].*$/, "").replace(/\/$/, "")}:${options.key}`;
|
|
56
70
|
const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
57
71
|
let state = {
|
|
58
|
-
version:
|
|
72
|
+
version: 1, anonymousId: uuidv7(), sessionId: uuidv7(), lastActivity: 0,
|
|
59
73
|
queue: [], batches: [], retryAt: 0, attempts: 0, stopped: false,
|
|
60
74
|
};
|
|
61
75
|
let ready = false;
|
|
62
|
-
let quotaRefused = false;
|
|
63
76
|
let closed = false;
|
|
64
77
|
let closing;
|
|
65
78
|
let stagedCount = 0;
|
|
@@ -90,20 +103,48 @@ export function createClient(options) {
|
|
|
90
103
|
timer = setTimeout(() => { clearTimer(); void flush(); }, Math.min(delay, 2_147_483_647));
|
|
91
104
|
timer.unref?.();
|
|
92
105
|
};
|
|
106
|
+
// Bytes held, kept incrementally (re-measured whenever whole batches change) so a capture never has to re-encode the queue.
|
|
107
|
+
let queueBytes = 0;
|
|
108
|
+
let batchBytes = 0;
|
|
109
|
+
const measure = () => {
|
|
110
|
+
queueBytes = state.queue.reduce((total, line) => total + bytes(line) + 1, 0);
|
|
111
|
+
batchBytes = state.batches.reduce((total, body) => total + bytes(body), 0);
|
|
112
|
+
};
|
|
93
113
|
const trim = () => {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
114
|
+
let dropped = 0;
|
|
115
|
+
const excess = state.queue.length - maxQueue;
|
|
116
|
+
if (excess > 0) {
|
|
117
|
+
for (const line of state.queue.splice(0, excess))
|
|
118
|
+
queueBytes -= bytes(line) + 1;
|
|
119
|
+
dropped += excess;
|
|
120
|
+
}
|
|
121
|
+
while (queueBytes + batchBytes > maxQueueBytes && state.queue.length) {
|
|
122
|
+
queueBytes -= bytes(state.queue.shift()) + 1;
|
|
123
|
+
dropped++;
|
|
124
|
+
}
|
|
125
|
+
// Still too much: the oldest unsent batches go too, but never the one that is on the wire right now.
|
|
126
|
+
const protectedBatches = sending ? 1 : 0;
|
|
127
|
+
while (queueBytes + batchBytes > maxQueueBytes && state.batches.length > protectedBatches) {
|
|
128
|
+
dropped += count(state.batches.splice(protectedBatches, 1)[0]);
|
|
129
|
+
measure();
|
|
98
130
|
}
|
|
131
|
+
if (dropped > 0)
|
|
132
|
+
report(new Error('queue full; dropped the oldest queued events'), dropped);
|
|
99
133
|
};
|
|
100
134
|
const hydrate = (raw) => {
|
|
101
|
-
if (raw !== null)
|
|
102
|
-
|
|
135
|
+
if (raw !== null) {
|
|
136
|
+
const restored = restore(raw);
|
|
137
|
+
state = restored.state;
|
|
138
|
+
if (restored.damaged)
|
|
139
|
+
report(new Error(`dropped ${restored.damaged} damaged saved event(s)`), restored.damaged);
|
|
140
|
+
}
|
|
141
|
+
measure();
|
|
103
142
|
};
|
|
143
|
+
// A client that replaces another one (`init` twice) must read what the first one saved, so it waits for that save to finish.
|
|
144
|
+
const earlier = pendingCloses.get(storageKey);
|
|
104
145
|
let read = null;
|
|
105
146
|
try {
|
|
106
|
-
read = storage.getItem(storageKey);
|
|
147
|
+
read = earlier ? earlier.then(() => storage.getItem(storageKey)) : storage.getItem(storageKey);
|
|
107
148
|
}
|
|
108
149
|
catch (error) {
|
|
109
150
|
report(error);
|
|
@@ -141,10 +182,8 @@ export function createClient(options) {
|
|
|
141
182
|
const waiters = saveWaiters;
|
|
142
183
|
saveWaiters = [];
|
|
143
184
|
let saved = true;
|
|
144
|
-
// A 402 stops this client, but a fresh client after an owner upgrade
|
|
145
|
-
// must recover without clearing identities on every user's device.
|
|
146
185
|
try {
|
|
147
|
-
await storage.setItem(storageKey, JSON.stringify(
|
|
186
|
+
await storage.setItem(storageKey, JSON.stringify(state));
|
|
148
187
|
}
|
|
149
188
|
catch (error) {
|
|
150
189
|
saved = false;
|
|
@@ -173,8 +212,11 @@ export function createClient(options) {
|
|
|
173
212
|
size += next;
|
|
174
213
|
length++;
|
|
175
214
|
}
|
|
176
|
-
if (length)
|
|
215
|
+
if (length) {
|
|
177
216
|
state.batches.push(state.queue.splice(0, length).join('\n') + '\n');
|
|
217
|
+
queueBytes -= size;
|
|
218
|
+
batchBytes += size;
|
|
219
|
+
}
|
|
178
220
|
};
|
|
179
221
|
const retry = async (response) => {
|
|
180
222
|
let delay = NaN;
|
|
@@ -214,9 +256,15 @@ export function createClient(options) {
|
|
|
214
256
|
return true;
|
|
215
257
|
let response;
|
|
216
258
|
controller = new AbortController();
|
|
259
|
+
let deadlineTimer;
|
|
217
260
|
try {
|
|
218
|
-
const endpoint = keepalive ? new URL(options.endpoint,
|
|
261
|
+
const endpoint = keepalive ? new URL(options.endpoint, pageHref()) : undefined;
|
|
219
262
|
endpoint?.searchParams.set('key', options.key);
|
|
263
|
+
// A request that is never answered must not stall delivery for good: abort it and retry the same bytes.
|
|
264
|
+
const deadline = new Promise((_, reject) => {
|
|
265
|
+
deadlineTimer = setTimeout(() => { controller?.abort(); reject(new Error(`request timed out after ${requestTimeoutMs} ms`)); }, requestTimeoutMs);
|
|
266
|
+
deadlineTimer.unref?.();
|
|
267
|
+
});
|
|
220
268
|
response = await Promise.race([
|
|
221
269
|
doFetch(endpoint?.toString() ?? options.endpoint, {
|
|
222
270
|
method: 'POST', headers: keepalive ? { 'Content-Type': 'text/plain' } :
|
|
@@ -224,6 +272,7 @@ export function createClient(options) {
|
|
|
224
272
|
body, keepalive, signal: controller.signal,
|
|
225
273
|
}),
|
|
226
274
|
stopped.then(() => undefined),
|
|
275
|
+
deadline,
|
|
227
276
|
]);
|
|
228
277
|
}
|
|
229
278
|
catch (error) {
|
|
@@ -234,29 +283,32 @@ export function createClient(options) {
|
|
|
234
283
|
return;
|
|
235
284
|
}
|
|
236
285
|
finally {
|
|
286
|
+
clearTimeout(deadlineTimer);
|
|
237
287
|
controller = undefined;
|
|
238
288
|
}
|
|
239
289
|
if (closed || !response)
|
|
240
290
|
return;
|
|
241
291
|
void response.body?.cancel().catch(() => { });
|
|
242
292
|
const status = response.status;
|
|
243
|
-
if (status === 401 || status ===
|
|
293
|
+
if (status === 401 || status === 403) {
|
|
244
294
|
const dropped = pending();
|
|
245
|
-
quotaRefused = status === 402;
|
|
246
295
|
state.stopped = true;
|
|
247
296
|
state.batches = [];
|
|
248
297
|
state.queue = [];
|
|
298
|
+
queueBytes = 0;
|
|
299
|
+
batchBytes = 0;
|
|
249
300
|
clearTimer();
|
|
250
|
-
report(
|
|
301
|
+
report(new Error(`ingest ${status}; client stopped`), dropped);
|
|
251
302
|
await persist();
|
|
252
303
|
return;
|
|
253
304
|
}
|
|
254
|
-
if (status === 429 || status >= 500) {
|
|
305
|
+
if (status === 408 || status === 429 || status >= 500) {
|
|
255
306
|
report(new Error(`ingest ${status}; retry scheduled`));
|
|
256
307
|
await retry(response);
|
|
257
308
|
return;
|
|
258
309
|
}
|
|
259
310
|
state.batches.shift();
|
|
311
|
+
batchBytes -= bytes(body);
|
|
260
312
|
state.retryAt = 0;
|
|
261
313
|
state.attempts = 0;
|
|
262
314
|
if (status === 413) {
|
|
@@ -266,11 +318,12 @@ export function createClient(options) {
|
|
|
266
318
|
else {
|
|
267
319
|
const middle = Math.ceil(lines.length / 2);
|
|
268
320
|
state.batches.unshift(lines.slice(0, middle).join('\n') + '\n', lines.slice(middle).join('\n') + '\n');
|
|
321
|
+
measure();
|
|
269
322
|
}
|
|
270
323
|
}
|
|
271
|
-
else if (status
|
|
324
|
+
else if (status >= 200 && status < 300)
|
|
272
325
|
accepted += count(body);
|
|
273
|
-
else
|
|
326
|
+
else
|
|
274
327
|
report(new Error(`ingest ${status}; batch dropped`), count(body));
|
|
275
328
|
if (!(await persist())) {
|
|
276
329
|
schedule(interval);
|
|
@@ -359,8 +412,9 @@ export function createClient(options) {
|
|
|
359
412
|
anonymous_id: identity.anonymousId ?? anonymousId,
|
|
360
413
|
session_id: identity.sessionId ?? sessionId, data: payload, context: eventContext,
|
|
361
414
|
});
|
|
362
|
-
|
|
363
|
-
|
|
415
|
+
const size = bytes(line);
|
|
416
|
+
if (size > MAX_EVENT_BYTES) {
|
|
417
|
+
report(new Error('event exceeds 1 MiB'), 1);
|
|
364
418
|
return;
|
|
365
419
|
}
|
|
366
420
|
state.anonymousId = anonymousId;
|
|
@@ -369,6 +423,7 @@ export function createClient(options) {
|
|
|
369
423
|
state.userId = identified;
|
|
370
424
|
state.lastActivity = now;
|
|
371
425
|
state.queue.push(line);
|
|
426
|
+
queueBytes += size + 1;
|
|
372
427
|
trim();
|
|
373
428
|
if (state.queue.length >= flushAt && state.retryAt <= Date.now())
|
|
374
429
|
void flush();
|
|
@@ -400,7 +455,11 @@ export function createClient(options) {
|
|
|
400
455
|
clearTimer();
|
|
401
456
|
hideWindow?.removeEventListener('pagehide', onPageHide);
|
|
402
457
|
hideDocument?.removeEventListener('visibilitychange', onVisibility);
|
|
403
|
-
|
|
458
|
+
const done = persist().then(() => { });
|
|
459
|
+
pendingCloses.set(storageKey, done);
|
|
460
|
+
void done.finally(() => { if (pendingCloses.get(storageKey) === done)
|
|
461
|
+
pendingCloses.delete(storageKey); });
|
|
462
|
+
return closing = done;
|
|
404
463
|
},
|
|
405
464
|
get pending() { return pending(); },
|
|
406
465
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hitslop/slurp-sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Browser and Node event capture for Slurp: durable queue, byte-identical retries and batching. No runtime dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"analytics",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"repository": {
|
|
19
19
|
"type": "git",
|
|
20
20
|
"url": "git+https://github.com/hitSlop/slurp.git",
|
|
21
|
-
"directory": "
|
|
21
|
+
"directory": "sdks/js"
|
|
22
22
|
},
|
|
23
23
|
"bugs": {
|
|
24
24
|
"url": "https://github.com/hitSlop/slurp/issues"
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
"./node": {
|
|
36
36
|
"types": "./dist/node.d.ts",
|
|
37
37
|
"default": "./dist/node.js"
|
|
38
|
+
},
|
|
39
|
+
"./feedback": {
|
|
40
|
+
"types": "./dist/feedback.d.ts",
|
|
41
|
+
"default": "./dist/feedback.js"
|
|
38
42
|
}
|
|
39
43
|
},
|
|
40
44
|
"files": [
|
|
@@ -48,12 +52,13 @@
|
|
|
48
52
|
"publishConfig": {
|
|
49
53
|
"access": "public"
|
|
50
54
|
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"jsdom": "^31.0.0",
|
|
57
|
+
"typescript": "5.9.3"
|
|
58
|
+
},
|
|
51
59
|
"scripts": {
|
|
52
60
|
"test": "node --test test/*.test.ts",
|
|
53
61
|
"typecheck": "tsc -p . --noEmit",
|
|
54
62
|
"build": "tsc -p tsconfig.build.json"
|
|
55
|
-
},
|
|
56
|
-
"devDependencies": {
|
|
57
|
-
"typescript": "5.9.3"
|
|
58
63
|
}
|
|
59
|
-
}
|
|
64
|
+
}
|