Skip to main content

Contributing to pulse-web

Definition​

This document explains how to build, run, test and release pulse-web, which conventions its commits and merge requests follow, and what reviewers check before a change is merged. It is written for engineers contributing to the SDK itself, not for teams integrating it.

Commands​

All commands run from the repository root. Most of them use nx affected or nx run-many under the hood, so expect output from several packages.

GoalCommand
Full build (packages as ESM and CJS, bundles, Worker)npm run build
Bundles only, written to .mf/kv/BUNDLES together with meta.jsonnpm run build:bundles
Rebuild the bundles on changenpm run watch:bundles
Local Worker on port 8585 (Miniflare)npm run serve:server
Unit tests for projects affected since origin/mainnpm test
Unit tests for all projectsnpm run test:unit
Coverage run (what CI runs)npm run test:coverage
E2E tests (Playwright); build the bundles firstnpm run test:e2e
Lint (affected projects)npm run lint
Format, or only check formattingnpm run format, npm run format:check

npm run build runs these steps in order:

  1. Clean the package dist folders.
  2. Build every package. Public packages and common use a custom tsc executor (tools/executors/tsc-with-transformers) that rewrites import extensions; internal packages use @nx/js:tsc.
  3. Build the CJS variants, followed by a package.json fix-up.
  4. Build the browser bundles (build:bundles).
  5. Bundle the Worker for production (rollup and terser).
  6. Remove the tsbuildinfo files.

Running the SDK locally​

The Cloudflare Worker that serves the SDK runs locally under Miniflare. It reads bundles from the local KV folder .mf/kv/BUNDLES, so build them first:

npm run build:bundles
npm run serve:server

The Worker listens on http://localhost:8585 and watches for bundle and meta.json changes. Load the SDK from http://localhost:8585/pulse/{app}/pulse.js in a test page. See SDK Delivery for the routes and how the Worker personalises a bundle.

tip

To exercise session recording and replay together with the SDK, run the autopilot collector and the tools portal alongside the Worker. The Pulse loader then loads the local autopilot bundle.

caution

A git worktree created inside the repository directory can make nx commands fail with duplicate project names, because nx scans the worktree's copy of every project. If that happens, add the worktree's path to a local, uncommitted .nxignore.

Tests​

Unit tests​

Unit tests use Jest 29 with ts-jest and jsdom. Spec files sit next to the code as *.spec.ts.

For anything that involves several tabs, storage or IndexedDB, use @pulse/testbed-node (NodeTestBrowsingContext) instead of hand-rolled mocks. It gives multi-page JSDOM tests with:

  • cookies shared between pages,
  • fake-indexeddb,
  • a BroadcastChannel polyfill,
  • a mock fetch.

This matters because the SDK assumes many tabs running over the same storage; see Identity and State.

E2E tests​

E2E specs live in packages/browser/tracker-e2e/src/ as *.e2e.spec.ts, with built-in events and attributes in subfolders. They cover events, the header, IndexedDB, sessions, tabs, transport, built-in events and built-in attributes. They use @pulse/testbed-browser:

  • TrackerInterceptor serves the prebuilt esnext all bundle listed in .mf/kv/BUNDLES/meta.json, with the dynamic.env.config placeholder replaced by the test config. The Worker is not involved.
  • If the placeholder is missing, the interceptor throws. That means your bundles are stale: run npm run build:bundles.
  • Captured /fetch requests are parsed into chunks, so tests assert on what actually goes over the wire.
  • The test defaults are debug: ['debugger'], batchTimeMS: 50 and batchSize: 20.
npm run build:bundles && npm run test:e2e
note

Don't add a build step to the e2e script. Build the bundles locally; CI builds before it tests.

Coverage gap: no specs cover the anonymous or page-session modes. Add them when you touch mode-dependent code.

CI pipeline​

The GitLab pipeline (.gitlab-ci.yml) has five stages: verify, release, deploydev, automation and deployprod.

JobWhat it runs
testnpm ci, npm run build, test:coverage and test:e2e; uploads junit and cobertura reports
styleAffected lint and format:check
release (manual)semantic-release; fails if no new version is produced
publish_npm (manual)npm Trusted Publishing (OIDC) of @picsart/pulse-common, then @picsart/pulse-mini
deploydev, deploy_stage, deploy_prod (manual; prod only from main)npm run deploy (wrangler)
automation (manual)Triggers the external QA automation pipeline

Releases​

Versions are decided by semantic-release with the Angular preset, from the commit types since the last release (.releaserc.yml):

Commit typeRelease
featMinor
fix, perf, refactor, deps, build, ci, revertPatch
Any type with a breaking changeMajor
chore, test, docsNo release
caution

A fix committed as chore, test or docs never ships. Pick the type by what the change does for users.

The release job then:

  1. sets the versions,
  2. builds,
  3. replaces every __version__ placeholder in the dists (scripts/bake-version.mjs),
  4. publishes the packages to the GitLab npm registry,
  5. uploads the bundles to KV (npm run upload:bundles).

Release branches are main, master, N.x, and the prerelease branches next, beta, alpha and next-release.

Publishing to npm​

Two packages are also published to the public npm registry. scripts/lib/publish-npm-package.mjs renames @pulse/common and @pulse/mini to @picsart/pulse-common and @picsart/pulse-mini, both in package.json and in the emitted code, and refuses to publish if any placeholder remains.

