Monorepo Packages and Dependency Injection
Definition
This document explains how the pulse-web monorepo is split into packages, how those packages depend on each other, and how the small custom dependency-injection (DI) container wires them together at runtime. It also describes the configuration model that flows through the container.
Why a monorepo with layers
Pulse ships several products from one codebase: the full browser tracker, a lightweight "mini" tracker for miniapps, remote settings, and wrappers for React, Node.js, Express and NestJS. They share a lot of code — storage, cookies, locks, HTTP, device and tab identity — so the code lives in one Nx monorepo and is split into packages by responsibility.
Workspaces are declared in the root package.json. Packages resolve each other through npm workspace symlinks (node_modules/@pulse/* points into packages/...), and most packages' main field points straight at ./src/index.ts. Note that tsconfig.base.json has no paths mapping; Jest maps @pulse/* separately in jest.preset.js.
Layers
Dependencies point downward only. A package may import from the layers below it, never from the layers above. This keeps @pulse/common free of browser concerns and lets the loader be the single place where everything is composed.
public/* what integrators install (@pulse/browser, react, core, server, express, nest, mini)
browser/loader webpack entries that compose modules into the served bundles
browser/tracker browser/settings browser/mini
browser/tracker-shared (PluginHost, PipeHost, RUM collectors)
browser/shared storage, cookies, locks, HTTP, device/tab id, state, IndexedDB, debug UI
common DI, config models, SDK abstract types, global proxy, utils (no deps)
Package map
| Directory | Package | Role |
|---|---|---|
apps/server | nx server | Cloudflare Worker (Cloudflow router) that serves bundles from KV. See SDK Delivery |
packages/common | @pulse/common (npm: @picsart/pulse-common) | DI, configuration models, abstract SDK types, createGlobalProxy, storage and lock abstractions |
packages/browser/shared | @pulse/browser-shared | SharedModule: storages, cookies, LockManager, HttpService, DeviceIdService, TabManagerService, StateService, IndexedDBService, HashStore, debug controls |
packages/browser/tracker-shared | @pulse/browser-tracker-shared | TrackerSharedModule: plugin and pipe hosts, RUM, DOM path; shared by the tracker and mini |
packages/browser/tracker | @pulse/browser-tracker | TrackerModule: the full tracker (session, queue, batching, plugins) |
packages/browser/settings | @pulse/browser-settings | SettingsModule: remote settings, experiments, segments, cookie sync |
packages/browser/mini | @pulse/browser-mini | PulseMiniModule: tracker for miniapps; the host platform provides the transport |
packages/browser/loader | @pulse/browser-loader | Bundle entries (all-in-one.bundle.ts, mini.bundle.ts), autopilot.loader.ts, event-forwarding.loader.ts, the meta-generator nx executor |
packages/browser/tracker-e2e | nx tracker-e2e | Playwright end-to-end specs |
packages/public/browser | @pulse/browser | initialize() / initializeScoped(); injects the module/nomodule script tags |
packages/public/core | @pulse/core | export const pulse = createGlobalProxy(), a proxy that works in the browser and on the server |
packages/public/react | @pulse/react | PulseProvider, usePulseSettings, useLazyPulseSettings, usePulseState, usePulseSegments, usePulseExperiments, withPulseSettings |
packages/public/server | @pulse/server | createClient() for Node and Workers. It uses no DI; it is plain classes |
packages/public/express | @pulse/express | withPulse() middleware (exported as usePulse) on top of @pulse/server |
packages/public/nest | @pulse/nest | PulseModule.forRoot() plus middleware on top of @pulse/server |
packages/public/mini | @pulse/mini (npm: @picsart/pulse-mini) | npm wrapper that injects the mini loader script |
packages/testing/testbed-node | — | JSDOM harness (fake-indexeddb, BroadcastChannel and Storage polyfills, shared cookie jar) |
packages/testing/testbed-browser | — | Playwright harness that serves the built bundle with a test config |
dev/* | nx dev apps | Playgrounds used by the serve:* scripts |
tools/executors/ | — | tsc-with-transformers (ts-patch, for .js extension rewriting) and nx-watch |
Bundle composition
The two served bundles are composed in packages/browser/loader/src/bundles/ from the same building blocks:
| Bundle | Modules, in import order |
|---|---|
all | Core → Shared → TrackerShared → Tracker → Settings |
mini | Core → Shared → TrackerShared → PulseMiniModule (no settings, session, queue or HTTP) |
The DI container
The container lives in packages/common/src/di/. It is custom and deliberately small: it exists so that the tracker, the mini tracker and the settings module can share services without shipping a heavy DI framework in a bundle that runs on every page.
Tokens and providers
InjectionToken<T, Name>(token.ts) identifies a non-class dependency. Identity is by object reference, so two tokens with the same name are still different tokens. Classes and abstract classes are also valid tokens; configuration classes such asTrackerConfigurationsare used this way.Providerhas the shape{provide, useClass?, useFactory?, useValue?, deps?, provideInRoot?}. When several strategies are set, the precedence isuseValue, thenuseClass, thenuseFactory, then "provideis itself a class".ProviderBuilder(provider-builder.ts) is the fluent API used inside.provide(b => …):useClass(token, Class, [deps], provideInRoot?)useFactory(token, fn, [deps], provideInRoot?)useValue(token, value, provideInRoot?)
Dependency arrays are type-checked against the constructor or factory parameters, so a wrong or missing dependency is a compile error rather than a runtime surprise. bootstrapStrict goes a step further: it only compiles once every required token has a provider.
PulseInjector
PulseInjector (injector.ts) resolves tokens into instances.
get(token)always returns a Promise. Because resolution is async-first, an async factory (for example one that opens IndexedDB) is completely ordinary.getSync()exists too, but it throws if anything in the dependency graph is async.- Every service is a singleton per injector.
checkDepsfails fast and lists every missing token at once.
Resolution follows this order:
get(token)
1. token === PulseInjector → return this injector
2. singleton store has it → return it
a creation task is in flight → await the same task (dedupes concurrent get calls)
3. local provider exists → create, cache, return
otherwise → ask the parent injector
4. nothing found → throw "No provider found"
PulseModule
PulseModule (module.ts) is how features are packaged and composed:
new PulseModule()
.import(CoreModule)
.import(SharedModule) // one level only — deep imports throw
.provide(b => b.useClass(...))
.beforeInit(userConfig => ({ ...defaults, ...userConfig })) // build this module's config
.onInit((config, dep1, dep2) => { ... }, [Dep1Token, Dep2Token])
.bootstrap(userConfig); // → Promise<PulseInjector>
bootstrap runs these steps:
beforeInit. The root module's hook runs first. Then every imported module'sbeforeInitruns in parallel, and each receives the same original user config. The results are shallow-merged as{...root, ...m1, ...m2, …}in import order.QA override. If localStorage key
{storagePrefix}config_override(paa_config_overrideby default) holds JSON, it is spread over the merged config. See Override configs.Config registration. The merged config is registered as
PulseRootConfigurationToken. Every module also registers a pass-through factory for its own config class. As a resultCommonConfigurations,TrackerConfigurationsandSettingsConfigurationsall resolve to the same merged object.Injectors. Each imported module gets
new PulseInjector(root).provideInRootdecides where a provider is registered:provideInRootRegistered in undefined(default)the child and the root truethe root only falsethe child only onInit. Every module'sonInitruns in parallel. Its dependencies are resolved from that module's own injector.bootstrapresolves only after everyonInithas settled.
Rules of thumb
These rules follow directly from how the container works. Breaking them usually does not fail loudly; it produces subtle timing or identity bugs.
- Keep
onInitcheap. Bootstrap awaits everyonInit. If anonInitpulls in a heavy graph, such as the batch queue, it runs ahead of the tracker. The tracker then starts late, sessions can regenerate, and e2e tests break. Start optional features from a plugin instead. - Later modules override earlier defaults. Because configs are shallow-merged in import order, a later module that spreads shared defaults overwrites values an earlier module computed. If a value must win, set it in the last module or in the tracker's
buildConfig(tracker.module.ts). Every module's result also re-spreads the original user config, so an explicit client or Worker value for the same key still wins over a value computed in an earlier module. - Watch out for duplicate singletons. With the default
provideInRoot, a dependency resolved in a child injector (insideonInit) can be a different instance from the root singleton.IndexedDBServiceisprovideInRoot: truefor exactly this reason. Do the same for any service that owns a connection, a lock or a subscription. - Don't provide falsy values directly. The singleton store lookup is a truthiness check, so a provider whose value is
0,''orfalseis recreated on everyget. Wrap such values in an object. - One level of imports. Compose modules in the bundle entry, not by nesting modules inside modules.
Configuration model
Configuration types live in packages/common/src/models/configurations.ts. Each layer contributes its own defaults through its module's beforeInit.
| Type | Defaults file | Notable fields (default) |
|---|---|---|
CommonConfigurations | common/src/configuration.ts | app '', mode 'standard', app_version '0', version '__version__' (baked at release), storagePrefix 'paa_', keyPulseSyncCookie 'pulse-state', logLevel Error, locksMaxAge 24h, environment null |
SharedConfigurations | browser/shared/src/configuration.ts | transportTimeout 60s, transportRetries Infinity, backoff 100ms–30s, sessionStorageType 'persistent', tabManagerStorageType 'tab', cacheStorageType 'persistent', eventEnrichment true, autopilotTracking {enabled:false}, pulseMini false |
TrackerSharedConfigurations | browser/tracker-shared/src/tracker.configuration.ts | plugins, transformPipes, enrichmentPipes, debug [], rumReport [], rumReportOnce [], dataLayerKey 'dataLayer' |
TrackerConfigurations | browser/tracker/src/tracker.configuration.ts | serverBaseURL 'https://t.picsart.com/events/v2/web', batchTimeMS 500, batchSize 100, sessionTimeoutMS 30min, pageLoadedDomIdleMS 500, pageLoadedMaxWaitMS 5000, attributeTimeoutMS 7d, attributeSyncIntervalMS 1d, consentChecks [], stateEnrichment true, serverCookies true, attributeHints true, debugLiveEventKey 'debug-leuid' |
SettingsConfigurations | browser/settings/src/configuration.ts | settingsBaseURL 'https://optifyr.com/web/v2', cacheTimeout 10min, minSettingsGetTimeout 1500, maxSettingsGetTimeout 5000, defaults {}, attributes {}, remoteSettingEnabled true |
BrowserConfiguration | public/browser/src/configuration.ts | Common & Tracker & Settings; this is what initialize() accepts |
eventEnrichment and stateEnrichment accept either true or an object of per-field toggles:
eventEnrichment:url,previous_url,referrer,tab_id,legacy_sourcestateEnrichment:device_id,session_id,app,market,platform,version,language_code,pulse_version
Where a config value comes from
A single config value can be set in four places. Later sources win:
- Package defaults (the table above).
- Worker
dynamic.env.config, rendered per segment fromapps/server/src/data/values.ts. See SDK Delivery. - Client config passed to
initialize()(stored inθ.c). - The
{storagePrefix}config_overridelocalStorage entry (paa_config_overrideby default).
When a value "doesn't take effect", walk this list from the bottom up: a leftover paa_config_override entry in your browser beats everything else.
Where to look in the code
| Area | Path |
|---|---|
| Workspaces | package.json |
| Injection tokens | packages/common/src/di/token.ts |
| Provider builder | packages/common/src/di/provider-builder.ts |
| Injector | packages/common/src/di/injector.ts |
| Module and bootstrap | packages/common/src/di/module.ts |
| Configuration types | packages/common/src/models/configurations.ts |
| Bundle composition | packages/browser/loader/src/bundles/ |
| Tracker config finalisation | packages/browser/tracker/src/tracker.module.ts |