Topsort's analytics.js is a browser-side JavaScript library that auto-detects DOM events (impressions, clicks, and purchases) via data-ts-* HTML attributes, deduplicates and queues them with retry logic, and sends them to the Topsort Analytics API using @topsort/sdk. It is published to npm as @topsort/analytics.js.
- Never commit directly to
main. All changes go through PRs from a dedicated branch. - Branch names should be descriptive (e.g.,
feat/add-google-environment,fix/merge-pagination-offset). - Large changes must be broken into stacked PRs — each PR should be independently reviewable and represent a single logical unit of work (e.g., one PR adds the config, the next adds the validation schema, the next adds tests). Avoid monolithic PRs that touch many unrelated things at once.
- Each PR in a stack should be based on the previous branch, not
main, so they can be reviewed and merged in order. - Admin override (
gh pr merge --admin) is only appropriate to bypass the review requirement when all CI checks pass. Never use it to force-merge a PR with failing CI — fix the failures first. Before using--admin, check whether the repo allows it (e.g.gh api repos/{owner}/{repo}or branch protection settings). If admin override is not permitted or you cannot verify it is, do not merge — ask the user instead. - Keep branches up to date with
mainbefore merging — rebase or mergemaininto your branch to resolve conflicts locally, not in the merge commit. - Use Conventional Commits for all commit messages (e.g.,
feat:,fix:,chore:,docs:,refactor:,test:). - Never approve or merge a PR that has unresolved review comments — address or explicitly dismiss each one first. Always check nested/threaded comments (e.g. replies under bot comments) as they may contain substantive issues not visible at the top level.
- Before merging with
--admin, wait at least 5 minutes after the PR is opened. This gives Bugbot and other async bots time to post their comments. After the wait, check all PR comments (including nested/threaded replies) for unresolved issues before merging.
| Layer | Tool |
|---|---|
| Language | TypeScript (strict mode, ES6 target) |
| Runtime | Browser (DOM APIs, window.TS global config) |
| Package manager | pnpm (v10.22.0, declared in packageManager field) |
| Bundler | Vite (builds UMD, ESM, and IIFE formats) |
| Testing | Vitest with jsdom environment |
| HTTP mocking | MSW (Mock Service Worker) |
| Linting/Formatting | Biome (v2.3.5) |
| Coverage | @vitest/coverage-v8, reported to Codecov |
| SDK dependency | @topsort/sdk (the only runtime dependency) |
| Node version | >=20.0.0 |
| Command | Description |
|---|---|
pnpm install |
Install dependencies |
pnpm run build |
Build UMD + ESM bundles, then IIFE bundle |
pnpm run test |
Run unit tests with coverage (Vitest) |
pnpm run lint |
Run Biome checks (linting + formatting) |
pnpm run lint:fix |
Auto-fix Biome lint issues |
pnpm run lint:ci |
Run Biome in CI mode (fails on any issue) |
pnpm run format |
Check formatting with Biome |
pnpm run format:fix |
Auto-fix formatting with Biome |
pnpm run types:check |
Run tsc --noemit to type-check without emitting |
pnpm run test:e2e |
Build + run E2E test server (Express-based, manual) |
src/
detector.ts # Main entry point: DOM observation, event detection, API dispatch
queue.ts # Persistent event queue with retry + exponential backoff
store.ts # Storage abstraction (LocalStorage with MemoryStore fallback, BidStore for session)
set.ts # Utility to truncate a Set to a max size (keeps newest entries)
index.d.ts # Public type re-exports
*.test.ts # Co-located unit tests for each module
mocks/
api-server.ts # Express server for manual E2E testing
tests/
browser-test.ts # Browser-based E2E test runner
components.tsx # React test components (used with react-router-dom)
test.html # HTML harness for E2E tests
real_e2e.html # Manual E2E test page
@types/
global.d.ts # Global type declarations (window.TS interface)
-
Initialization (
detector.ts): OnDOMContentLoaded(or immediately if the document is already loaded), the library readswindow.TSconfig (token, url, optional getUserId). It scans the existing DOM for elements matching[data-ts-product],[data-ts-action],[data-ts-items], or[data-ts-resolved-bid]. -
Detection: Two mechanisms detect events:
- IntersectionObserver (threshold 0.5): Fires
Impressionevents when a product element becomes 50% visible. Each element is unobserved after its first impression. - MutationObserver: Watches for new child elements and attribute changes (
data-ts-product,data-ts-action,data-ts-items,data-ts-resolved-bid) to detect dynamically added or modified products. - Click listeners: Attached to product elements (or their
[data-ts-clickable]children for granular control). Clicks on banners store theresolvedBidIdin session storage (BidStore) for cross-page attribution viadata-ts-resolved-bid="inherit". - Purchase events: Triggered when elements with
data-ts-action="purchase"are detected; item data is parsed fromdata-ts-itemsJSON attribute.
- IntersectionObserver (threshold 0.5): Fires
-
Deduplication: A
Set<string>of seen event keys (page + type + product + bid + items) prevents duplicate events. The set is capped at 2,500 entries, dropping oldest first (truncateSet). -
Queuing (
queue.ts): Events are appended to aQueuebacked byLocalStorageStore(falls back toMemoryStoreif localStorage is unavailable). The queue:- Caps at 250 entries (drops oldest on overflow).
- Processes up to 25 events per batch.
- Uses exponential backoff for retries (max 3 retries).
- High-priority events (purchases) are processed immediately; low-priority events are batched with a 250ms delay.
-
Dispatch: The
processorfunction creates aTopsortClientfrom@topsort/sdkand callsreportEvent()for each queued event. On success, the event is removed from the queue. On retryable failure, it is kept for retry. On permanent failure or after max retries, it is dropped. -
User ID: Managed via a cookie (
tsuidby default, configurable viawindow.TS.cookieName). Can be overridden by providingwindow.TS.getUserId. The library also exposessetUserIdandresetUserIdonwindow.TS.
dist/ts.js— UMD bundle (default forrequire())dist/ts.mjs— ESM bundle (default forimport)dist/ts.iife.js— IIFE bundle for direct<script>inclusion in legacy environments
- Formatting: Biome with 2-space indent, 100-char line width, space indent style.
- Imports: Auto-organized by Biome (
organizeImports: "on"). - Linting: Biome linter enabled;
noExplicitAnyandnoDocumentCookierules are disabled. - TypeScript: Strict mode (
alwaysStrict,strictNullChecks,noImplicitAny,noImplicitReturns,noUncheckedIndexedAccess,noUnusedLocals,noUnusedParameters). - Naming: Files use kebab-case. Test files are co-located with source and named
<module>.<scenario>.test.ts. - No external runtime dependencies other than
@topsort/sdk— the library must remain lightweight for browser embedding.
- Framework: Vitest with
jsdomenvironment (configured invite.config.ts). - Pattern: Each test file sets up
window.TS = { token: "token" }, injects HTML intodocument.body, dynamically imports./detector, and asserts on customtopsortevents dispatched on DOM nodes. - Deduplication: Each test file runs in isolation (separate Vitest workers), so the
seenEventsset and module state are fresh per file. - Queue tests (
queue.test.ts): Usevi.useFakeTimers()to control timing for exponential backoff and delayed processing. - Coverage: Generated by
@vitest/coverage-v8and uploaded to Codecov. - Adding a test: Create a new
src/<module>.<scenario>.test.tsfile. Set upwindow.TS, inject DOM, import./detector, simulate user interactions, and assert on thetopsortCustomEvent details. - E2E tests: Manual process — run
pnpm run test:e2e, open the served HTML page, and verify results visually.
Three parallel jobs:
- lint —
pnpm install+pnpm run lint:ci+pnpm run types:check - test —
pnpm install+pnpm run test+ Codecov upload - format (Biome Lint) — Uses
biomejs/setup-biome@v2action +biome ci --reporter=github
Concurrency: grouped by workflow + ref, cancels in-progress runs on new pushes.
- Install + lint + test + type-check + build
- Publish to npm via
npm publish --no-git-checks(usesNPM_PUBLISH_TOKENsecret)
- Monthly updates for npm dev dependencies and GitHub Actions.
- Module side effects: Importing
detector.tsimmediately starts DOM observation. Tests must set upwindow.TSand inject DOM HTML before the dynamicimport("./detector")call. - IntersectionObserver in jsdom: jsdom does not implement
IntersectionObserver, so in the test environment, impressions fire synchronously on DOM insertion instead of on visibility. This means test behavior differs from real browser behavior for impression timing. - Cookie parsing: The user ID cookie parser uses a regex that expects the cookie name at the start or after a semicolon. Custom
cookieNamevalues with special regex characters could cause issues. - Queue persistence: The queue persists to
localStorageunder keyts-q. If localStorage is unavailable (e.g., private browsing in some browsers), it falls back to an in-memory store, meaning events are lost on page refresh. window.TS.loadedguard: Thestart()function setswindow.TS.loaded = trueto prevent double initialization. If you need to re-initialize in tests, you must reset this flag.- IIFE build bundles all dependencies: The IIFE format (
dist/ts.iife.js) inlines@topsort/sdk, while the ESM/UMD formats treat it as an external dependency.