@hitslop/slurp-sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,118 @@
1
- # Temporary Holding Version
1
+ # @hitslop/slurp-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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`), your source ID 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
+ sourceId: 'src_…',
19
+ endpoint: 'https://slurp.YOUR-SUBDOMAIN.workers.dev/v1/events',
20
+ });
21
+
22
+ track('signed_up', { plan: 'pro' });
23
+ ```
24
+
25
+ Node:
26
+
27
+ ```js
28
+ import { init, track, flush } from '@hitslop/slurp-sdk';
29
+
30
+ init({
31
+ key: 'slurp_pk_…',
32
+ sourceId: 'src_…',
33
+ endpoint: 'https://slurp.YOUR-SUBDOMAIN.workers.dev/v1/events',
34
+ });
35
+
36
+ track('order_completed', { total: 49 });
37
+
38
+ // Send queued events before the process exits.
39
+ await flush();
40
+ ```
41
+
42
+ `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.
43
+
44
+ Each record is one JSON line. Identify records omit `event` and put trait updates in `data`:
45
+
46
+ ```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"}}}
48
+ ```
49
+
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.
51
+
52
+ Options besides the required `key` and full `endpoint`:
53
+
54
+ | Option | Default | Meaning |
55
+ | --- | --- | --- |
56
+ | `sourceId` | None | Public source ID; include it so identity survives publishable-key rotation |
57
+ | `context` | Automatic environment | JSON context or a function producing capture-time context |
58
+ | `flushAt` | 100 | Queue size that triggers a send |
59
+ | `flushIntervalMs` | 5000 | Background flush interval |
60
+ | `maxBatchEvents` | 1000 | Maximum events per request |
61
+ | `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 |
63
+ | `sessionTimeoutMs` | 1800000 | Session inactivity timeout |
64
+ | `storage` | Browser localStorage; Node memory | Adapter described below |
65
+ | `fetch` | Global fetch | Fetch implementation |
66
+ | `onError` | None | Error callback; `dropped` is zero when work is retained for retry |
67
+ | `flushOnHide` | true in browsers | Try one head batch using fetch keepalive on page hide |
68
+
69
+ 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
+
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.
72
+
73
+ 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
+
75
+ The storage adapter has localStorage's shape; methods may be synchronous or return promises:
76
+
77
+ ```ts
78
+ interface StorageAdapter {
79
+ getItem(key: string): string | null | Promise<string | null>
80
+ setItem(key: string, value: string): void | Promise<void>
81
+ removeItem(key: string): void | Promise<void>
82
+ }
83
+ ```
84
+
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:
86
+
87
+ ```js
88
+ import { createClient } from '@hitslop/slurp-sdk';
89
+ import { fileStorage } from '@hitslop/slurp-sdk/node';
90
+
91
+ const client = createClient({
92
+ key: 'slurp_pk_…',
93
+ sourceId: 'src_…',
94
+ endpoint: 'https://slurp.YOUR-SUBDOMAIN.workers.dev/v1/events',
95
+ storage: fileStorage('/tmp/slurp-sdk.json'),
96
+ });
97
+ client.track('order_completed', { total: 49 });
98
+ await client.flush();
99
+ await client.close();
100
+ ```
101
+
102
+ 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
+
104
+ ## Contributing
105
+
106
+ From the [repository](https://github.com/hitSlop/slurp) root:
107
+
108
+ ```sh
109
+ pnpm --filter @hitslop/slurp-sdk typecheck
110
+ (cd packages/sdk-js && node --test test/*.test.ts)
111
+ pnpm --filter @hitslop/slurp-sdk build
112
+ ```
113
+
114
+ 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.
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,6 @@
1
+ export declare const version = "0.1.0";
2
+ export declare const encoder: TextEncoder;
3
+ export declare function uuidv7(now?: number): string;
4
+ export declare function plainObject(value: unknown): value is Record<string, unknown>;
5
+ export declare function jsonData(data: unknown): Record<string, unknown>;
6
+ export declare function context(): Record<string, unknown>;
package/dist/event.js ADDED
@@ -0,0 +1,57 @@
1
+ export const version = '0.1.0';
2
+ export const encoder = new TextEncoder();
3
+ export function uuidv7(now = Date.now()) {
4
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
5
+ for (let i = 5; i >= 0; i--) {
6
+ bytes[i] = now % 256;
7
+ now = Math.floor(now / 256);
8
+ }
9
+ bytes[6] = (bytes[6] & 15) | 0x70;
10
+ bytes[8] = (bytes[8] & 63) | 0x80;
11
+ const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
12
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
13
+ }
14
+ export function plainObject(value) {
15
+ if (value === null || typeof value !== 'object')
16
+ return false;
17
+ const prototype = Object.getPrototypeOf(value);
18
+ return prototype === Object.prototype || prototype === null;
19
+ }
20
+ export function jsonData(data) {
21
+ if (!plainObject(data))
22
+ throw new Error('data must be a plain JSON object');
23
+ const seen = new Set();
24
+ const check = (value) => {
25
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
26
+ return;
27
+ if (typeof value === 'number' && Number.isFinite(value))
28
+ return;
29
+ if (!Array.isArray(value) && !plainObject(value))
30
+ throw new Error('data must contain only JSON values');
31
+ if (seen.has(value))
32
+ throw new Error('data must not contain cycles');
33
+ seen.add(value);
34
+ if (Object.getOwnPropertySymbols(value).length)
35
+ throw new Error('data must contain only JSON keys');
36
+ for (const item of Array.isArray(value) ? value : Object.values(value))
37
+ check(item);
38
+ seen.delete(value);
39
+ };
40
+ check(data);
41
+ return JSON.parse(JSON.stringify(data));
42
+ }
43
+ export function context() {
44
+ const value = { library: { name: '@hitslop/slurp-sdk', version } };
45
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
46
+ value.page = { url: window.location.href, path: window.location.pathname, referrer: document.referrer, title: document.title };
47
+ value.user_agent = navigator.userAgent;
48
+ value.locale = navigator.language;
49
+ value.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
50
+ }
51
+ else {
52
+ const process = globalThis.process;
53
+ if (process?.versions?.node)
54
+ value.runtime = { name: 'node', version: process.version };
55
+ }
56
+ return value;
57
+ }
@@ -0,0 +1,53 @@
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
+ /** 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
+ export interface SlurpOptions {
11
+ key: string;
12
+ endpoint: string;
13
+ sourceId?: string;
14
+ context?: Record<string, unknown> | (() => Record<string, unknown>);
15
+ flushAt?: number;
16
+ flushIntervalMs?: number;
17
+ maxBatchEvents?: number;
18
+ maxBatchBytes?: number;
19
+ maxQueue?: number;
20
+ sessionTimeoutMs?: number;
21
+ storage?: StorageAdapter;
22
+ fetch?: typeof globalThis.fetch;
23
+ onError?: (error: Error, dropped: number) => void;
24
+ flushOnHide?: boolean;
25
+ }
26
+ export interface CaptureOptions {
27
+ userId?: string;
28
+ anonymousId?: string;
29
+ sessionId?: string;
30
+ context?: Record<string, unknown>;
31
+ }
32
+ /** Cumulative accepted/rejected counts for this client instance, plus current delivery state. */
33
+ export interface FlushResult {
34
+ accepted: number;
35
+ rejected: number;
36
+ pending: number;
37
+ retryAt: number | null;
38
+ stopped: boolean;
39
+ }
40
+ export interface SlurpClient {
41
+ track(event: string, data?: Record<string, unknown>, options?: CaptureOptions): string;
42
+ identify(userId: string, data?: Record<string, unknown>): string;
43
+ reset(): void;
44
+ flush(): Promise<FlushResult>;
45
+ close(): Promise<void>;
46
+ readonly pending: number;
47
+ }
48
+ export declare function createClient(options: SlurpOptions): SlurpClient;
49
+ export declare function init(options: SlurpOptions): SlurpClient;
50
+ export declare const track: (event: string, data?: Record<string, unknown>, options?: CaptureOptions) => string;
51
+ export declare const identify: (userId: string, data?: Record<string, unknown>) => string;
52
+ export declare const reset: () => void;
53
+ export declare const flush: () => Promise<FlushResult>;
package/dist/index.js ADDED
@@ -0,0 +1,422 @@
1
+ import { context, encoder, jsonData, uuidv7 } from "./event.js";
2
+ import { defaultStorage } from "./storage.js";
3
+ export { memoryStorage } from "./storage.js";
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
+ const count = (body) => body.split('\n').length - 1;
14
+ const bytes = (body) => encoder.encode(body).byteLength;
15
+ const KEEPALIVE_BYTES = 60 * 1024;
16
+ function positive(value, fallback) {
17
+ if (value === undefined)
18
+ return fallback;
19
+ if (!Number.isSafeInteger(value) || value < 1)
20
+ throw new Error('limits and intervals must be positive safe integers');
21
+ return value;
22
+ }
23
+ function restore(raw) {
24
+ const state = JSON.parse(raw);
25
+ if (state.version !== 2 || typeof state.anonymousId !== 'string' || typeof state.sessionId !== 'string' ||
26
+ !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')) ||
29
+ !Number.isFinite(state.retryAt) || !Number.isSafeInteger(state.attempts) || typeof state.stopped !== 'boolean') {
30
+ throw new Error('invalid stored SDK state');
31
+ }
32
+ return state;
33
+ }
34
+ export function createClient(options) {
35
+ if (!options.key || !options.endpoint)
36
+ throw new Error('key and endpoint are required');
37
+ const flushAt = positive(options.flushAt, 100);
38
+ const interval = positive(options.flushIntervalMs, 5000);
39
+ const maxBatchEvents = positive(options.maxBatchEvents, 1000);
40
+ const maxBatchBytes = positive(options.maxBatchBytes, 900 * 1024);
41
+ const maxQueue = positive(options.maxQueue, 10_000);
42
+ const sessionTimeout = positive(options.sessionTimeoutMs, 30 * 60 * 1000);
43
+ let accepted = 0;
44
+ let rejected = 0;
45
+ const report = (error, dropped = 0) => {
46
+ rejected += dropped;
47
+ try {
48
+ options.onError?.(error instanceof Error ? error : new Error(String(error)), dropped);
49
+ }
50
+ catch { /* callbacks cannot break delivery */ }
51
+ };
52
+ 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}`;
56
+ const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
57
+ let state = {
58
+ version: 2, anonymousId: uuidv7(), sessionId: uuidv7(), lastActivity: 0,
59
+ queue: [], batches: [], retryAt: 0, attempts: 0, stopped: false,
60
+ };
61
+ let ready = false;
62
+ let quotaRefused = false;
63
+ let closed = false;
64
+ let closing;
65
+ let stagedCount = 0;
66
+ const staged = [];
67
+ let sending;
68
+ let controller;
69
+ let timer;
70
+ let timerAt = Infinity;
71
+ let saving = false;
72
+ let saveWaiters = [];
73
+ let stop;
74
+ const stopped = new Promise(resolve => { stop = resolve; });
75
+ const clearTimer = () => {
76
+ if (timer !== undefined)
77
+ clearTimeout(timer);
78
+ timer = undefined;
79
+ timerAt = Infinity;
80
+ };
81
+ const pending = () => stagedCount + state.queue.length + state.batches.reduce((total, body) => total + count(body), 0);
82
+ const schedule = (delay) => {
83
+ if (closed || state.stopped)
84
+ return;
85
+ const at = Date.now() + delay;
86
+ if (timerAt <= at)
87
+ return;
88
+ clearTimer();
89
+ timerAt = at;
90
+ timer = setTimeout(() => { clearTimer(); void flush(); }, Math.min(delay, 2_147_483_647));
91
+ timer.unref?.();
92
+ };
93
+ 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);
98
+ }
99
+ };
100
+ const hydrate = (raw) => {
101
+ if (raw !== null)
102
+ state = restore(raw);
103
+ };
104
+ let read = null;
105
+ try {
106
+ read = storage.getItem(storageKey);
107
+ }
108
+ catch (error) {
109
+ report(error);
110
+ }
111
+ const finishLoad = () => {
112
+ ready = true;
113
+ for (const apply of staged.splice(0))
114
+ apply();
115
+ trim();
116
+ if (pending())
117
+ schedule(Math.max(0, state.retryAt - Date.now()) || interval);
118
+ };
119
+ let loaded;
120
+ if (typeof read === 'string' || read === null) {
121
+ try {
122
+ hydrate(read);
123
+ }
124
+ catch (error) {
125
+ report(error);
126
+ }
127
+ finishLoad();
128
+ loaded = Promise.resolve();
129
+ }
130
+ else {
131
+ loaded = Promise.resolve(read).then(hydrate).catch(report).then(finishLoad);
132
+ }
133
+ const persist = () => new Promise(resolve => {
134
+ saveWaiters.push(resolve);
135
+ if (saving)
136
+ return;
137
+ saving = true;
138
+ void (async () => {
139
+ await loaded;
140
+ while (saveWaiters.length) {
141
+ const waiters = saveWaiters;
142
+ saveWaiters = [];
143
+ 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
+ try {
147
+ await storage.setItem(storageKey, JSON.stringify(quotaRefused ? { ...state, stopped: false } : state));
148
+ }
149
+ catch (error) {
150
+ saved = false;
151
+ report(error);
152
+ }
153
+ for (const done of waiters)
154
+ done(saved);
155
+ }
156
+ saving = false;
157
+ })();
158
+ });
159
+ const apply = (operation) => {
160
+ if (ready)
161
+ operation();
162
+ else
163
+ staged.push(operation);
164
+ void persist();
165
+ };
166
+ const cutBatch = (limit) => {
167
+ let size = 0;
168
+ let length = 0;
169
+ for (const line of state.queue) {
170
+ const next = bytes(line) + 1;
171
+ if (length >= maxBatchEvents || (length > 0 && size + next > limit))
172
+ break;
173
+ size += next;
174
+ length++;
175
+ }
176
+ if (length)
177
+ state.batches.push(state.queue.splice(0, length).join('\n') + '\n');
178
+ };
179
+ const retry = async (response) => {
180
+ let delay = NaN;
181
+ const header = response?.headers.get('Retry-After');
182
+ if ((response?.status === 429 || response?.status === 503) && header) {
183
+ delay = /^\d+(?:\.\d+)?$/.test(header.trim()) ? Number(header) * 1000 : Date.parse(header) - Date.now();
184
+ }
185
+ state.attempts++;
186
+ if (!Number.isFinite(delay)) {
187
+ const backoff = Math.min(500 * 2 ** Math.min(state.attempts - 1, 7), 60_000);
188
+ delay = backoff * (0.5 + Math.random() * 0.5);
189
+ }
190
+ state.retryAt = Date.now() + Math.max(0, delay);
191
+ await persist();
192
+ schedule(Math.max(0, state.retryAt - Date.now()));
193
+ };
194
+ const send = async (keepalive) => {
195
+ await loaded;
196
+ while (!closed && !state.stopped) {
197
+ if (state.retryAt > Date.now()) {
198
+ schedule(state.retryAt - Date.now());
199
+ return;
200
+ }
201
+ // A page-hide batch must fit a keepalive request; bigger pending batches wait for the next load.
202
+ if (!state.batches.length)
203
+ cutBatch(keepalive ? Math.min(maxBatchBytes, KEEPALIVE_BYTES) : maxBatchBytes);
204
+ const body = state.batches[0];
205
+ if (!body)
206
+ return;
207
+ if (!(await persist())) {
208
+ schedule(interval);
209
+ return;
210
+ }
211
+ if (closed || state.stopped)
212
+ return;
213
+ if (keepalive && bytes(body) > KEEPALIVE_BYTES)
214
+ return true;
215
+ let response;
216
+ controller = new AbortController();
217
+ try {
218
+ const endpoint = keepalive ? new URL(options.endpoint, typeof window === 'undefined' ? undefined : window.location.href) : undefined;
219
+ endpoint?.searchParams.set('key', options.key);
220
+ response = await Promise.race([
221
+ doFetch(endpoint?.toString() ?? options.endpoint, {
222
+ method: 'POST', headers: keepalive ? { 'Content-Type': 'text/plain' } :
223
+ { 'Content-Type': 'application/x-ndjson', Authorization: `Bearer ${options.key}` },
224
+ body, keepalive, signal: controller.signal,
225
+ }),
226
+ stopped.then(() => undefined),
227
+ ]);
228
+ }
229
+ catch (error) {
230
+ if (closed)
231
+ return;
232
+ report(error);
233
+ await retry();
234
+ return;
235
+ }
236
+ finally {
237
+ controller = undefined;
238
+ }
239
+ if (closed || !response)
240
+ return;
241
+ void response.body?.cancel().catch(() => { });
242
+ const status = response.status;
243
+ if (status === 401 || status === 402 || status === 403) {
244
+ const dropped = pending();
245
+ quotaRefused = status === 402;
246
+ state.stopped = true;
247
+ state.batches = [];
248
+ state.queue = [];
249
+ clearTimer();
250
+ report(status === 402 ? new SlurpQuotaError() : new Error(`ingest ${status}; client stopped`), dropped);
251
+ await persist();
252
+ return;
253
+ }
254
+ if (status === 429 || status >= 500) {
255
+ report(new Error(`ingest ${status}; retry scheduled`));
256
+ await retry(response);
257
+ return;
258
+ }
259
+ state.batches.shift();
260
+ state.retryAt = 0;
261
+ state.attempts = 0;
262
+ if (status === 413) {
263
+ const lines = body.slice(0, -1).split('\n');
264
+ if (lines.length === 1)
265
+ report(new Error('ingest 413; single event rejected'), 1);
266
+ else {
267
+ const middle = Math.ceil(lines.length / 2);
268
+ state.batches.unshift(lines.slice(0, middle).join('\n') + '\n', lines.slice(middle).join('\n') + '\n');
269
+ }
270
+ }
271
+ else if (status === 200)
272
+ accepted += count(body);
273
+ else if (status !== 200)
274
+ report(new Error(`ingest ${status}; batch dropped`), count(body));
275
+ if (!(await persist())) {
276
+ schedule(interval);
277
+ return;
278
+ }
279
+ if (keepalive)
280
+ return true;
281
+ }
282
+ };
283
+ const start = (keepalive) => {
284
+ sending ??= send(keepalive).finally(() => {
285
+ sending = undefined;
286
+ if (pending())
287
+ schedule(Math.max(0, state.retryAt - Date.now()) || interval);
288
+ });
289
+ return sending;
290
+ };
291
+ async function flush() {
292
+ clearTimer();
293
+ while (await start(false)) {
294
+ if (closed || state.stopped || !pending())
295
+ break;
296
+ clearTimer();
297
+ }
298
+ return { accepted, rejected, pending: pending(), retryAt: state.retryAt > Date.now() ? state.retryAt : null, stopped: closed || state.stopped };
299
+ }
300
+ const onPageHide = () => {
301
+ if (!sending && !closed && !state.stopped) {
302
+ clearTimer();
303
+ void start(true);
304
+ }
305
+ };
306
+ const onVisibility = () => { if (document.visibilityState === 'hidden')
307
+ onPageHide(); };
308
+ const watchHide = (options.flushOnHide ?? true) && typeof window !== 'undefined' && typeof document !== 'undefined';
309
+ const hideWindow = watchHide ? window : undefined;
310
+ const hideDocument = watchHide ? document : undefined;
311
+ if (watchHide) {
312
+ hideWindow.addEventListener('pagehide', onPageHide);
313
+ hideDocument.addEventListener('visibilitychange', onVisibility);
314
+ }
315
+ const capture = (type, event, data, overrides = {}, identified) => {
316
+ const now = Date.now();
317
+ const id = uuidv7(now);
318
+ if (closed || state.stopped) {
319
+ report(new Error('client stopped; event dropped'), 1);
320
+ return id;
321
+ }
322
+ let payload;
323
+ let eventContext;
324
+ let identity;
325
+ try {
326
+ if (type === 'track' && (typeof event !== 'string' || !event.length || [...event].length > 200))
327
+ throw new Error('event must be a non-empty string of at most 200 characters');
328
+ if (type === 'identify' && (typeof identified !== 'string' || !identified.length))
329
+ throw new Error('userId must be a non-empty string');
330
+ for (const value of [overrides.userId, overrides.anonymousId, overrides.sessionId]) {
331
+ if (value !== undefined && (typeof value !== 'string' || !value.length))
332
+ throw new Error('identity overrides must be non-empty strings');
333
+ }
334
+ payload = jsonData(data);
335
+ const configured = typeof options.context === 'function' ? options.context() : options.context ?? {};
336
+ eventContext = jsonData({ ...context(), ...jsonData(configured), ...jsonData(overrides.context ?? {}) });
337
+ if ('geo' in eventContext)
338
+ throw new Error('context.geo is reserved for server enrichment');
339
+ identity = { userId: overrides.userId, anonymousId: overrides.anonymousId, sessionId: overrides.sessionId };
340
+ }
341
+ catch (error) {
342
+ report(error, 1);
343
+ return id;
344
+ }
345
+ stagedCount++;
346
+ apply(() => {
347
+ stagedCount--;
348
+ if (state.stopped) {
349
+ report(new Error('client stopped; event dropped'), 1);
350
+ return;
351
+ }
352
+ const switching = identified !== undefined && state.userId !== undefined && state.userId !== identified;
353
+ const anonymousId = switching ? uuidv7(now) : state.anonymousId;
354
+ const sessionId = switching || now - state.lastActivity >= sessionTimeout ? uuidv7(now) : state.sessionId;
355
+ const userId = identified ?? identity.userId ?? state.userId;
356
+ const line = JSON.stringify({
357
+ id, type, ...(event === undefined ? {} : { event }), timestamp: new Date(now).toISOString(),
358
+ ...(userId !== undefined ? { user_id: userId } : {}),
359
+ anonymous_id: identity.anonymousId ?? anonymousId,
360
+ session_id: identity.sessionId ?? sessionId, data: payload, context: eventContext,
361
+ });
362
+ if (bytes(line) > 32 * 1024) {
363
+ report(new Error('event exceeds 32 KB'), 1);
364
+ return;
365
+ }
366
+ state.anonymousId = anonymousId;
367
+ state.sessionId = sessionId;
368
+ if (identified !== undefined)
369
+ state.userId = identified;
370
+ state.lastActivity = now;
371
+ state.queue.push(line);
372
+ trim();
373
+ if (state.queue.length >= flushAt && state.retryAt <= Date.now())
374
+ void flush();
375
+ else
376
+ schedule(Math.max(0, state.retryAt - Date.now()) || interval);
377
+ });
378
+ return id;
379
+ };
380
+ return {
381
+ track: (event, data = {}, overrides) => capture('track', event, data, overrides),
382
+ identify: (userId, data = {}) => capture('identify', undefined, data, {}, userId),
383
+ reset() {
384
+ if (closed || state.stopped)
385
+ return;
386
+ apply(() => {
387
+ delete state.userId;
388
+ state.anonymousId = uuidv7();
389
+ state.sessionId = uuidv7();
390
+ state.lastActivity = Date.now();
391
+ });
392
+ },
393
+ flush,
394
+ close() {
395
+ if (closing)
396
+ return closing;
397
+ closed = true;
398
+ stop();
399
+ controller?.abort();
400
+ clearTimer();
401
+ hideWindow?.removeEventListener('pagehide', onPageHide);
402
+ hideDocument?.removeEventListener('visibilitychange', onVisibility);
403
+ return closing = persist().then(() => { });
404
+ },
405
+ get pending() { return pending(); },
406
+ };
407
+ }
408
+ let defaultClient;
409
+ export function init(options) {
410
+ if (defaultClient)
411
+ void defaultClient.close();
412
+ return defaultClient = createClient(options);
413
+ }
414
+ function client() {
415
+ if (!defaultClient)
416
+ throw new Error('call init before using the default client');
417
+ return defaultClient;
418
+ }
419
+ export const track = (event, data, options) => client().track(event, data, options);
420
+ export const identify = (userId, data) => client().identify(userId, data);
421
+ export const reset = () => client().reset();
422
+ export const flush = () => client().flush();
package/dist/node.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { StorageAdapter } from './storage.ts';
2
+ export declare function fileStorage(path: string): StorageAdapter;
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;
@@ -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,59 @@
1
1
  {
2
2
  "name": "@hitslop/slurp-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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": "packages/sdk-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
+ },
40
+ "files": [
41
+ "dist",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "engines": {
46
+ "node": ">=20"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "scripts": {
52
+ "test": "node --test test/*.test.ts",
53
+ "typecheck": "tsc -p . --noEmit",
54
+ "build": "tsc -p tsconfig.build.json"
55
+ },
56
+ "devDependencies": {
57
+ "typescript": "5.9.3"
58
+ }
59
+ }