@storewatch/browser 0.0.0-stage → 0.2.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 Edin Skeja
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,31 @@
1
- # Temporary Holding Version
1
+ # @storewatch/browser
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
+ Storewatch browser SDK: live, consent-aware commerce tracking in about 3 KB
4
+ (gzipped). Tracking never blocks or breaks your store: events are queued and
5
+ sent in the background, with bounded retries.
6
+
7
+ ```ts
8
+ import { init, money } from "@storewatch/browser";
9
+
10
+ const sw = init({ publicKey: "sw_pk_…", endpoint: "https://ingest.example.com/v1/batch" });
11
+ sw.track("product_viewed", { productId: "prod_123", price: money(1249, "SEK") });
12
+ sw.setConsent("granted"); // "pending" (default, cookieless) | "granted" | "denied"
13
+ ```
14
+
15
+ **Payment redirects.** Before consent the session lives only in memory, so a
16
+ shopper who leaves for Klarna, PayPal or 3-D Secure would come back as a new
17
+ session. Pass the return URL through `linkUrl` and the session continues; the
18
+ store page that receives it removes the parameter from the address bar:
19
+
20
+ ```ts
21
+ stripe.confirmPayment({ elements, confirmParams: { return_url: sw.linkUrl(returnUrl) } });
22
+ ```
23
+
24
+ If the return URL is a server route that redirects onwards, forward the `_sws`
25
+ query parameter to the page it redirects to.
26
+
27
+ A visible tab counts as online only while someone uses it: after 5 minutes
28
+ without a click, scroll, tap or key press it stops (`presenceIdleMs`).
29
+
30
+ Use your **public** key here. Secret keys belong on your server
31
+ (`@storewatch/node`). On Next.js, use `@storewatch/nextjs`.
@@ -0,0 +1,12 @@
1
+ import type { Storewatch, StorewatchOptions } from "./types.js";
2
+ /** The session in a `linkUrl` link, if it's well-formed and recent enough to still be alive. */
3
+ export declare function readSessionLink(value: string | null, now?: number): string | null;
4
+ export declare function createStorewatch(options: StorewatchOptions): Storewatch;
5
+ /**
6
+ * Idempotent page-level singleton: calling `init` twice (React StrictMode,
7
+ * remounts, HMR) returns the same client instead of double-tracking.
8
+ */
9
+ export declare function init(options: StorewatchOptions): Storewatch;
10
+ export declare function getStorewatch(): Storewatch;
11
+ /** Test hook: forget the singleton and any queued calls. */
12
+ export declare function resetForTests(): void;
@@ -0,0 +1,37 @@
1
+ import type { Consent } from "./types.js";
2
+ /** A session ends after this much inactivity. */
3
+ export declare const SESSION_IDLE_MS: number;
4
+ type Utm = Partial<Record<"source" | "medium" | "campaign" | "term" | "content", string>>;
5
+ export type Session = {
6
+ id: string;
7
+ last: number;
8
+ utm?: Utm;
9
+ referrer?: string;
10
+ };
11
+ /**
12
+ * Session and visitor identity under three consent states:
13
+ * - pending: in memory only; nothing touches device storage
14
+ * - granted: session and visitor IDs persisted in localStorage
15
+ * - denied: no IDs, storage cleared
16
+ */
17
+ export declare function createIdentity(initial: Consent): {
18
+ consent: () => Consent;
19
+ setConsent(next: Consent): void;
20
+ /**
21
+ * The active session, rolled over after inactivity. Null when denied.
22
+ * `activity: false` (heartbeats) reads the session without extending it
23
+ * and never starts a new one: an open but untouched tab must not keep a
24
+ * session, or "online", alive forever.
25
+ */
26
+ touch(now?: number, activity?: boolean): Session | null;
27
+ /**
28
+ * Continue a session carried over a redirect (see `linkUrl`). It keeps
29
+ * the id but not the landing attribution: the payment page the shopper
30
+ * returns from is not where they came from.
31
+ */
32
+ adopt(id: string, now?: number): void;
33
+ current: () => Session | null;
34
+ visitorId: () => string | null;
35
+ };
36
+ export type Identity = ReturnType<typeof createIdentity>;
37
+ export {};
package/dist/ids.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * UUIDv7: time-ordered, so events sort naturally and ClickHouse stores them
3
+ * compactly. Uses crypto.getRandomValues, which (unlike randomUUID) also
4
+ * works on plain-http staging sites.
5
+ */
6
+ export declare function uuidv7(now?: number): string;
7
+ export declare function shortId(): string;
@@ -0,0 +1,3 @@
1
+ export { createStorewatch, getStorewatch, init } from "./client.js";
2
+ export { money, toMajor, type Money } from "@storewatch/schema/constants";
3
+ export type { Consent, EventProperties, Storewatch, StorewatchOptions } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ function x(e=Date.now()){let t=crypto.getRandomValues(new Uint8Array(16)),n=e;for(let a=5;a>=0;a--)t[a]=n&255,n=Math.floor(n/256);t[6]=t[6]&15|112,t[8]=t[8]&63|128;let r=Array.from(t,a=>a.toString(16).padStart(2,"0")).join("");return`${r.slice(0,8)}-${r.slice(8,12)}-${r.slice(12,16)}-${r.slice(16,20)}-${r.slice(20)}`}function M(){return x().replace(/-/g,"").slice(-20)}var w="sw_s",k="sw_v",_=30*6e4,U=e=>{try{return localStorage.getItem(e)}catch{return null}},S=(e,t)=>{try{t===null?localStorage.removeItem(e):localStorage.setItem(e,t)}catch{}};function Q(){let e={};try{let t=new URLSearchParams(location.search),n={};for(let r of["source","medium","campaign","term","content"]){let a=t.get(`utm_${r}`);a&&(n[r]=a.slice(0,200))}Object.keys(n).length>0&&(e.utm=n),document.referrer&&(e.referrer=document.referrer)}catch{}return e}function V(e){let t=e,n=null,r=null;function a(){if(t==="granted"){try{let s=JSON.parse(U(w)??"null");s&&typeof s.id=="string"&&Date.now()-s.last<_&&(n??(n=s))}catch{}r=U(k),r||(r=M(),S(k,r))}}return a(),{consent:()=>t,setConsent(s){t=s,s==="denied"?(n=null,r=null,S(w,null),S(k,null)):s==="granted"&&(a(),n&&S(w,JSON.stringify(n)))},touch(s=Date.now(),u=!0){if(t==="denied")return null;let h=!n||s-n.last>=_;return u?(h&&(n={id:M(),last:s,...Q()}),n?(n.last=s,t==="granted"&&S(w,JSON.stringify(n)),n):null):h?null:n},adopt(s,u=Date.now()){t!=="denied"&&(n={id:s,last:u},t==="granted"&&S(w,JSON.stringify(n)))},current:()=>n,visitorId:()=>t==="granted"?r:null}}var q=["page_viewed","product_viewed","collection_viewed","search_performed","add_to_cart","remove_from_cart","cart_updated","checkout_started","checkout_step_viewed","customer_information_entered","shipping_selected","payment_started","payment_failed","payment_authorized","order_completed","order_cancelled","refund_created","session_heartbeat"],ue=new Set(q);var T={batchMaxEvents:100,batchMaxBytes:256*1024,propertiesMaxBytes:4*1024,propertiesMaxDepth:4,stringMaxLength:1024,urlMaxLength:2048,idMaxLength:128,sessionIdMaxLength:64,eventNameMaxLength:64,searchQueryMaxLength:100,orderMaxItems:100};function $(e){return new Intl.NumberFormat("en",{style:"currency",currency:e}).resolvedOptions().maximumFractionDigits??2}function W(e,t){let n=t.toUpperCase(),r=10**$(n);return{amount:Math.round(e*r),currency:n}}function X(e){return e.amount/10**$(e.currency)}var Y=5,Z=6e4,G=60*1024,j=(e,...t)=>{e&&console.warn("[storewatch]",...t)};function F(e){let t=[],n,r=!1,a=0,s=0,u=i=>JSON.stringify({key:e.publicKey,sentAt:new Date().toISOString(),batch:i});function h(i=e.flushIntervalMs){n===void 0&&(n=setTimeout(()=>(n=void 0,void g()),i))}async function g(){if(r||t.length===0)return;r=!0;let i=t.splice(0,T.batchMaxEvents),f=!1;try{let c=await fetch(e.endpoint,{method:"POST",body:u(i),headers:{"content-type":"text/plain;charset=UTF-8"},keepalive:!0,credentials:"omit"});f=c.status===429||c.status>=500,!f&&c.status>=400&&j(e.debug,`ingest rejected batch (${c.status})`)}catch(c){f=!0,j(e.debug,"ingest unreachable",c)}if(r=!1,f){s++,++a<Y?t=i.concat(t).slice(-e.maxQueue):a=0,h(Math.min(Z,1e3*2**s));return}s=0,a=0,t.length>0&&h(t.length>=e.flushAt?0:e.flushIntervalMs)}return{enqueue(i){t.push(i),t.length>e.maxQueue&&t.shift(),t.length>=e.flushAt&&s===0?g():h()},flush:g,beacon(){for(n!==void 0&&clearTimeout(n),n=void 0;t.length>0;){let i=t.splice(0,T.batchMaxEvents);for(;i.length>1&&u(i).length>G;)t.unshift(...i.splice(Math.ceil(i.length/2)));let f=u(i),c=!1;try{c=navigator.sendBeacon(e.endpoint,new Blob([f],{type:"text/plain;charset=UTF-8"}))}catch{c=!1}c||fetch(e.endpoint,{method:"POST",body:f,keepalive:!0,credentials:"omit",headers:{"content-type":"text/plain;charset=UTF-8"}}).catch(()=>{})}},clear(){t=[],n!==void 0&&clearTimeout(n),n=void 0},size:()=>t.length}}var ee="0.2.0",I="_sws",te=5*6e4;function ne(e,t=Date.now()){let n=e?.match(/^([0-9a-f]{20})\.([0-9a-z]{1,11})$/);if(!n)return null;let r=t-parseInt(n[2],36);return r>=-6e4&&r<_?n[1]:null}var O={track(){},page(){},identify(){},setConsent(){},ids:()=>({sessionId:"",anonymousId:null}),linkUrl:e=>e,flush(){},shutdown(){}};function d(e,t,n){return(...r)=>{try{return t(...r)}catch(a){return e&&console.warn("[storewatch]",a),n}}}function H(e){if(typeof window>"u"||typeof document>"u")return O;if(!/^sw_pk_/.test(e.publicKey))return console.error("[storewatch] publicKey must be a public key (sw_pk_\u2026). Secret keys must never reach the browser."),O;let t=e.debug??!1,n=V(e.consent??"pending"),r=F({endpoint:e.endpoint??"/_sw/v1/batch",publicKey:e.publicKey,flushAt:e.flushAt??20,flushIntervalMs:e.flushIntervalMs??2e3,maxQueue:500,debug:t}),a=null,s="",u=Date.now();d(t,()=>{let o=new URL(location.href);if(!o.searchParams.has(I))return;let l=ne(o.searchParams.get(I));l&&!n.touch(Date.now(),!1)&&n.adopt(l),o.searchParams.delete(I),history.replaceState(history.state,"",o.pathname+o.search+o.hash)},void 0)();function h(o,l){let m=o!=="session_heartbeat";m&&(u=Date.now());let y=n.touch(Date.now(),m);if(!y)return null;let K=n.visitorId(),J=n.consent()==="granted";return{eventId:x(),name:o,timestamp:new Date().toISOString(),sessionId:y.id,...K?{anonymousId:K}:{},...J&&a?{customerId:a}:{},properties:l,context:{page:{url:location.href,path:location.pathname,title:document.title.slice(0,200),...y.referrer?{referrer:y.referrer}:{}},...y.utm?{utm:y.utm}:{},locale:navigator.language,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone,screen:{width:innerWidth,height:innerHeight},...e.currency?{currency:e.currency}:{},library:{name:"@storewatch/browser",version:ee},consent:n.consent()}}}let g=d(t,(o,l={})=>{let m=h(o,l);m&&r.enqueue(m)},void 0),i=d(t,(o={})=>{s=location.pathname;let l=e.pageType?.(location.pathname);g("page_viewed",{...l?{pageType:l}:{},...o})},void 0),f=!0,c=()=>{f&&location.pathname!==s&&i()},A=history.pushState,L=history.replaceState,N=function(...o){A.apply(this,o),c()},R=function(...o){L.apply(this,o),c()};(e.autoPageViews??!0)&&(history.pushState=N,history.replaceState=R,addEventListener("popstate",c));let z=e.presenceIdleMs??te,E=()=>{u=Date.now()},C=["pointerdown","pointermove","keydown","scroll","wheel","touchstart"];for(let o of C)addEventListener(o,E,{capture:!0,passive:!0});let B=setInterval(d(t,()=>{document.visibilityState==="visible"&&Date.now()-u<z&&n.current()&&g("session_heartbeat")},void 0),e.heartbeatIntervalMs??2e4),P=d(t,()=>{document.visibilityState==="hidden"?r.beacon():E()},void 0),D=d(t,()=>r.beacon(),void 0);return document.addEventListener("visibilitychange",P),addEventListener("pagehide",D),(e.autoPageViews??!0)&&i(),{track:g,page:i,identify:d(t,o=>{a=o},void 0),setConsent:d(t,o=>{n.setConsent(o),o==="denied"&&r.clear()},void 0),ids:d(t,()=>({sessionId:n.touch(Date.now(),!1)?.id??"",anonymousId:n.visitorId()}),{sessionId:"",anonymousId:null}),linkUrl:o=>{try{let l=n.touch(Date.now(),!1);if(!l)return o;let m=new URL(o,location.href);return m.searchParams.set(I,`${l.id}.${Date.now().toString(36)}`),m.toString()}catch(l){return t&&console.warn("[storewatch]",l),o}},flush:d(t,()=>{r.flush()},void 0),shutdown:d(t,()=>{f=!1,clearInterval(B);for(let o of C)removeEventListener(o,E,{capture:!0});r.beacon(),document.removeEventListener("visibilitychange",P),removeEventListener("pagehide",D),removeEventListener("popstate",c),history.pushState===N&&(history.pushState=A),history.replaceState===R&&(history.replaceState=L)},void 0)}}var p=null,re=100,b=[];function oe(e){if(p?.key===e.publicKey)return p.client;p?.client.shutdown(),p={key:e.publicKey,client:H(e)};let t=b;b=[];for(let[n,r]of t)p.client[n](...r);return p.client}var v=e=>(...t)=>{if(p)return p.client[e](...t);b.length<re&&b.push([e,t])},se={track:v("track"),page:v("page"),identify:v("identify"),setConsent:v("setConsent"),ids:()=>p?.client.ids()??O.ids(),linkUrl:e=>p?.client.linkUrl(e)??e,flush:v("flush"),shutdown:()=>p?.client.shutdown()};function ae(){return se}export{H as createStorewatch,ae as getStorewatch,oe as init,W as money,X as toMajor};
@@ -0,0 +1,24 @@
1
+ import type { OutgoingEvent } from "./types.js";
2
+ export type TransportOptions = {
3
+ endpoint: string;
4
+ publicKey: string;
5
+ flushAt: number;
6
+ flushIntervalMs: number;
7
+ maxQueue: number;
8
+ debug: boolean;
9
+ };
10
+ /**
11
+ * Batches events and delivers them without ever affecting the page: no
12
+ * exceptions, no unhandled rejections, bounded memory, bounded retries.
13
+ * Bodies are JSON sent as text/plain (a CORS "simple request", no preflight)
14
+ * with the public key in the body, which also makes sendBeacon possible.
15
+ */
16
+ export declare function createTransport(o: TransportOptions): {
17
+ enqueue(event: OutgoingEvent): void;
18
+ flush: () => Promise<void>;
19
+ /** Last-chance delivery when the page is hidden or unloading. */
20
+ beacon(): void;
21
+ clear(): void;
22
+ size: () => number;
23
+ };
24
+ export type Transport = ReturnType<typeof createTransport>;
@@ -0,0 +1,64 @@
1
+ import type { CanonicalEventName, CanonicalProperties } from "@storewatch/schema";
2
+ export type Consent = "pending" | "granted" | "denied";
3
+ export type OutgoingEvent = {
4
+ eventId: string;
5
+ name: string;
6
+ timestamp: string;
7
+ sessionId: string;
8
+ anonymousId?: string;
9
+ customerId?: string;
10
+ properties: Record<string, unknown>;
11
+ context: Record<string, unknown>;
12
+ };
13
+ /** Canonical events get their typed properties; anything else is a custom event. */
14
+ export type EventProperties<N extends string> = N extends CanonicalEventName ? CanonicalProperties[N] : Record<string, unknown>;
15
+ export type StorewatchOptions = {
16
+ /** Public key (sw_pk_…). Never put a secret key in the browser. */
17
+ publicKey: string;
18
+ /** Ingest endpoint. Default: same-origin proxy `/_sw/v1/batch` (see @storewatch/nextjs). */
19
+ endpoint?: string;
20
+ /**
21
+ * Initial consent. "pending" (default) tracks without storing anything on
22
+ * the device (cookieless); "granted" persists visitor and session IDs;
23
+ * "denied" sends nothing.
24
+ */
25
+ consent?: Consent;
26
+ /** Track a page view on load and on every client-side navigation. Default true. */
27
+ autoPageViews?: boolean;
28
+ /** Classify a path for `pageType` (e.g. "/products/x" → "product"). */
29
+ pageType?: (path: string) => string | undefined;
30
+ /** Default ISO-4217 currency added to event context. */
31
+ currency?: string;
32
+ heartbeatIntervalMs?: number;
33
+ /**
34
+ * A visible tab only counts as "online" if the shopper moved, scrolled,
35
+ * tapped or typed within this long. Default 5 minutes.
36
+ */
37
+ presenceIdleMs?: number;
38
+ flushAt?: number;
39
+ flushIntervalMs?: number;
40
+ /** Log delivery problems to the console. Default false. */
41
+ debug?: boolean;
42
+ };
43
+ export type Storewatch = {
44
+ track<N extends string>(name: N, properties?: EventProperties<N>): void;
45
+ page(properties?: EventProperties<"page_viewed">): void;
46
+ /** Attach the merchant's own customer ID. Only sent once consent is granted. */
47
+ identify(customerId: string | null): void;
48
+ setConsent(consent: Consent): void;
49
+ /** IDs for server-side correlation, e.g. stored in cart metadata by an adapter. */
50
+ ids(): {
51
+ sessionId: string;
52
+ anonymousId: string | null;
53
+ };
54
+ /**
55
+ * `url` with the current session attached, for a page that leaves the
56
+ * store and comes back (a payment provider's return URL). Without stored
57
+ * consent the session lives only in memory, so the full page load on
58
+ * return would otherwise start a new one. The receiving page picks the
59
+ * parameter up and removes it from the address bar.
60
+ */
61
+ linkUrl(url: string): string;
62
+ flush(): void;
63
+ shutdown(): void;
64
+ };
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@storewatch/browser",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Storewatch browser SDK: lightweight, consent-aware commerce tracking.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "dependencies": {
22
+ "@storewatch/schema": "0.2.0"
23
+ },
24
+ "devDependencies": {
25
+ "happy-dom": "^21.0.1",
26
+ "tsup": "^9.0.0",
27
+ "@storewatch/config": "0.0.0"
28
+ },
29
+ "keywords": [
30
+ "storewatch",
31
+ "analytics",
32
+ "ecommerce"
33
+ ],
34
+ "scripts": {
35
+ "build": "rm -rf dist && tsup && tsc -p tsconfig.build.json",
36
+ "typecheck": "tsc -p .",
37
+ "test": "vitest run",
38
+ "size": "node scripts/size.mjs"
39
+ }
6
40
  }