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.
| Goal | Command |
|---|---|
| Full build (packages as ESM and CJS, bundles, Worker) | npm run build |
Bundles only, written to .mf/kv/BUNDLES together with meta.json | npm run build:bundles |
| Rebuild the bundles on change | npm run watch:bundles |
| Local Worker on port 8585 (Miniflare) | npm run serve:server |
Unit tests for projects affected since origin/main | npm test |
| Unit tests for all projects | npm run test:unit |
| Coverage run (what CI runs) | npm run test:coverage |
| E2E tests (Playwright); build the bundles first | npm run test:e2e |
| Lint (affected projects) | npm run lint |
| Format, or only check formatting | npm run format, npm run format:check |
npm run build runs these steps in order:
- Clean the package
distfolders. - Build every package. Public packages and
commonuse a customtscexecutor (tools/executors/tsc-with-transformers) that rewrites import extensions; internal packages use@nx/js:tsc. - Build the CJS variants, followed by a
package.jsonfix-up. - Build the browser bundles (
build:bundles). - Bundle the Worker for production (rollup and terser).
- Remove the
tsbuildinfofiles.
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.
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.
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:
TrackerInterceptorserves the prebuilt esnextallbundle listed in.mf/kv/BUNDLES/meta.json, with thedynamic.env.configplaceholder 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
/fetchrequests are parsed into chunks, so tests assert on what actually goes over the wire. - The test defaults are
debug: ['debugger'],batchTimeMS: 50andbatchSize: 20.
npm run build:bundles && npm run test:e2e
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.
| Job | What it runs |
|---|---|
test | npm ci, npm run build, test:coverage and test:e2e; uploads junit and cobertura reports |
style | Affected 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 type | Release |
|---|---|
feat | Minor |
fix, perf, refactor, deps, build, ci, revert | Patch |
| Any type with a breaking change | Major |
chore, test, docs | No release |
A fix committed as chore, test or docs never ships. Pick the type by what the change does for users.
The release job then:
- sets the versions,
- builds,
- replaces every
__version__placeholder in the dists (scripts/bake-version.mjs), - publishes the packages to the GitLab npm registry,
- 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 aretracker,settings,server,mini,shared,loader,core,rum,queue,originsandconsent. The body explains why: the problem first, then the fix. - Tickets are Jira
DK-xxxx, occasionallyAM-for infrastructure. - Branch names are short kebab-case topics, for example
device-id-resetoradaptive-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-xxxxticket.
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, thenbrowser/shared, thenbrowser/tracker-shared, thentrackerandmini;settingssits onbrowser/shared;loadercomposes everything. See Packages and DI. tracker,settingsandmininever import each other. Shared code moves down tobrowser/sharedortracker-shared.@pulse/browser,@pulse/coreand@pulse/minidepend only oncommon. Importing a@pulse/browser-*package would bundle the tracker into integrators' apps.commonstays environment-neutral: nowindow,documentornavigatoraccess, 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,
postMessagehandlers, 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
commonneeds an explicit justification. - The
es5build must still work. APIs that core-js can't polyfill (WeakRef,BroadcastChannel,navigator.locks,CompressionStream, a resizableArrayBuffer) are feature-detected and have a fallback.
Cross-tab correctness and storage
- Code assumes N tabs over shared storage. It uses
PulseSyncStoragechange streams,LockManagerand 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 onunload, and stays within the 60 KB keepalive budget.
Consent
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
postMessagecalls that carry identifiers use an explicit target origin, never'*'. Incoming handlers validate the origin and the payload shape. - SDK-rendered UI uses
textContentand attribute setters, neverinnerHTMLwith untrusted data. Links built from URL or storage input accept onlyhttps: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,SameSiteand 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__,constructorandprototype;JSON.parseof 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 useprovideInRoot: true;onInitdoes 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-nodefor cross-tab and storage behaviour, and an e2e spec when the change is visible on the wire.