@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 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`), your source ID and your publishable key (`slurp_pk_…`, ingest-only, safe in a web page).
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.0"}}}
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 32 KiB of serialized UTF-8 JSON are dropped and reported. No server stamp fields are emitted.
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; unacknowledged batch bodies remain intact |
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. A 200 acknowledges a batch; 429/503 honor `Retry-After` (seconds or HTTP date), and other 5xx/network errors 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.
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
- Version-2 storage keys are namespaced by endpoint and public source ID when supplied, with publishable-key fallback otherwise. The incompatible version-1 state remains untouched. Supply `sourceId` for identity continuity across key rotation. Use one active client per endpoint/source and adapter; independent clients can use separate adapters. The default Node adapter is in memory. For restart recovery, use the Node-only entry:
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 packages/sdk-js && node --test test/*.test.ts)
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.0";
2
- export declare const encoder: TextEncoder;
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.0';
2
- export const encoder = new TextEncoder();
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 = crypto.getRandomValues(new Uint8Array(16));
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;
@@ -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 !== 2 || typeof state.anonymousId !== 'string' || typeof state.sessionId !== 'string' ||
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' && !line.includes('\n')) ||
28
- !Array.isArray(state.batches) || !state.batches.every(body => typeof body === 'string' && body.endsWith('\n')) ||
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
- return state;
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
- const storageKey = options.sourceId
54
- ? `@hitslop/slurp-sdk:v2:${new URL(options.endpoint, typeof window === 'undefined' ? undefined : window.location.href).toString().replace(/[?#].*$/, "").replace(/\/$/, "")}:${options.sourceId}`
55
- : `@hitslop/slurp-sdk:v2:${options.key}`;
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: 2, anonymousId: uuidv7(), sessionId: uuidv7(), lastActivity: 0,
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
- const dropped = state.queue.length - maxQueue;
95
- if (dropped > 0) {
96
- state.queue.splice(0, dropped);
97
- report(new Error('queue full; dropped oldest queued events'), dropped);
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
- state = restore(raw);
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(quotaRefused ? { ...state, stopped: false } : state));
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, typeof window === 'undefined' ? undefined : window.location.href) : undefined;
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 === 402 || status === 403) {
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(status === 402 ? new SlurpQuotaError() : new Error(`ingest ${status}; client stopped`), dropped);
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 === 200)
324
+ else if (status >= 200 && status < 300)
272
325
  accepted += count(body);
273
- else if (status !== 200)
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
- if (bytes(line) > 32 * 1024) {
363
- report(new Error('event exceeds 32 KB'), 1);
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
- return closing = persist().then(() => { });
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.0",
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": "packages/sdk-js"
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
+ }