@hitslop/slurp-sdk 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +129 -2
- package/dist/event.d.ts +12 -0
- package/dist/event.js +107 -0
- package/dist/feedback.d.ts +41 -0
- package/dist/feedback.js +170 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +481 -0
- package/dist/node.d.ts +2 -0
- package/dist/node.js +37 -0
- package/dist/storage.d.ts +7 -0
- package/dist/storage.js +52 -0
- package/package.json +61 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Longtail Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,130 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @hitslop/slurp-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Browser and Node event capture for [Slurp](https://slurp.run), with no runtime dependencies. Events go to the Worker in your own Cloudflare account; `slurp sync` pulls them into DuckDB for your coding agent.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @hitslop/slurp-sdk
|
|
7
|
+
```
|
|
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`) and your publishable key (`slurp_pk_…`, ingest-only, safe in a web page).
|
|
10
|
+
|
|
11
|
+
Browser:
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { init, track } from '@hitslop/slurp-sdk';
|
|
15
|
+
|
|
16
|
+
init({
|
|
17
|
+
key: 'slurp_pk_…',
|
|
18
|
+
endpoint: 'https://slurp.YOUR-SUBDOMAIN.workers.dev/v1/events',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
track('signed_up', { plan: 'pro' });
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Node:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
import { init, track, flush } from '@hitslop/slurp-sdk';
|
|
28
|
+
|
|
29
|
+
init({
|
|
30
|
+
key: 'slurp_pk_…',
|
|
31
|
+
endpoint: 'https://slurp.YOUR-SUBDOMAIN.workers.dev/v1/events',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
track('order_completed', { total: 49 });
|
|
35
|
+
|
|
36
|
+
// Send queued events before the process exits.
|
|
37
|
+
await flush();
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`init` creates or replaces the default client. `createClient(options)` creates an independent client with `track`, `identify`, `reset`, `flush`, `close` and a `pending` event count. `track` returns a locally generated UUIDv7, including when an event is rejected; use `onError(error, dropped)` to observe drops. Network failures never throw from `track` or `flush`. Call `identify('user-id', { plan: 'pro' })` to queue an identify record and attach `user_id` to subsequent events. Switching between known users rotates anonymous and session IDs; first identification keeps them so prior anonymous activity can be linked. Queued records retain their original identities. `reset()` clears it and starts a new anonymous identity and session. Thirty minutes without tracking starts a new session. Identity and session survive reloads through storage.
|
|
41
|
+
|
|
42
|
+
Each record is one JSON line. Identify records omit `event` and put trait updates in `data`:
|
|
43
|
+
|
|
44
|
+
```json
|
|
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"}}}
|
|
46
|
+
```
|
|
47
|
+
|
|
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.
|
|
49
|
+
|
|
50
|
+
Options besides the required `key` and full `endpoint`:
|
|
51
|
+
|
|
52
|
+
| Option | Default | Meaning |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| `context` | Automatic environment | JSON context or a function producing capture-time context |
|
|
55
|
+
| `flushAt` | 100 | Queue size that triggers a send |
|
|
56
|
+
| `flushIntervalMs` | 5000 | Background flush interval |
|
|
57
|
+
| `maxBatchEvents` | 1000 | Maximum events per request |
|
|
58
|
+
| `maxBatchBytes` | 921600 | UTF-8 body target; an event larger than the target travels alone |
|
|
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 |
|
|
62
|
+
| `sessionTimeoutMs` | 1800000 | Session inactivity timeout |
|
|
63
|
+
| `storage` | Browser localStorage; Node memory | Adapter described below |
|
|
64
|
+
| `fetch` | Global fetch | Fetch implementation |
|
|
65
|
+
| `onError` | None | Error callback; `dropped` is zero when work is retained for retry |
|
|
66
|
+
| `flushOnHide` | true in browsers | Try one head batch using fetch keepalive on page hide |
|
|
67
|
+
|
|
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.
|
|
69
|
+
|
|
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.
|
|
71
|
+
|
|
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.
|
|
73
|
+
|
|
74
|
+
The storage adapter has localStorage's shape; methods may be synchronous or return promises:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
interface StorageAdapter {
|
|
78
|
+
getItem(key: string): string | null | Promise<string | null>
|
|
79
|
+
setItem(key: string, value: string): void | Promise<void>
|
|
80
|
+
removeItem(key: string): void | Promise<void>
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
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:
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
import { createClient } from '@hitslop/slurp-sdk';
|
|
88
|
+
import { fileStorage } from '@hitslop/slurp-sdk/node';
|
|
89
|
+
|
|
90
|
+
const client = createClient({
|
|
91
|
+
key: 'slurp_pk_…',
|
|
92
|
+
endpoint: 'https://slurp.YOUR-SUBDOMAIN.workers.dev/v1/events',
|
|
93
|
+
storage: fileStorage('/tmp/slurp-sdk.json'),
|
|
94
|
+
});
|
|
95
|
+
client.track('order_completed', { total: 49 });
|
|
96
|
+
await client.flush();
|
|
97
|
+
await client.close();
|
|
98
|
+
```
|
|
99
|
+
|
|
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.
|
|
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
|
+
|
|
116
|
+
## Contributing
|
|
117
|
+
|
|
118
|
+
From the [repository](https://github.com/hitSlop/slurp) root:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
pnpm --filter @hitslop/slurp-sdk typecheck
|
|
122
|
+
(cd sdks/js && node --test test/*.test.ts)
|
|
123
|
+
pnpm --filter @hitslop/slurp-sdk build
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The capture scenarios verify identity transitions, contextual snapshots, key rotation and delivery results. The gateway integration scenario starts the real Worker (`backend/`) under `wrangler dev` with local R2 and Durable Objects, injects proxy faults, checks deduped restart replay, retry timing, 413 splitting and permanent authentication failures, then verifies archive digests and the exact delivered IDs. It needs Node 24+ and `npm install` in `backend/`; it uses no external services or paid APIs. `dist/` is ignored. Source uses erasable TypeScript and `.ts` import specifiers; the build rewrites them to `.js` and emits declarations.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
package/dist/event.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
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;
|
|
9
|
+
export declare function uuidv7(now?: number): string;
|
|
10
|
+
export declare function plainObject(value: unknown): value is Record<string, unknown>;
|
|
11
|
+
export declare function jsonData(data: unknown): Record<string, unknown>;
|
|
12
|
+
export declare function context(): Record<string, unknown>;
|
package/dist/event.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
}
|
|
53
|
+
export function uuidv7(now = Date.now()) {
|
|
54
|
+
const bytes = randomBytes(16);
|
|
55
|
+
for (let i = 5; i >= 0; i--) {
|
|
56
|
+
bytes[i] = now % 256;
|
|
57
|
+
now = Math.floor(now / 256);
|
|
58
|
+
}
|
|
59
|
+
bytes[6] = (bytes[6] & 15) | 0x70;
|
|
60
|
+
bytes[8] = (bytes[8] & 63) | 0x80;
|
|
61
|
+
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
62
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
63
|
+
}
|
|
64
|
+
export function plainObject(value) {
|
|
65
|
+
if (value === null || typeof value !== 'object')
|
|
66
|
+
return false;
|
|
67
|
+
const prototype = Object.getPrototypeOf(value);
|
|
68
|
+
return prototype === Object.prototype || prototype === null;
|
|
69
|
+
}
|
|
70
|
+
export function jsonData(data) {
|
|
71
|
+
if (!plainObject(data))
|
|
72
|
+
throw new Error('data must be a plain JSON object');
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
const check = (value) => {
|
|
75
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
76
|
+
return;
|
|
77
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
78
|
+
return;
|
|
79
|
+
if (!Array.isArray(value) && !plainObject(value))
|
|
80
|
+
throw new Error('data must contain only JSON values');
|
|
81
|
+
if (seen.has(value))
|
|
82
|
+
throw new Error('data must not contain cycles');
|
|
83
|
+
seen.add(value);
|
|
84
|
+
if (Object.getOwnPropertySymbols(value).length)
|
|
85
|
+
throw new Error('data must contain only JSON keys');
|
|
86
|
+
for (const item of Array.isArray(value) ? value : Object.values(value))
|
|
87
|
+
check(item);
|
|
88
|
+
seen.delete(value);
|
|
89
|
+
};
|
|
90
|
+
check(data);
|
|
91
|
+
return JSON.parse(JSON.stringify(data));
|
|
92
|
+
}
|
|
93
|
+
export function context() {
|
|
94
|
+
const value = { library: { name: '@hitslop/slurp-sdk', version } };
|
|
95
|
+
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
96
|
+
value.page = { url: window.location.href, path: window.location.pathname, referrer: document.referrer, title: document.title };
|
|
97
|
+
value.user_agent = navigator.userAgent;
|
|
98
|
+
value.locale = navigator.language;
|
|
99
|
+
value.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
const process = globalThis.process;
|
|
103
|
+
if (process?.versions?.node)
|
|
104
|
+
value.runtime = { name: 'node', version: process.version };
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
@@ -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
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { StorageAdapter } from './storage.ts';
|
|
2
|
+
export type { StorageAdapter } from './storage.ts';
|
|
3
|
+
export { memoryStorage } from './storage.ts';
|
|
4
|
+
export { version } from './event.ts';
|
|
5
|
+
export interface SlurpOptions {
|
|
6
|
+
key: string;
|
|
7
|
+
endpoint: string;
|
|
8
|
+
context?: Record<string, unknown> | (() => Record<string, unknown>);
|
|
9
|
+
flushAt?: number;
|
|
10
|
+
flushIntervalMs?: number;
|
|
11
|
+
maxBatchEvents?: number;
|
|
12
|
+
maxBatchBytes?: number;
|
|
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;
|
|
18
|
+
sessionTimeoutMs?: number;
|
|
19
|
+
storage?: StorageAdapter;
|
|
20
|
+
fetch?: typeof globalThis.fetch;
|
|
21
|
+
onError?: (error: Error, dropped: number) => void;
|
|
22
|
+
flushOnHide?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface CaptureOptions {
|
|
25
|
+
userId?: string;
|
|
26
|
+
anonymousId?: string;
|
|
27
|
+
sessionId?: string;
|
|
28
|
+
context?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
/** Cumulative accepted/rejected counts for this client instance, plus current delivery state. */
|
|
31
|
+
export interface FlushResult {
|
|
32
|
+
accepted: number;
|
|
33
|
+
rejected: number;
|
|
34
|
+
pending: number;
|
|
35
|
+
retryAt: number | null;
|
|
36
|
+
stopped: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface SlurpClient {
|
|
39
|
+
track(event: string, data?: Record<string, unknown>, options?: CaptureOptions): string;
|
|
40
|
+
identify(userId: string, data?: Record<string, unknown>): string;
|
|
41
|
+
reset(): void;
|
|
42
|
+
flush(): Promise<FlushResult>;
|
|
43
|
+
close(): Promise<void>;
|
|
44
|
+
readonly pending: number;
|
|
45
|
+
}
|
|
46
|
+
export declare function createClient(options: SlurpOptions): SlurpClient;
|
|
47
|
+
export declare function init(options: SlurpOptions): SlurpClient;
|
|
48
|
+
export declare const track: (event: string, data?: Record<string, unknown>, options?: CaptureOptions) => string;
|
|
49
|
+
export declare const identify: (userId: string, data?: Record<string, unknown>) => string;
|
|
50
|
+
export declare const reset: () => void;
|
|
51
|
+
export declare const flush: () => Promise<FlushResult>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { context, encoder, jsonData, pageHref, resolveEndpoint, uuidv7 } from "./event.js";
|
|
2
|
+
import { defaultStorage } from "./storage.js";
|
|
3
|
+
export { memoryStorage } from "./storage.js";
|
|
4
|
+
export { version } from "./event.js";
|
|
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();
|
|
8
|
+
const bytes = (body) => encoder.encode(body).byteLength;
|
|
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;
|
|
12
|
+
function positive(value, fallback) {
|
|
13
|
+
if (value === undefined)
|
|
14
|
+
return fallback;
|
|
15
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
16
|
+
throw new Error('limits and intervals must be positive safe integers');
|
|
17
|
+
return value;
|
|
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. */
|
|
30
|
+
function restore(raw) {
|
|
31
|
+
const state = JSON.parse(raw);
|
|
32
|
+
if (state.version !== 1 || typeof state.anonymousId !== 'string' || typeof state.sessionId !== 'string' ||
|
|
33
|
+
!Number.isFinite(state.lastActivity) || (state.userId !== undefined && typeof state.userId !== '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') ||
|
|
36
|
+
!Number.isFinite(state.retryAt) || !Number.isSafeInteger(state.attempts) || typeof state.stopped !== 'boolean') {
|
|
37
|
+
throw new Error('invalid stored SDK state');
|
|
38
|
+
}
|
|
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 };
|
|
45
|
+
}
|
|
46
|
+
export function createClient(options) {
|
|
47
|
+
if (!options.key || !options.endpoint)
|
|
48
|
+
throw new Error('key and endpoint are required');
|
|
49
|
+
const flushAt = positive(options.flushAt, 100);
|
|
50
|
+
const interval = positive(options.flushIntervalMs, 5000);
|
|
51
|
+
const maxBatchEvents = positive(options.maxBatchEvents, 1000);
|
|
52
|
+
const maxBatchBytes = positive(options.maxBatchBytes, 900 * 1024);
|
|
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);
|
|
56
|
+
const sessionTimeout = positive(options.sessionTimeoutMs, 30 * 60 * 1000);
|
|
57
|
+
let accepted = 0;
|
|
58
|
+
let rejected = 0;
|
|
59
|
+
const report = (error, dropped = 0) => {
|
|
60
|
+
rejected += dropped;
|
|
61
|
+
try {
|
|
62
|
+
options.onError?.(error instanceof Error ? error : new Error(String(error)), dropped);
|
|
63
|
+
}
|
|
64
|
+
catch { /* callbacks cannot break delivery */ }
|
|
65
|
+
};
|
|
66
|
+
const storage = options.storage ?? defaultStorage(report);
|
|
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}`;
|
|
70
|
+
const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
71
|
+
let state = {
|
|
72
|
+
version: 1, anonymousId: uuidv7(), sessionId: uuidv7(), lastActivity: 0,
|
|
73
|
+
queue: [], batches: [], retryAt: 0, attempts: 0, stopped: false,
|
|
74
|
+
};
|
|
75
|
+
let ready = false;
|
|
76
|
+
let closed = false;
|
|
77
|
+
let closing;
|
|
78
|
+
let stagedCount = 0;
|
|
79
|
+
const staged = [];
|
|
80
|
+
let sending;
|
|
81
|
+
let controller;
|
|
82
|
+
let timer;
|
|
83
|
+
let timerAt = Infinity;
|
|
84
|
+
let saving = false;
|
|
85
|
+
let saveWaiters = [];
|
|
86
|
+
let stop;
|
|
87
|
+
const stopped = new Promise(resolve => { stop = resolve; });
|
|
88
|
+
const clearTimer = () => {
|
|
89
|
+
if (timer !== undefined)
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
timer = undefined;
|
|
92
|
+
timerAt = Infinity;
|
|
93
|
+
};
|
|
94
|
+
const pending = () => stagedCount + state.queue.length + state.batches.reduce((total, body) => total + count(body), 0);
|
|
95
|
+
const schedule = (delay) => {
|
|
96
|
+
if (closed || state.stopped)
|
|
97
|
+
return;
|
|
98
|
+
const at = Date.now() + delay;
|
|
99
|
+
if (timerAt <= at)
|
|
100
|
+
return;
|
|
101
|
+
clearTimer();
|
|
102
|
+
timerAt = at;
|
|
103
|
+
timer = setTimeout(() => { clearTimer(); void flush(); }, Math.min(delay, 2_147_483_647));
|
|
104
|
+
timer.unref?.();
|
|
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
|
+
};
|
|
113
|
+
const trim = () => {
|
|
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();
|
|
130
|
+
}
|
|
131
|
+
if (dropped > 0)
|
|
132
|
+
report(new Error('queue full; dropped the oldest queued events'), dropped);
|
|
133
|
+
};
|
|
134
|
+
const hydrate = (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();
|
|
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);
|
|
145
|
+
let read = null;
|
|
146
|
+
try {
|
|
147
|
+
read = earlier ? earlier.then(() => storage.getItem(storageKey)) : storage.getItem(storageKey);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
report(error);
|
|
151
|
+
}
|
|
152
|
+
const finishLoad = () => {
|
|
153
|
+
ready = true;
|
|
154
|
+
for (const apply of staged.splice(0))
|
|
155
|
+
apply();
|
|
156
|
+
trim();
|
|
157
|
+
if (pending())
|
|
158
|
+
schedule(Math.max(0, state.retryAt - Date.now()) || interval);
|
|
159
|
+
};
|
|
160
|
+
let loaded;
|
|
161
|
+
if (typeof read === 'string' || read === null) {
|
|
162
|
+
try {
|
|
163
|
+
hydrate(read);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
report(error);
|
|
167
|
+
}
|
|
168
|
+
finishLoad();
|
|
169
|
+
loaded = Promise.resolve();
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
loaded = Promise.resolve(read).then(hydrate).catch(report).then(finishLoad);
|
|
173
|
+
}
|
|
174
|
+
const persist = () => new Promise(resolve => {
|
|
175
|
+
saveWaiters.push(resolve);
|
|
176
|
+
if (saving)
|
|
177
|
+
return;
|
|
178
|
+
saving = true;
|
|
179
|
+
void (async () => {
|
|
180
|
+
await loaded;
|
|
181
|
+
while (saveWaiters.length) {
|
|
182
|
+
const waiters = saveWaiters;
|
|
183
|
+
saveWaiters = [];
|
|
184
|
+
let saved = true;
|
|
185
|
+
try {
|
|
186
|
+
await storage.setItem(storageKey, JSON.stringify(state));
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
saved = false;
|
|
190
|
+
report(error);
|
|
191
|
+
}
|
|
192
|
+
for (const done of waiters)
|
|
193
|
+
done(saved);
|
|
194
|
+
}
|
|
195
|
+
saving = false;
|
|
196
|
+
})();
|
|
197
|
+
});
|
|
198
|
+
const apply = (operation) => {
|
|
199
|
+
if (ready)
|
|
200
|
+
operation();
|
|
201
|
+
else
|
|
202
|
+
staged.push(operation);
|
|
203
|
+
void persist();
|
|
204
|
+
};
|
|
205
|
+
const cutBatch = (limit) => {
|
|
206
|
+
let size = 0;
|
|
207
|
+
let length = 0;
|
|
208
|
+
for (const line of state.queue) {
|
|
209
|
+
const next = bytes(line) + 1;
|
|
210
|
+
if (length >= maxBatchEvents || (length > 0 && size + next > limit))
|
|
211
|
+
break;
|
|
212
|
+
size += next;
|
|
213
|
+
length++;
|
|
214
|
+
}
|
|
215
|
+
if (length) {
|
|
216
|
+
state.batches.push(state.queue.splice(0, length).join('\n') + '\n');
|
|
217
|
+
queueBytes -= size;
|
|
218
|
+
batchBytes += size;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
const retry = async (response) => {
|
|
222
|
+
let delay = NaN;
|
|
223
|
+
const header = response?.headers.get('Retry-After');
|
|
224
|
+
if ((response?.status === 429 || response?.status === 503) && header) {
|
|
225
|
+
delay = /^\d+(?:\.\d+)?$/.test(header.trim()) ? Number(header) * 1000 : Date.parse(header) - Date.now();
|
|
226
|
+
}
|
|
227
|
+
state.attempts++;
|
|
228
|
+
if (!Number.isFinite(delay)) {
|
|
229
|
+
const backoff = Math.min(500 * 2 ** Math.min(state.attempts - 1, 7), 60_000);
|
|
230
|
+
delay = backoff * (0.5 + Math.random() * 0.5);
|
|
231
|
+
}
|
|
232
|
+
state.retryAt = Date.now() + Math.max(0, delay);
|
|
233
|
+
await persist();
|
|
234
|
+
schedule(Math.max(0, state.retryAt - Date.now()));
|
|
235
|
+
};
|
|
236
|
+
const send = async (keepalive) => {
|
|
237
|
+
await loaded;
|
|
238
|
+
while (!closed && !state.stopped) {
|
|
239
|
+
if (state.retryAt > Date.now()) {
|
|
240
|
+
schedule(state.retryAt - Date.now());
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
// A page-hide batch must fit a keepalive request; bigger pending batches wait for the next load.
|
|
244
|
+
if (!state.batches.length)
|
|
245
|
+
cutBatch(keepalive ? Math.min(maxBatchBytes, KEEPALIVE_BYTES) : maxBatchBytes);
|
|
246
|
+
const body = state.batches[0];
|
|
247
|
+
if (!body)
|
|
248
|
+
return;
|
|
249
|
+
if (!(await persist())) {
|
|
250
|
+
schedule(interval);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (closed || state.stopped)
|
|
254
|
+
return;
|
|
255
|
+
if (keepalive && bytes(body) > KEEPALIVE_BYTES)
|
|
256
|
+
return true;
|
|
257
|
+
let response;
|
|
258
|
+
controller = new AbortController();
|
|
259
|
+
let deadlineTimer;
|
|
260
|
+
try {
|
|
261
|
+
const endpoint = keepalive ? new URL(options.endpoint, pageHref()) : undefined;
|
|
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
|
+
});
|
|
268
|
+
response = await Promise.race([
|
|
269
|
+
doFetch(endpoint?.toString() ?? options.endpoint, {
|
|
270
|
+
method: 'POST', headers: keepalive ? { 'Content-Type': 'text/plain' } :
|
|
271
|
+
{ 'Content-Type': 'application/x-ndjson', Authorization: `Bearer ${options.key}` },
|
|
272
|
+
body, keepalive, signal: controller.signal,
|
|
273
|
+
}),
|
|
274
|
+
stopped.then(() => undefined),
|
|
275
|
+
deadline,
|
|
276
|
+
]);
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
if (closed)
|
|
280
|
+
return;
|
|
281
|
+
report(error);
|
|
282
|
+
await retry();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
clearTimeout(deadlineTimer);
|
|
287
|
+
controller = undefined;
|
|
288
|
+
}
|
|
289
|
+
if (closed || !response)
|
|
290
|
+
return;
|
|
291
|
+
void response.body?.cancel().catch(() => { });
|
|
292
|
+
const status = response.status;
|
|
293
|
+
if (status === 401 || status === 403) {
|
|
294
|
+
const dropped = pending();
|
|
295
|
+
state.stopped = true;
|
|
296
|
+
state.batches = [];
|
|
297
|
+
state.queue = [];
|
|
298
|
+
queueBytes = 0;
|
|
299
|
+
batchBytes = 0;
|
|
300
|
+
clearTimer();
|
|
301
|
+
report(new Error(`ingest ${status}; client stopped`), dropped);
|
|
302
|
+
await persist();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (status === 408 || status === 429 || status >= 500) {
|
|
306
|
+
report(new Error(`ingest ${status}; retry scheduled`));
|
|
307
|
+
await retry(response);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
state.batches.shift();
|
|
311
|
+
batchBytes -= bytes(body);
|
|
312
|
+
state.retryAt = 0;
|
|
313
|
+
state.attempts = 0;
|
|
314
|
+
if (status === 413) {
|
|
315
|
+
const lines = body.slice(0, -1).split('\n');
|
|
316
|
+
if (lines.length === 1)
|
|
317
|
+
report(new Error('ingest 413; single event rejected'), 1);
|
|
318
|
+
else {
|
|
319
|
+
const middle = Math.ceil(lines.length / 2);
|
|
320
|
+
state.batches.unshift(lines.slice(0, middle).join('\n') + '\n', lines.slice(middle).join('\n') + '\n');
|
|
321
|
+
measure();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
else if (status >= 200 && status < 300)
|
|
325
|
+
accepted += count(body);
|
|
326
|
+
else
|
|
327
|
+
report(new Error(`ingest ${status}; batch dropped`), count(body));
|
|
328
|
+
if (!(await persist())) {
|
|
329
|
+
schedule(interval);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (keepalive)
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
const start = (keepalive) => {
|
|
337
|
+
sending ??= send(keepalive).finally(() => {
|
|
338
|
+
sending = undefined;
|
|
339
|
+
if (pending())
|
|
340
|
+
schedule(Math.max(0, state.retryAt - Date.now()) || interval);
|
|
341
|
+
});
|
|
342
|
+
return sending;
|
|
343
|
+
};
|
|
344
|
+
async function flush() {
|
|
345
|
+
clearTimer();
|
|
346
|
+
while (await start(false)) {
|
|
347
|
+
if (closed || state.stopped || !pending())
|
|
348
|
+
break;
|
|
349
|
+
clearTimer();
|
|
350
|
+
}
|
|
351
|
+
return { accepted, rejected, pending: pending(), retryAt: state.retryAt > Date.now() ? state.retryAt : null, stopped: closed || state.stopped };
|
|
352
|
+
}
|
|
353
|
+
const onPageHide = () => {
|
|
354
|
+
if (!sending && !closed && !state.stopped) {
|
|
355
|
+
clearTimer();
|
|
356
|
+
void start(true);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
const onVisibility = () => { if (document.visibilityState === 'hidden')
|
|
360
|
+
onPageHide(); };
|
|
361
|
+
const watchHide = (options.flushOnHide ?? true) && typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
362
|
+
const hideWindow = watchHide ? window : undefined;
|
|
363
|
+
const hideDocument = watchHide ? document : undefined;
|
|
364
|
+
if (watchHide) {
|
|
365
|
+
hideWindow.addEventListener('pagehide', onPageHide);
|
|
366
|
+
hideDocument.addEventListener('visibilitychange', onVisibility);
|
|
367
|
+
}
|
|
368
|
+
const capture = (type, event, data, overrides = {}, identified) => {
|
|
369
|
+
const now = Date.now();
|
|
370
|
+
const id = uuidv7(now);
|
|
371
|
+
if (closed || state.stopped) {
|
|
372
|
+
report(new Error('client stopped; event dropped'), 1);
|
|
373
|
+
return id;
|
|
374
|
+
}
|
|
375
|
+
let payload;
|
|
376
|
+
let eventContext;
|
|
377
|
+
let identity;
|
|
378
|
+
try {
|
|
379
|
+
if (type === 'track' && (typeof event !== 'string' || !event.length || [...event].length > 200))
|
|
380
|
+
throw new Error('event must be a non-empty string of at most 200 characters');
|
|
381
|
+
if (type === 'identify' && (typeof identified !== 'string' || !identified.length))
|
|
382
|
+
throw new Error('userId must be a non-empty string');
|
|
383
|
+
for (const value of [overrides.userId, overrides.anonymousId, overrides.sessionId]) {
|
|
384
|
+
if (value !== undefined && (typeof value !== 'string' || !value.length))
|
|
385
|
+
throw new Error('identity overrides must be non-empty strings');
|
|
386
|
+
}
|
|
387
|
+
payload = jsonData(data);
|
|
388
|
+
const configured = typeof options.context === 'function' ? options.context() : options.context ?? {};
|
|
389
|
+
eventContext = jsonData({ ...context(), ...jsonData(configured), ...jsonData(overrides.context ?? {}) });
|
|
390
|
+
if ('geo' in eventContext)
|
|
391
|
+
throw new Error('context.geo is reserved for server enrichment');
|
|
392
|
+
identity = { userId: overrides.userId, anonymousId: overrides.anonymousId, sessionId: overrides.sessionId };
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
report(error, 1);
|
|
396
|
+
return id;
|
|
397
|
+
}
|
|
398
|
+
stagedCount++;
|
|
399
|
+
apply(() => {
|
|
400
|
+
stagedCount--;
|
|
401
|
+
if (state.stopped) {
|
|
402
|
+
report(new Error('client stopped; event dropped'), 1);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const switching = identified !== undefined && state.userId !== undefined && state.userId !== identified;
|
|
406
|
+
const anonymousId = switching ? uuidv7(now) : state.anonymousId;
|
|
407
|
+
const sessionId = switching || now - state.lastActivity >= sessionTimeout ? uuidv7(now) : state.sessionId;
|
|
408
|
+
const userId = identified ?? identity.userId ?? state.userId;
|
|
409
|
+
const line = JSON.stringify({
|
|
410
|
+
id, type, ...(event === undefined ? {} : { event }), timestamp: new Date(now).toISOString(),
|
|
411
|
+
...(userId !== undefined ? { user_id: userId } : {}),
|
|
412
|
+
anonymous_id: identity.anonymousId ?? anonymousId,
|
|
413
|
+
session_id: identity.sessionId ?? sessionId, data: payload, context: eventContext,
|
|
414
|
+
});
|
|
415
|
+
const size = bytes(line);
|
|
416
|
+
if (size > MAX_EVENT_BYTES) {
|
|
417
|
+
report(new Error('event exceeds 1 MiB'), 1);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
state.anonymousId = anonymousId;
|
|
421
|
+
state.sessionId = sessionId;
|
|
422
|
+
if (identified !== undefined)
|
|
423
|
+
state.userId = identified;
|
|
424
|
+
state.lastActivity = now;
|
|
425
|
+
state.queue.push(line);
|
|
426
|
+
queueBytes += size + 1;
|
|
427
|
+
trim();
|
|
428
|
+
if (state.queue.length >= flushAt && state.retryAt <= Date.now())
|
|
429
|
+
void flush();
|
|
430
|
+
else
|
|
431
|
+
schedule(Math.max(0, state.retryAt - Date.now()) || interval);
|
|
432
|
+
});
|
|
433
|
+
return id;
|
|
434
|
+
};
|
|
435
|
+
return {
|
|
436
|
+
track: (event, data = {}, overrides) => capture('track', event, data, overrides),
|
|
437
|
+
identify: (userId, data = {}) => capture('identify', undefined, data, {}, userId),
|
|
438
|
+
reset() {
|
|
439
|
+
if (closed || state.stopped)
|
|
440
|
+
return;
|
|
441
|
+
apply(() => {
|
|
442
|
+
delete state.userId;
|
|
443
|
+
state.anonymousId = uuidv7();
|
|
444
|
+
state.sessionId = uuidv7();
|
|
445
|
+
state.lastActivity = Date.now();
|
|
446
|
+
});
|
|
447
|
+
},
|
|
448
|
+
flush,
|
|
449
|
+
close() {
|
|
450
|
+
if (closing)
|
|
451
|
+
return closing;
|
|
452
|
+
closed = true;
|
|
453
|
+
stop();
|
|
454
|
+
controller?.abort();
|
|
455
|
+
clearTimer();
|
|
456
|
+
hideWindow?.removeEventListener('pagehide', onPageHide);
|
|
457
|
+
hideDocument?.removeEventListener('visibilitychange', onVisibility);
|
|
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;
|
|
463
|
+
},
|
|
464
|
+
get pending() { return pending(); },
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
let defaultClient;
|
|
468
|
+
export function init(options) {
|
|
469
|
+
if (defaultClient)
|
|
470
|
+
void defaultClient.close();
|
|
471
|
+
return defaultClient = createClient(options);
|
|
472
|
+
}
|
|
473
|
+
function client() {
|
|
474
|
+
if (!defaultClient)
|
|
475
|
+
throw new Error('call init before using the default client');
|
|
476
|
+
return defaultClient;
|
|
477
|
+
}
|
|
478
|
+
export const track = (event, data, options) => client().track(event, data, options);
|
|
479
|
+
export const identify = (userId, data) => client().identify(userId, data);
|
|
480
|
+
export const reset = () => client().reset();
|
|
481
|
+
export const flush = () => client().flush();
|
package/dist/node.d.ts
ADDED
package/dist/node.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
export function fileStorage(path) {
|
|
3
|
+
let operations = Promise.resolve();
|
|
4
|
+
const run = (operation) => {
|
|
5
|
+
const result = operations.then(operation);
|
|
6
|
+
operations = result.then(() => { }, () => { });
|
|
7
|
+
return result;
|
|
8
|
+
};
|
|
9
|
+
const read = async () => {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(await fs.readFile(path, 'utf8'));
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error.code === 'ENOENT')
|
|
15
|
+
return Object.create(null);
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const write = async (values) => {
|
|
20
|
+
const temporary = `${path}.${crypto.randomUUID()}.tmp`;
|
|
21
|
+
try {
|
|
22
|
+
await fs.writeFile(temporary, JSON.stringify(values), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
23
|
+
await fs.rename(temporary, path);
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
await fs.unlink(temporary).catch(error => {
|
|
27
|
+
if (error.code !== 'ENOENT')
|
|
28
|
+
throw error;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
getItem: key => run(async () => (await read())[key] ?? null),
|
|
34
|
+
setItem: (key, value) => run(async () => { const values = await read(); values[key] = value; await write(values); }),
|
|
35
|
+
removeItem: key => run(async () => { const values = await read(); delete values[key]; await write(values); }),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface StorageAdapter {
|
|
2
|
+
getItem(key: string): string | null | Promise<string | null>;
|
|
3
|
+
setItem(key: string, value: string): void | Promise<void>;
|
|
4
|
+
removeItem(key: string): void | Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export declare function memoryStorage(): StorageAdapter;
|
|
7
|
+
export declare function defaultStorage(report: (error: unknown) => void): StorageAdapter;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export function memoryStorage() {
|
|
2
|
+
const values = new Map();
|
|
3
|
+
return {
|
|
4
|
+
getItem: key => values.get(key) ?? null,
|
|
5
|
+
setItem: (key, value) => { values.set(key, value); },
|
|
6
|
+
removeItem: key => { values.delete(key); },
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export function defaultStorage(report) {
|
|
10
|
+
const memory = memoryStorage();
|
|
11
|
+
if (typeof window === 'undefined')
|
|
12
|
+
return memory;
|
|
13
|
+
let failed = false;
|
|
14
|
+
return {
|
|
15
|
+
getItem(key) {
|
|
16
|
+
if (!failed) {
|
|
17
|
+
try {
|
|
18
|
+
return window.localStorage.getItem(key);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
failed = true;
|
|
22
|
+
report(error);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return memory.getItem(key);
|
|
26
|
+
},
|
|
27
|
+
setItem(key, value) {
|
|
28
|
+
memory.setItem(key, value);
|
|
29
|
+
if (!failed) {
|
|
30
|
+
try {
|
|
31
|
+
window.localStorage.setItem(key, value);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
failed = true;
|
|
35
|
+
report(error);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
removeItem(key) {
|
|
40
|
+
memory.removeItem(key);
|
|
41
|
+
if (!failed) {
|
|
42
|
+
try {
|
|
43
|
+
window.localStorage.removeItem(key);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
failed = true;
|
|
47
|
+
report(error);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hitslop/slurp-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Browser and Node event capture for Slurp: durable queue, byte-identical retries and batching. No runtime dependencies.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"analytics",
|
|
7
|
+
"product-analytics",
|
|
8
|
+
"events",
|
|
9
|
+
"tracking",
|
|
10
|
+
"slurp",
|
|
11
|
+
"cloudflare",
|
|
12
|
+
"duckdb",
|
|
13
|
+
"coding-agents"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Longtail Labs",
|
|
17
|
+
"homepage": "https://slurp.run/docs/#capture",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/hitSlop/slurp.git",
|
|
21
|
+
"directory": "sdks/js"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/hitSlop/slurp/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./node": {
|
|
36
|
+
"types": "./dist/node.d.ts",
|
|
37
|
+
"default": "./dist/node.js"
|
|
38
|
+
},
|
|
39
|
+
"./feedback": {
|
|
40
|
+
"types": "./dist/feedback.d.ts",
|
|
41
|
+
"default": "./dist/feedback.js"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist",
|
|
46
|
+
"README.md",
|
|
47
|
+
"LICENSE"
|
|
48
|
+
],
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=20"
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"jsdom": "^31.0.0",
|
|
57
|
+
"typescript": "5.9.3"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"test": "node --test test/*.test.ts",
|
|
61
|
+
"typecheck": "tsc -p . --noEmit",
|
|
62
|
+
"build": "tsc -p tsconfig.build.json"
|
|
63
|
+
}
|
|
6
64
|
}
|