Skip to main content

SDK Delivery: Worker, Bundles and the Global Proxy

Definition​

This document explains how Pulse gets from the pulse-web repository onto a web page: how bundles are built and stored, how the Cloudflare Worker personalises and serves them, what the bundle does when it boots, and how the window.pulse global proxy lets pages call Pulse before it has loaded.

How a page gets Pulse​

At a high level, delivery is four steps. Each step is covered in detail further down.

  1. The integrator adds Pulse. Either @pulse/browser's initialize({app, …}) injects the script tags, or the page includes them by hand (see the Integration Guide):

    <script type="module" src="https://optifyr.com/pulse/{app}/module/pulse.js?cid=…"></script>
    <script nomodule src="https://optifyr.com/pulse/{app}/nomodule/pulse.js?cid=…"></script>
  2. The browser picks one. The standard module/nomodule split means modern browsers load the esnext bundle and legacy browsers load the es5 one. No runtime code chooses between them.

  3. The Worker personalises the bundle. It computes segments for the request, renders dynamic.env.config into the hashed bundle text, and returns the whole bundle. There is no separate small loader script: it was merged into the bundle for performance, saving a round trip.

  4. The bundle boots. all-in-one.bundle.ts bootstraps the DI modules, builds the Pulse instance and replays any calls the page queued in the meantime.

 page ──GET /pulse/{app}/module/pulse.js──▶ Cloudflare Worker
│ segments + dynamic.env.config
│ rendered into bundle from KV
page ◀────────── personalised bundle ────────┘
│
└─▶ all-in-one.bundle.ts: bootstrap DI → create Pulse → replay θ.ready queue

Building bundles into KV​

Bundles are produced by webpack and stored as immutable, content-hashed files. The all and mini entries are each built twice, once per target:

npm run build:bundles
→ webpack (packages/browser/loader/webpack.config.cjs): entries all + mini, each ×2
bundlesESNEXT tsc, tsconfig.lib.esnext.json (ES2024)
bundlesES5 babel preset-env + core-js usage, targets incl. ie >= 11
→ .mf/kv/BUNDLES/pulse.bundle.{all|mini}.{esnext|es5}.[chunkhash].js (+ .map)
→ nx generateMeta (loader/src/executors/meta-generator) → .mf/kv/BUNDLES/meta.json

meta.json lists the build targets and the hashed bundle file names grouped by bundle type, so the Worker knows which files to read from KV.

EnvironmentHow bundles reach the Worker
Localnpm run serve:server bundles the Worker with rollup, then runs scripts/debug-server.mjs. It starts Miniflare on port 8585 with KV loaded from .mf/kv/BUNDLES, sets ENV=local, and watches meta.json
CIscripts/upload-bundles.mjs PUTs the bundles to the Cloudflare KV API. scripts/deploy.mjs writes meta.json into vars.BUNDLES_META and runs wrangler deploy
note

Run npm run build:bundles (or watch:bundles) before npm run serve:server. The local Worker only serves what is already in .mf/kv/BUNDLES.