Publishing uses OIDC Trusted Publishing only. Don't reintroduce a long-lived NPM_PUBLISH_TOKEN. The CI variable NPM_ID_TOKEN must keep that exact name, and npm 11.5 or later is required.

Conventions​

  • Commit messages follow Conventional Commits: type(scope): subject. The scope is optional. Common scopes are tracker, settings, server, mini, shared, loader, core, rum, queue, origins and consent. The body explains why: the problem first, then the fix.
  • Tickets are Jira DK-xxxx, occasionally AM- for infrastructure.
  • Branch names are short kebab-case topics, for example device-id-reset or adaptive-settings-timeout.
  • Merging: merge requests are squash-merged or rebased, so history stays linear. A series of small reviewed commits is fine.
  • Code style: Prettier with 4 spaces, single quotes, width 100 and trailing commas. File names are kebab-case with a role suffix (*.service.ts, *.plugin.ts, *.pipe.ts).
fix(tracker): keep the event that overflows a batch

The last event of a full batch was dropped at unload because ...

Merge request description​

Every merge request description answers four questions:

  • What was done? Concrete changes, one bullet per change, naming the files or packages touched.
  • Why are the changes necessary? The problem this removes or the behaviour it enables, with links to incidents or data.
  • How? The approach: key design choices, trade-offs, and anything a reviewer must know to read the diff.
  • Ticket reference. A link to the DK-xxxx ticket.

GitLab pre-fills these headings for every new merge request from the default template .gitlab/merge_request_templates/Default.md. The template also carries a checklist: conventional commit types (and that chore, test and docs don't release), green format, lint and unit tests, green e2e when packages/browser, packages/common or the loader changed, test coverage for new behaviour, a completed review, checked cross-repository contracts with linked counterpart changes, and readiness for production.

Review checklist​

Pulse runs inside other teams' pages, on every page load, in every tab, on old browsers and in webviews. Reviewers check each change against the following areas. Design decisions behind these rules are on Design Decisions and Common Pitfalls.

Package layering​

  • Dependencies point downward only: common, then browser/shared, then browser/tracker-shared, then tracker and mini; settings sits on browser/shared; loader composes everything. See Packages and DI.
  • tracker, settings and mini never import each other. Shared code moves down to browser/shared or tracker-shared.
  • @pulse/browser, @pulse/core and @pulse/mini depend only on common. Importing a @pulse/browser-* package would bundle the tracker into integrators' apps.
  • common stays environment-neutral: no window, document or navigator access, because it also runs in Node and Workers.
  • Cross-package imports use the package name, never a relative path into another package. No new cycles.

The lint configuration does not enforce this layering, so review does.

Contracts​

Any change to a wire field, id format, storage key, global, postMessage type or public npm API follows the checklist on Cross-Repository Contracts: counterpart named, old format kept working, event definitions added before release.

Host-page safety​

  • SDK code never throws into page code, never blocks the main thread, and patches no new globals or prototypes.
  • Callbacks invoked by the page or browser (listeners, observers, postMessage handlers, timers) catch their errors.
  • Another window is only read after a same-origin check.

Bundle size and ES5​

  • Every byte ships to every page. A new runtime dependency in a browser package or common needs an explicit justification.
  • The es5 build must still work. APIs that core-js can't polyfill (WeakRef, BroadcastChannel, navigator.locks, CompressionStream, a resizable ArrayBuffer) are feature-detected and have a fallback.

Cross-tab correctness and storage​

  • Code assumes N tabs over shared storage. It uses PulseSyncStorage change streams, LockManager and tab ownership of persisted records.
  • Storage goes through the storage tokens. Foreign keys (paa-did, pulse-state, w2m_meta_info, OptanonConsent) use unprefixed storage.
  • Every IndexedDB operation is time-boxed and has a path that works without IndexedDB.

Deduplication and delivery​

  • Built-in events can't fire twice: across tabs, on re-init, on session renewal or on consent changes. Use HashStore, a lock, or report on transitions only.
  • Entry points stay idempotent.
  • Delivery flushes on visibilitychange, never relies on unload, and stays within the 60 KB keepalive budget.

New message-producing code goes through the queue's consent gate. Only genuinely consent-related events use Force priority. New cookies and new data collection respect TrackingConsentService.

Security and privacy​

  • Outgoing postMessage calls that carry identifiers use an explicit target origin, never '*'. Incoming handlers validate the origin and the payload shape.
  • SDK-rendered UI uses textContent and attribute setters, never innerHTML with untrusted data. Links built from URL or storage input accept only https: URLs.
  • Switches read from the URL or storage can only select among allowlisted values. They must never redirect traffic to an arbitrary host or disable consent.
  • New cookies set Secure, SameSite and the narrowest domain and path that works. Widening to the apex domain needs a reason.
  • No new automatic collection of PII: form inputs, text content, emails, phone numbers or payment data. URL-bearing fields must not add exposure.
  • Merges of external objects guard __proto__, constructor and prototype; JSON.parse of external data is guarded and shape-checked.
  • No secrets in code or CI; npm publishing stays on OIDC.

Code quality and tests​

  • Services are registered through ProviderBuilder; stateful singletons use provideInRoot: true; onInit does no heavy work.
  • Every RxJS subscription has an end; an Observable is never used as a boolean.
  • No new any; data from storage, cookies, URLs or the network is validated.
  • Changed behaviour has colocated unit tests, @pulse/testbed-node for cross-tab and storage behaviour, and an e2e spec when the change is visible on the wire.