@storewatch/browser 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 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,16 @@
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
+ Use your **public** key here. Secret keys belong on your server
16
+ (`@storewatch/node`). On Next.js, use `@storewatch/nextjs`.
@@ -0,0 +1,10 @@
1
+ import type { Storewatch, StorewatchOptions } from "./types.js";
2
+ export declare function createStorewatch(options: StorewatchOptions): Storewatch;
3
+ /**
4
+ * Idempotent page-level singleton: calling `init` twice (React StrictMode,
5
+ * remounts, HMR) returns the same client instead of double-tracking.
6
+ */
7
+ export declare function init(options: StorewatchOptions): Storewatch;
8
+ export declare function getStorewatch(): Storewatch;
9
+ /** Test hook: forget the singleton and any queued calls. */
10
+ export declare function resetForTests(): void;
@@ -0,0 +1,31 @@
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
+ current: () => Session | null;
28
+ visitorId: () => string | null;
29
+ };
30
+ export type Identity = ReturnType<typeof createIdentity>;
31
+ 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 b(t=Date.now()){let e=crypto.getRandomValues(new Uint8Array(16)),n=t;for(let o=5;o>=0;o--)e[o]=n&255,n=Math.floor(n/256);e[6]=e[6]&15|112,e[8]=e[8]&63|128;let r=Array.from(e,o=>o.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 _(){return b().replace(/-/g,"").slice(-20)}var w="sw_s",x="sw_v",N=30*6e4,R=t=>{try{return localStorage.getItem(t)}catch{return null}},g=(t,e)=>{try{e===null?localStorage.removeItem(t):localStorage.setItem(t,e)}catch{}};function F(){let t={};try{let e=new URLSearchParams(location.search),n={};for(let r of["source","medium","campaign","term","content"]){let o=e.get(`utm_${r}`);o&&(n[r]=o.slice(0,200))}Object.keys(n).length>0&&(t.utm=n),document.referrer&&(t.referrer=document.referrer)}catch{}return t}function L(t){let e=t,n=null,r=null;function o(){if(e==="granted"){try{let s=JSON.parse(R(w)??"null");s&&typeof s.id=="string"&&Date.now()-s.last<N&&(n??(n=s))}catch{}r=R(x),r||(r=_(),g(x,r))}}return o(),{consent:()=>e,setConsent(s){e=s,s==="denied"?(n=null,r=null,g(w,null),g(x,null)):s==="granted"&&(o(),n&&g(w,JSON.stringify(n)))},touch(s=Date.now(),h=!0){if(e==="denied")return null;let l=!n||s-n.last>=N;return h?(l&&(n={id:_(),last:s,...F()}),n?(n.last=s,e==="granted"&&g(w,JSON.stringify(n)),n):null):l?null:n},current:()=>n,visitorId:()=>e==="granted"?r:null}}var H=["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"],ne=new Set(H);var I={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 P(t){return new Intl.NumberFormat("en",{style:"currency",currency:t}).resolvedOptions().maximumFractionDigits??2}function $(t,e){let n=e.toUpperCase(),r=10**P(n);return{amount:Math.round(t*r),currency:n}}function z(t){return t.amount/10**P(t.currency)}var B=5,Q=6e4,J=60*1024,K=(t,...e)=>{t&&console.warn("[storewatch]",...e)};function V(t){let e=[],n,r=!1,o=0,s=0,h=i=>JSON.stringify({key:t.publicKey,sentAt:new Date().toISOString(),batch:i});function l(i=t.flushIntervalMs){n===void 0&&(n=setTimeout(()=>(n=void 0,void m()),i))}async function m(){if(r||e.length===0)return;r=!0;let i=e.splice(0,I.batchMaxEvents),u=!1;try{let c=await fetch(t.endpoint,{method:"POST",body:h(i),headers:{"content-type":"text/plain;charset=UTF-8"},keepalive:!0,credentials:"omit"});u=c.status===429||c.status>=500,!u&&c.status>=400&&K(t.debug,`ingest rejected batch (${c.status})`)}catch(c){u=!0,K(t.debug,"ingest unreachable",c)}if(r=!1,u){s++,++o<B?e=i.concat(e).slice(-t.maxQueue):o=0,l(Math.min(Q,1e3*2**s));return}s=0,o=0,e.length>0&&l(e.length>=t.flushAt?0:t.flushIntervalMs)}return{enqueue(i){e.push(i),e.length>t.maxQueue&&e.shift(),e.length>=t.flushAt&&s===0?m():l()},flush:m,beacon(){for(n!==void 0&&clearTimeout(n),n=void 0;e.length>0;){let i=e.splice(0,I.batchMaxEvents);for(;i.length>1&&h(i).length>J;)e.unshift(...i.splice(Math.ceil(i.length/2)));let u=h(i),c=!1;try{c=navigator.sendBeacon(t.endpoint,new Blob([u],{type:"text/plain;charset=UTF-8"}))}catch{c=!1}c||fetch(t.endpoint,{method:"POST",body:u,keepalive:!0,credentials:"omit",headers:{"content-type":"text/plain;charset=UTF-8"}}).catch(()=>{})}},clear(){e=[],n!==void 0&&clearTimeout(n),n=void 0},size:()=>e.length}}var q="0.1.0",E={track(){},page(){},identify(){},setConsent(){},ids:()=>({sessionId:"",anonymousId:null}),flush(){},shutdown(){}};function d(t,e,n){return(...r)=>{try{return e(...r)}catch(o){return t&&console.warn("[storewatch]",o),n}}}function D(t){if(typeof window>"u"||typeof document>"u")return E;if(!/^sw_pk_/.test(t.publicKey))return console.error("[storewatch] publicKey must be a public key (sw_pk_\u2026). Secret keys must never reach the browser."),E;let e=t.debug??!1,n=L(t.consent??"pending"),r=V({endpoint:t.endpoint??"/_sw/v1/batch",publicKey:t.publicKey,flushAt:t.flushAt??20,flushIntervalMs:t.flushIntervalMs??2e3,maxQueue:500,debug:e}),o=null,s="";function h(a,y){let f=n.touch(Date.now(),a!=="session_heartbeat");if(!f)return null;let C=n.visitorId(),j=n.consent()==="granted";return{eventId:b(),name:a,timestamp:new Date().toISOString(),sessionId:f.id,...C?{anonymousId:C}:{},...j&&o?{customerId:o}:{},properties:y,context:{page:{url:location.href,path:location.pathname,title:document.title.slice(0,200),...f.referrer?{referrer:f.referrer}:{}},...f.utm?{utm:f.utm}:{},locale:navigator.language,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone,screen:{width:innerWidth,height:innerHeight},...t.currency?{currency:t.currency}:{},library:{name:"@storewatch/browser",version:q},consent:n.consent()}}}let l=d(e,(a,y={})=>{let f=h(a,y);f&&r.enqueue(f)},void 0),m=d(e,(a={})=>{s=location.pathname;let y=t.pageType?.(location.pathname);l("page_viewed",{...y?{pageType:y}:{},...a})},void 0),i=!0,u=()=>{i&&location.pathname!==s&&m()},c=history.pushState,M=history.replaceState,T=function(...a){c.apply(this,a),u()},k=function(...a){M.apply(this,a),u()};(t.autoPageViews??!0)&&(history.pushState=T,history.replaceState=k,addEventListener("popstate",u));let U=setInterval(d(e,()=>{document.visibilityState==="visible"&&n.current()&&l("session_heartbeat")},void 0),t.heartbeatIntervalMs??2e4),O=d(e,()=>{document.visibilityState==="hidden"&&r.beacon()},void 0),A=d(e,()=>r.beacon(),void 0);return document.addEventListener("visibilitychange",O),addEventListener("pagehide",A),(t.autoPageViews??!0)&&m(),{track:l,page:m,identify:d(e,a=>{o=a},void 0),setConsent:d(e,a=>{n.setConsent(a),a==="denied"&&r.clear()},void 0),ids:d(e,()=>({sessionId:n.touch(Date.now(),!1)?.id??"",anonymousId:n.visitorId()}),{sessionId:"",anonymousId:null}),flush:d(e,()=>{r.flush()},void 0),shutdown:d(e,()=>{i=!1,clearInterval(U),r.beacon(),document.removeEventListener("visibilitychange",O),removeEventListener("pagehide",A),removeEventListener("popstate",u),history.pushState===T&&(history.pushState=c),history.replaceState===k&&(history.replaceState=M)},void 0)}}var p=null,W=100,v=[];function X(t){if(p?.key===t.publicKey)return p.client;p?.client.shutdown(),p={key:t.publicKey,client:D(t)};let e=v;v=[];for(let[n,r]of e)p.client[n](...r);return p.client}var S=t=>(...e)=>{if(p)return p.client[t](...e);v.length<W&&v.push([t,e])},Y={track:S("track"),page:S("page"),identify:S("identify"),setConsent:S("setConsent"),ids:()=>p?.client.ids()??E.ids(),flush:S("flush"),shutdown:()=>p?.client.shutdown()};function Z(){return Y}export{D as createStorewatch,Z as getStorewatch,X as init,$ as money,z 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,51 @@
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
+ flushAt?: number;
34
+ flushIntervalMs?: number;
35
+ /** Log delivery problems to the console. Default false. */
36
+ debug?: boolean;
37
+ };
38
+ export type Storewatch = {
39
+ track<N extends string>(name: N, properties?: EventProperties<N>): void;
40
+ page(properties?: EventProperties<"page_viewed">): void;
41
+ /** Attach the merchant's own customer ID. Only sent once consent is granted. */
42
+ identify(customerId: string | null): void;
43
+ setConsent(consent: Consent): void;
44
+ /** IDs for server-side correlation, e.g. stored in cart metadata by an adapter. */
45
+ ids(): {
46
+ sessionId: string;
47
+ anonymousId: string | null;
48
+ };
49
+ flush(): void;
50
+ shutdown(): void;
51
+ };
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.1.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.1.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
  }