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.
The integrator adds Pulse. Either
@pulse/browser'sinitialize({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>The browser picks one. The standard module/nomodule split means modern browsers load the
esnextbundle and legacy browsers load thees5one. No runtime code chooses between them.The Worker personalises the bundle. It computes segments for the request, renders
dynamic.env.configinto 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.The bundle boots.
all-in-one.bundle.tsbootstraps 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.
| Environment | How bundles reach the Worker |
|---|---|
| Local | npm 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 |
| CI | scripts/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 |
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.
| Route | Behaviour |
|---|---|
GET /pulse/:app/pulse.js, GET /pulse/:app/:standard/pulse.js | Personalised 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.js | Personalised 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/heartbeat | Health 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.
| File | What it holds |
|---|---|
origins.ts | Allowed origins |
segments.ts | Segment definitions such as pa_prod, tools_stage, opt_in and editor_sdk_* |
gdpr.ts | The list of opt-in countries |
apps.ts | Short app names mapped to full app ids |
values.ts | Per-segment values rendered into dynamic.env.config (see below) |
values.ts controls, among other things:
storagePrefix,batchTimeMSandbatchSizedefaults;- 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;autopilotTrackingper segment;- RUM filters.
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:
- 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. - Call
createGlobalProxy(). The config becomes{...dynamic.env.config, ...θ.c}, so client config wins over Worker config. - Ensure
window.dataLayer ||= []. - Bootstrap
Core, Shared, TrackerShared, Tracker, Settings. 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 runstracker.cmd(arguments).- Start event forwarding, driven by a remote setting (see Event Forwarding).
- Call
loadAutopilotIfEnabled(see Ecosystem and Disable Autopilot). - Call
addLegacyInterfaces: it setswindow.pulse.tracker,window.pulse.settingsandwindow.pulse.settingsFactory, and fireswindow.pulseInit/window.pulseReady. - 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 }.iduis the initial document URL; it is captured because single-page apps often rewrite the URL before the SDK loads.- Calling the proxy:
pulse(fn)becomesfn(instance), andpulse('event', …)becomesinstance.cmd(…).cmdaliasessendtoeventandstatetoset. - 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
createGlobalProxywith a differentappthrowsPulse is already initialized with a different app id!.
The settings timeout depends on the connection's effective type:
| Connection | Timeout |
|---|---|
| default / unknown | 10 s |
4g | 5 s |
3g | 15 s |
2g | 30 s |
slow-2g | 60 s |
| mobile device | doubled, 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
createScopedProxyreusesθbut carries its own settingsdefaultsandattributes(sent asattr_<k>in state data).initializeScoped()and React'sPulseProvideruse it.- Server side:
getPulseInstance()reads the AsyncLocalStorage atglobalThis.__pulseAsyncContextStore, which@pulse/serverfills per request. This is how@pulse/core'spulseexport works during SSR.
Public wrappers
@pulse/browser(src/init.ts,services/loader.service.ts):initialize(config)callscreateGlobalProxy(config), which stores the config inθ.c. It injects the module/nomodule tags unless ascript[src*=pulse.js]already exists, and returns the proxy synchronously. The host comes fromconfig.environmentor from host patterns:environmentHost local http://localhost:8585dev / stage https://dev.optifyr.com/https://stage.optifyr.comprod https://optifyr.com@pulse/react:PulseProvider(propsapp,settings?,tracker?) callsinitializeScopedonce (memoised). The hooks callobserve(tag, name, default)andgetState().@pulse/server:createClient(config)has no DI.createAsyncRequestContext(init, next, req, res)builds a context from thepulse-statecookie, runsnextinside AsyncLocalStorage, and writes the cookie back before headers are sent. Settings are fetched withGET {host}/web/v2/{app}/{tag}; events are POSTed to${serverBaseURL}/${app}without the/fetchsuffix, with headermarket: 'cli'.@pulse/express(exported asusePulse) and@pulse/nestare thin middleware aroundcreateClient.
Where to look in the code
| Area | Path |
|---|---|
| Worker entry and env | apps/server/src/main.ts, apps/server/src/env.ts |
| Worker routes | apps/server/src/controllers/app.controller.ts |
| Server-side configuration | apps/server/src/data/ |
| Template cache and rendering | apps/server/src/services/ |
| Worker routing | apps/server/wrangler.toml |
| Bundle build | packages/browser/loader/webpack.config.cjs |
| Bundle entries | packages/browser/loader/src/bundles/ |
| Queue replay | packages/browser/loader/src/on-ready.ts |
| Global proxy | packages/common/src/pulse/proxy.ts |
| Pulse instance creation | packages/common/src/pulse/create-pulse.ts |
| Settings timeout | packages/common/src/utils/settings-timeout.ts |
| Local and CI scripts | scripts/debug-server.mjs, scripts/upload-bundles.mjs, scripts/deploy.mjs |
| Browser wrapper | packages/public/browser/src/init.ts, packages/public/browser/src/services/loader.service.ts |