Skip to main content

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​

DirectoryPackageRole
apps/servernx serverCloudflare 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-sharedSharedModule: storages, cookies, LockManager, HttpService, DeviceIdService, TabManagerService, StateService, IndexedDBService, HashStore, debug controls
packages/browser/tracker-shared@pulse/browser-tracker-sharedTrackerSharedModule: plugin and pipe hosts, RUM, DOM path; shared by the tracker and mini
packages/browser/tracker@pulse/browser-trackerTrackerModule: the full tracker (session, queue, batching, plugins)
packages/browser/settings@pulse/browser-settingsSettingsModule: remote settings, experiments, segments, cookie sync
packages/browser/mini@pulse/browser-miniPulseMiniModule: tracker for miniapps; the host platform provides the transport
packages/browser/loader@pulse/browser-loaderBundle entries (all-in-one.bundle.ts, mini.bundle.ts), autopilot.loader.ts, event-forwarding.loader.ts, the meta-generator nx executor
packages/browser/tracker-e2enx tracker-e2ePlaywright end-to-end specs
packages/public/browser@pulse/browserinitialize() / initializeScoped(); injects the module/nomodule script tags
packages/public/core@pulse/coreexport const pulse = createGlobalProxy(), a proxy that works in the browser and on the server
packages/public/react@pulse/reactPulseProvider, usePulseSettings, useLazyPulseSettings, usePulseState, usePulseSegments, usePulseExperiments, withPulseSettings
packages/public/server@pulse/servercreateClient() for Node and Workers. It uses no DI; it is plain classes
packages/public/express@pulse/expresswithPulse() middleware (exported as usePulse) on top of @pulse/server
packages/public/nest@pulse/nestPulseModule.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 appsPlaygrounds 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:

BundleModules, in import order
allCore → Shared → TrackerShared → Tracker → Settings
miniCore → 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 as TrackerConfigurations are used this way.
  • Provider has the shape {provide, useClass?, useFactory?, useValue?, deps?, provideInRoot?}. When several strategies are set, the precedence is useValue, then useClass, then useFactory, then "provide is 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.
  • checkDeps fails 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:

  1. beforeInit. The root module's hook runs first. Then every imported module's beforeInit runs in parallel, and each receives the same original user config. The results are shallow-merged as {...root, ...m1, ...m2, …} in import order.

  2. QA override. If localStorage key {storagePrefix}config_override (paa_config_override by default) holds JSON, it is spread over the merged config. See Override configs.

  3. Config registration. The merged config is registered as PulseRootConfigurationToken. Every module also registers a pass-through factory for its own config class. As a result CommonConfigurations, TrackerConfigurations and SettingsConfigurations all resolve to the same merged object.

  4. Injectors. Each imported module gets new PulseInjector(root). provideInRoot decides where a provider is registered:

    provideInRootRegistered in
    undefined (default)the child and the root
    truethe root only
    falsethe child only
  5. onInit. Every module's onInit runs in parallel. Its dependencies are resolved from that module's own injector. bootstrap resolves only after every onInit has 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 onInit cheap. Bootstrap awaits every onInit. If an onInit pulls 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 (inside onInit) can be a different instance from the root singleton. IndexedDBService is provideInRoot: true for 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, '' or false is recreated on every get. 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.

TypeDefaults fileNotable fields (default)
CommonConfigurationscommon/src/configuration.tsapp '', mode 'standard', app_version '0', version '__version__' (baked at release), storagePrefix 'paa_', keyPulseSyncCookie 'pulse-state', logLevel Error, locksMaxAge 24h, environment null
SharedConfigurationsbrowser/shared/src/configuration.tstransportTimeout 60s, transportRetries Infinity, backoff 100ms–30s, sessionStorageType 'persistent', tabManagerStorageType 'tab', cacheStorageType 'persistent', eventEnrichment true, autopilotTracking {enabled:false}, pulseMini false
TrackerSharedConfigurationsbrowser/tracker-shared/src/tracker.configuration.tsplugins, transformPipes, enrichmentPipes, debug [], rumReport [], rumReportOnce [], dataLayerKey 'dataLayer'
TrackerConfigurationsbrowser/tracker/src/tracker.configuration.tsserverBaseURL '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'
SettingsConfigurationsbrowser/settings/src/configuration.tssettingsBaseURL 'https://optifyr.com/web/v2', cacheTimeout 10min, minSettingsGetTimeout 1500, maxSettingsGetTimeout 5000, defaults {}, attributes {}, remoteSettingEnabled true
BrowserConfigurationpublic/browser/src/configuration.tsCommon & 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_source
  • stateEnrichment: 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:

  1. Package defaults (the table above).
  2. Worker dynamic.env.config, rendered per segment from apps/server/src/data/values.ts. See SDK Delivery.
  3. Client config passed to initialize() (stored in θ.c).
  4. The {storagePrefix}config_override localStorage entry (paa_config_override by default).
tip

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​

AreaPath
Workspacespackage.json
Injection tokenspackages/common/src/di/token.ts
Provider builderpackages/common/src/di/provider-builder.ts
Injectorpackages/common/src/di/injector.ts
Module and bootstrappackages/common/src/di/module.ts
Configuration typespackages/common/src/models/configurations.ts
Bundle compositionpackages/browser/loader/src/bundles/
Tracker config finalisationpackages/browser/tracker/src/tracker.module.ts