Worker routes for stage and prod are declared in apps/server/wrangler.toml (picsartstage2.com/pulse/* and picsart.com/pulse/*).

The Cloudflare Worker​

The Worker lives in apps/server. Its entry point is src/main.ts, built on the Cloudflow router (CloudflowRouter). Its environment is:

interface WorkerEnv {
BUNDLES: KVNamespace; // the bundle files
BUNDLES_META: string; // meta.json, passed as a Worker variable (not read from KV)
ENV: string;
}

Routes​

All routes are defined in src/controllers/app.controller.ts.

RouteBehaviour
GET /pulse/:app/pulse.js, GET /pulse/:app/:standard/pulse.jsPersonalised all bundle. A standard of module selects esnext; anything else selects es5. Sent with Cache-Control: private, no-cache because the response varies by origin and country
GET /pulse/mini/v1/:app[/:standard]/pulse.jsPersonalised mini bundle
GET /pulse/pulse.bundle.:type.:target.:hash.js (and .map)Raw KV file, public, max-age=2592000, immutable
GET /pulse/pulse.:app.js, /pulse/legacy/…Legacy bundles, kept for old integrations
GET /pulse/meta{env, meta}
GET /pulse/heartbeatHealth check

CORS echoes the request Origin, with credentials allowed.

The split in caching is intentional. Personalised responses cannot be cached publicly because two visitors from different countries or origins may get different config. The raw hashed files never change, so they can be cached for 30 days as immutable.

Rendering pipeline for the loader route​

The personalised routes render server-side configuration into the bundle text before sending it:

Context { app (short names mapped in data/apps.ts), country (cf.country), origin, referrer,
devServer (ENV==='local'), version, cid, batchTimeMS, batchSize, autopilot } ← query params
→ SegmentationService.resolve(ctx) data/segments.ts → Set<segment name>
→ TemplateService.getLoader(fileName, KV) LRU (30 entries / 50 MB) over KV, cacheTtl 30d
→ EnvironmentService.getValues(ctx, segs) data/values.ts if/then/elseIf/else rules, dot-prop setters
→ EnvironmentRendererService.render(tpl, env) replaces dynamic.env.KEY | ["KEY"] | ['KEY'] with JSON

Server-side configuration​

The files under apps/server/src/data/ are the server-side configuration of Pulse. Most "change Pulse behaviour for site X" requests are edits there rather than SDK changes.

FileWhat it holds
origins.tsAllowed origins
segments.tsSegment definitions such as pa_prod, tools_stage, opt_in and editor_sdk_*
gdpr.tsThe list of opt-in countries
apps.tsShort app names mapped to full app ids
values.tsPer-segment values rendered into dynamic.env.config (see below)

values.ts controls, among other things:

  • storagePrefix, batchTimeMS and batchSize defaults;
  • the consent provider per segment (OneTrust or DataGrail, opt-in or opt-out);
  • settingsBaseURL (stage or dev for non-prod);
  • debug: ['debugger'] outside prod;
  • mode: 'page-session' for the editor SDK;
  • autopilotTracking per segment;
  • RUM filters.
caution

Keep the batching defaults in data/values.ts aligned with the client defaults in tracker.configuration.ts. The Worker value overrides the package default (see the config precedence in Monorepo Packages and Dependency Injection), so a mismatch silently changes batching behaviour in production.

Bundle entry: all-in-one.bundle.ts​

When the full bundle executes, its initialize() runs these steps in order:

  1. Guard against double init with globalThis._pulse._initializStarted. A page can end up executing the bundle more than once (for example when another integration includes it again), and a second init must be a no-op.
  2. Call createGlobalProxy(). The config becomes {...dynamic.env.config, ...θ.c}, so client config wins over Worker config.
  3. Ensure window.dataLayer ||= [].
  4. Bootstrap Core, Shared, TrackerShared, Tracker, Settings.
  5. createPulse(settings, tracker) (common/src/pulse/create-pulse.ts) creates a callable function. Every prototype method of settings and tracker is copied onto it, and calling it runs tracker.cmd(arguments).
  6. Start event forwarding, driven by a remote setting (see Event Forwarding).
  7. Call loadAutopilotIfEnabled (see Ecosystem and Disable Autopilot).
  8. Call addLegacyInterfaces: it sets window.pulse.tracker, window.pulse.settings and window.pulse.settingsFactory, and fires window.pulseInit / window.pulseReady.
  9. Call fireOnReady(θ, pulse), which replays the queue.

mini.bundle.ts does not auto-initialise. It exposes window.pulse.init(config, attributes, transport, route, getContext, …), which the host platform calls (via @pulse/mini's initializeBrowserPulseMini).

The global proxy​

Pages want to call pulse.event(...) or pulse.get(...) immediately, long before the bundle has downloaded. The global proxy makes that safe: it accepts calls straight away and forwards them once the real instance exists. It lives in packages/common/src/pulse/.

createGlobalProxy(config) (proxy.ts) sets up window.pulse:

  • θ (theta) is the internal slot: { pulseInit: [], ready: [], idu: location.href, c: config }. idu is the initial document URL; it is captured because single-page apps often rewrite the URL before the SDK loads.
  • Calling the proxy: pulse(fn) becomes fn(instance), and pulse('event', …) becomes instance.cmd(…). cmd aliases send to event and state to set.
  • Tracker methods (event, events, attribute(s), set, state, stateData, getState, getState$, flush, cmd, queueEmpty) are delayed methods or subjects. Each one awaits the instance.
  • Settings methods (get, observe, segments, experiments) additionally race a network-aware timeout (common/src/utils/settings-timeout.ts). On timeout they fall back to the default value or [], so a page never hangs on settings.
  • App mismatch: a second createGlobalProxy with a different app throws Pulse is already initialized with a different app id!.

The settings timeout depends on the connection's effective type:

ConnectionTimeout
default / unknown10 s
4g5 s
3g15 s
2g30 s
slow-2g60 s
mobile devicedoubled, capped at 60 s

Queue replay​

Each delayed call pushes a resolver onto θ.ready. When the bundle is ready, fireOnReady (loader/src/on-ready.ts) runs the queue in order, then monkey-patches θ.ready.push so that later callbacks run immediately. That patch is how calls made after boot reach the instance without a second code path.

// Before the bundle loads: queued on θ.ready
window.pulse.event({ action: 'click' });

// After fireOnReady: θ.ready.push runs the callback immediately
window.pulse.event({ action: 'click' });

Scoped proxies and the server​

  • createScopedProxy reuses θ but carries its own settings defaults and attributes (sent as attr_<k> in state data). initializeScoped() and React's PulseProvider use it.
  • Server side: getPulseInstance() reads the AsyncLocalStorage at globalThis.__pulseAsyncContextStore, which @pulse/server fills per request. This is how @pulse/core's pulse export works during SSR.

Public wrappers​

  • @pulse/browser (src/init.ts, services/loader.service.ts): initialize(config) calls createGlobalProxy(config), which stores the config in θ.c. It injects the module/nomodule tags unless a script[src*=pulse.js] already exists, and returns the proxy synchronously. The host comes from config.environment or from host patterns:

    environmentHost
    localhttp://localhost:8585
    dev / stagehttps://dev.optifyr.com / https://stage.optifyr.com
    prodhttps://optifyr.com
  • @pulse/react: PulseProvider (props app, settings?, tracker?) calls initializeScoped once (memoised). The hooks call observe(tag, name, default) and getState().

  • @pulse/server: createClient(config) has no DI. createAsyncRequestContext(init, next, req, res) builds a context from the pulse-state cookie, runs next inside AsyncLocalStorage, and writes the cookie back before headers are sent. Settings are fetched with GET {host}/web/v2/{app}/{tag}; events are POSTed to ${serverBaseURL}/${app} without the /fetch suffix, with header market: 'cli'.

  • @pulse/express (exported as usePulse) and @pulse/nest are thin middleware around createClient.

Where to look in the code​

AreaPath
Worker entry and envapps/server/src/main.ts, apps/server/src/env.ts
Worker routesapps/server/src/controllers/app.controller.ts
Server-side configurationapps/server/src/data/
Template cache and renderingapps/server/src/services/
Worker routingapps/server/wrangler.toml
Bundle buildpackages/browser/loader/webpack.config.cjs
Bundle entriespackages/browser/loader/src/bundles/
Queue replaypackages/browser/loader/src/on-ready.ts
Global proxypackages/common/src/pulse/proxy.ts
Pulse instance creationpackages/common/src/pulse/create-pulse.ts
Settings timeoutpackages/common/src/utils/settings-timeout.ts
Local and CI scriptsscripts/debug-server.mjs, scripts/upload-bundles.mjs, scripts/deploy.mjs
Browser wrapperpackages/public/browser/src/init.ts, packages/public/browser/src/services/loader.service.ts