Skip to content

Bring main onto the release line - #27

Merged
Fiona2016 merged 49 commits into
publishfrom
main
Aug 21, 2026
Merged

Bring main onto the release line#27
Fiona2016 merged 49 commits into
publishfrom
main

Conversation

@Fiona2016

Copy link
Copy Markdown
Collaborator

The other half of reconciling the two branches. publish was merged into main first, so this direction carries no conflicts — it brings the release line up to everything main now holds, including the ES5 build for browsers without ES2015 support and the bundle sync script.

After this, the two branches hold the same tree and a release can be cut from either one.

Fiona2016 and others added 30 commits July 6, 2026 19:05
The staging/fed-staging intake endpoints were hardcoded to an internal
IP (http://10.99.1.110:11300) directly in `intakeSites.ts`. Since these
constants are publicly exported, the internal IP + plaintext http endpoint
leaked into every published `@flashcatcloud/browser-*` artifact.

Move the staging endpoints to the existing build-time injection mechanism
(`__BUILD_ENV__*` + replace-build-env / webpack DefinePlugin), the same
convention already used for SDK_VERSION. Values are read from the
INTAKE_SITE_STAGING / INTAKE_SITE_FED_STAGING env vars at build time and
default to an empty string, so release artifacts never contain an internal
host. Staging remains configurable by setting the env var at build time.

Verified: `BUILD_MODE=release` build of @flashcatcloud/browser-core emits
`INTAKE_SITE_STAGING = ""` and `grep -r 10.99` over cjs/ and esm/ is clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the build-time env injection with a hardcoded public https host,
matching the native SDKs (Android FlashcatSite.STAGING / iOS
FlashcatSite.staging both use jira.flashcat.cloud). This keeps the original
audit finding resolved (no internal 10.99.x IP, no cleartext http/port) while
making staging work out of the box across all builds with zero CI wiring, and
keeps the three client SDKs consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(core): remove hardcoded internal staging IP from intake sites
Introduce packages/rum-legacy, a CDN-only bundle for browsers without
ES2015 support. This commit sets up the toolchain only; collection and
transport follow.

The package does not extend tsconfig.base.json on purpose. Restricting
"lib" to ES5 + DOM turns a missing runtime API into a compile error
rather than a crash on the target browsers, and an empty "paths" map
keeps @flashcatcloud/* imports unresolvable, since those packages are
authored against ES2018.

check-es5-compatibility.js parses the emitted bundle with acorn at
ecmaVersion 5. It also asserts that the modern bundles are rejected: if
a misconfiguration made the parser accept everything, the positive
assertion alone would still pass and the gate would silently stop
protecting anything.

Console access is looked up lazily instead of captured at module
evaluation, because in IE9 window.console does not exist until the
developer tools are opened, and its methods are host objects without
bind().
Transport for browsers that have neither fetch nor sendBeacon.

The intake url is built to match the modern bundle byte for byte, so a
single reverse proxy rule on the customer domain serves both builds and
the intake needs no compatibility branch. Two details carry that
property and are easy to get wrong: the real intake path travels inside
the ddforward query parameter rather than being appended to the proxy
path, and a relative proxy value is resolved to an absolute url first.
The specs build a reference url with the modern implementation and
compare against it, so a change on either side fails loudly instead of
drifting.

Completion is detected through onreadystatechange. onload arrived in
IE10, so a transport built on it would look correct in a modern test
browser and never complete on the browsers this package exists for. The
spec's fake XMLHttpRequest fires only onreadystatechange to keep that
honest. The exit path sends synchronously because there is no
sendBeacon to hand the payload to.

Batch limits match the modern bundle. Payload size is measured with a
UTF-8 byte count rather than string length, which would undercount
non-latin content threefold and let batches grow past the intake limit.

Session identity reuses the modern cookie name, serialisation and
expiration rules. The modern parser rejects uppercase characters, so the
generated uuid has to stay lowercase or every page load would silently
start a new session.

Timers and listener registration go through the unpatched originals when
Zone.js is present, whose patched versions have been observed to cause
memory leaks and high CPU usage in host pages.

Time is read via a local dateNow() rather than Date.now(), which some
sites wrongly polyfill to return a Date instance. Pages still running
these browsers are the most likely to carry such a dependency.
Adds the event assembly and the collection this build can actually
support: uncaught errors, page load timings, view lifecycle and manual
actions.

Event shape is validated in the specs against the shared
rum-events-format schemas rather than against hand-written
expectations, since the intake owns that format. Durations are
nanoseconds, so page load timings derived from performance.timing are
converted rather than passed through as milliseconds.

The zero-valued resource and long task counts are emitted rather than
omitted. Those signals cannot be observed on these browsers, and
leaving the fields out would read downstream as missing data instead of
a real zero. Timings the browser has not reached are the opposite case:
performance.timing reports them as 0, which would be a false
measurement, so they are left out.

window.onerror preserves and still calls whatever handler the page had
installed, and passes its return value back so the page can keep
suppressing the browser's default logging. Replacing it outright would
silently disable the customer's own error reporting. Without an error
object there is no stack, so the script url and line are folded into a
single synthetic frame, which is what makes the error locatable at all.

Route changes are tracked through hashchange only, as there is no
History API to hook into here.
Wires collection, assembly, batching and transport behind the same
FC_RUM surface the modern bundle exposes.

Methods that cannot be supported here are no-ops rather than absent.
There is no PerformanceObserver for vitals, no MutationObserver for
session replay and no way to observe resource timings, but a missing
method throws "undefined is not a function" and takes the host page
down, which is the failure this package exists to prevent. A page
written against the modern bundle therefore runs unchanged.

Every public method is wrapped so an internal failure cannot surface as
an exception in the page. onReady is deliberately left unwrapped: it
invokes the caller's own callback, and swallowing there would hide the
customer's exceptions rather than ours.

The view context is passed to the view update callback rather than read
back from the manager. The first update is emitted while the manager is
still being constructed, so reading it back threw and, being caught by
the safety net, silently produced no events at all.

Uncaught and manually added errors share one path, so an error is
counted and reported exactly once.

The bundle size grows from 505 bytes to 39 KiB of sources, still
parsing as ES5.
Adds a fixture that removes fetch, Promise, sendBeacon, the observers,
TextEncoder and the URL constructor, then drives the package end to end
through an XMLHttpRequest offering only onreadystatechange. Without it
every spec runs in a browser that has all of those, so a dependency on
one would pass the suite and fail only where this package is meant to
run.

The ES2015 collections are deliberately left in place. lib: ES5 already
makes using them a compile error, a stronger guarantee than a runtime
spec, and the bundle scan covers the emitted output. Removing them here
broke the suite's own instrumentation instead: the shared leak detector
wraps addEventListener in a function that constructs a Map, so the first
listener this package registered failed inside the harness rather than
inside the code under test.

Globals are restored by putting back the captured property descriptor,
and a shadow over an inherited property is deleted rather than
overwritten. Restoring navigator.sendBeacon by assignment left it as an
own property of the instance rather than a method on Navigator.prototype,
which changed its shape for every later spec in the same browser context
and failed 41 of them across other packages.

check-es5-compatibility.js now also scans the bundle for runtime APIs
the target browsers lack. Parsing as ES5 says nothing about those: a
bundle full of Promise and fetch parses perfectly well and then fails on
the first line that runs.

Adds a package README covering setup, the required same-origin proxy,
the capability matrix, and an explicit statement that this has not been
verified on real hardware.
The merge and empty-check loops existed twice, byte for byte, in event
assembly and in the public api, because Object.assign and the spread
operator both need ES2015 and lib: ES5 rejects them. They move to
tools/objectUtils.ts.

The block reading a message, name and stack off an Error instance also
existed twice in error collection, once for uncaught errors and once
for manually added ones.

No behaviour change.
The view event carrying the time spent and the error and action counts
was only sent when the session was stopped explicitly. On a normal page
close nothing closed the view, so every view reached the intake with the
counts and duration it had at page load, which are zero. Error events
themselves were unaffected; the view level aggregates were not.

Emitting it was not enough on its own. The batch registered its own exit
listener when it was created, before the view manager existed, so it
always ran first and flushed an empty buffer before the closing update
could be added to it. Page exit is now owned in one place, which closes
the view and then flushes, and the batch no longer listens for it.

The exit path closes the view without shutting collection down.
beforeunload can fire for a navigation the user then cancels, and
tearing down there would leave the page with a dead SDK. It also runs
once per page: the request it makes is synchronous, and blocking a
closing browser twice is worse than missing a second closing update on
a cancelled navigation.

viewManager.flush() is replaced by endView(). It had no caller outside
its own specs.
Both options were accepted, validated and then ignored.

sessionSampleRate only reached _dd.configuration.session_sample_rate.
Every session was collected in full while each event claimed to have
been sampled at the configured rate, so the volume was wrong and the
reported rate described something that never happened. The decision is
now made once when a session starts and carried in the session cookie's
rum field, using the same values as the standard bundles, so a session
is either collected whole or not at all rather than losing a fraction
of each one.

trackingConsent was a no-op, which is worse for a consent control than
not offering it: a page could set 'not-granted' and still be collected
from. Collection now runs only while consent is exactly 'granted',
matching the standard bundles, where an unrecognised value counts as not
granted. Withdrawing consent drops whatever is buffered instead of
sending it and clears the session cookie.

Session cookie access is throttled to one second, as the standard
bundles throttle it. The session is looked up for every event, and
reading and writing document.cookie is a full string parse each time,
which is a cost worth avoiding on the browsers this package targets.
Public methods were wrapped so an internal failure could not surface in
the host page, but the handlers the browser calls back into were not.
A failure inside the hashchange, load or page exit listener became an
uncaught error on the page, which is the outcome this package exists to
avoid. The wrapper moves to tools/monitor.ts and now covers both entry
points.

Removing the wrapper failed no test before this change, so the guard was
untested rather than merely missing; the specs added here fail without
it. Making them fail for the right reason also required advancing past
the new session cookie throttling window, since a page that exits within
a second of init never touches the cookie and never reaches the
injected failure.

Views started in-page reported document.referrer, which describes how
the document was reached rather than how the view was, attributing every
in-page navigation to whatever site linked to the page. They now report
the previous view's url, as the standard bundles do, and only the first
view of a document falls back to document.referrer.

The loader snippet stubs init so that calling it outside onReady, before
the script has landed, queues the call instead of throwing "undefined is
not a function". The README also records where this build's stopSession
and setViewName deliberately differ from the standard bundles.

Session cookies are cleared before each spec as well as after: a spec
elsewhere may leave one behind, and a stale session would be reused
instead of a fresh one being created.
…ion reuse

Five more review passes, over clock behaviour, ordering, hostile input,
release plumbing and drift between the docs and the code.

Durations come from the wall clock, because these browsers have no
monotonic performance.now(). A backwards clock correction made
time_spent negative, which is not a measurement but a broken one, and
made the session throttle read the negative elapsed time as "still
inside the window", freezing the session until the clock caught up.
Elapsed time is now floored at zero and the throttle treats a backwards
jump as an elapsed window.

The page exit produced the closing view update and then flushed. If that
update crossed a buffer limit it started an asynchronous request, which a
closing page never completes. The update is now produced inside the exit
flush, so the whole sequence stays on the synchronous transport.

A session started by the standard bundles with session replay sampled is
stored as '1' rather than '2'. Reading only '2' as tracked meant such a
session was treated as sampled out and silenced for its whole lifetime.
Both builds share one cookie jar per domain, and IE enterprise site
lists routinely put some urls of a site in compatibility mode and others
not, so this is reachable rather than theoretical.

Hostile input was probed rather than assumed: a crafted session cookie,
a polluted Object prototype and a malformed cookie value are all
contained already, and now have specs saying so.

The bundle whose whole purpose is being small was missing from the size
report. It is 4 KiB gzipped.

The README claimed the degraded environment specs remove Map, Set and
Symbol. They deliberately do not, and overstating the coverage is worse
than describing it narrowly.
Four more review passes, over the emitted artifact, the module surface,
the changes outside this package, and the public API semantics.

The suite never executed the file customers actually load. Every spec
runs against TypeScript compiled by the test runner, and between that
and the shipped bundle sit Terser and the webpack runtime. A new check
executes the emitted file in an environment with no fetch, no Promise,
no sendBeacon and an XMLHttpRequest that only fires onreadystatechange,
then asserts what lands on the wire, including the intake path and
parameters carried inside ddforward.

It found a real defect on its first run. Errors were recognised with a
bare `instanceof Error`, which compares against the current frame's
constructor, so an error created in another frame was treated as a plain
value and stringified, losing its message, type and stack. Frameset and
iframe heavy applications are the norm on these browsers. The standard
bundles allow for this and now so does this one, verified with a real
iframe rather than a simulation.

The getters handed out the objects the SDK keeps rather than copies. The
stored configuration is what a later consent grant starts from, and the
contexts are attached to every event, so a caller could change SDK
behaviour by mutating what it read.

computeBytesCount and normalizeUrl were exported without a consumer,
which reads as part of the module's contract when they are internal.

The root build, the deploy path's package list and the workflow's ES5
step were run end to end rather than assumed.
The previous pass stopped the getters handing out the objects the SDK
keeps, but left the other half: setGlobalContext, setUser, setAccount
and init all stored the caller's object by reference.

Pages commonly keep the object they passed. An unrelated later mutation
of it silently changed what every subsequent event carried, and for the
configuration it changed what a later consent grant would start from.
Fixing only the read side left the same defect reachable from the write
side, which is worse than not having noticed it, because the specs
looked like the problem was covered.

Data is now copied at both boundaries.
…e fields

The sample rate range was checked by negating it. NaN fails every
comparison, so a rate computed from a string and landing on NaN passed
validation, and then failed the sampling comparison too: the SDK looked
configured and silently reported nothing, which is the worst way for a
monitoring build to go wrong. The range is now checked positively, as
the standard bundles check it.

The session cookie is shared with the standard bundles, which keep their
own entries in it. The anonymous user id is one of them and is tracked
by default. Rewriting the cookie with only the four fields this build
understands destroyed it, so a visit through a page served in
compatibility mode reset anonymous user continuity for every other page
of the same site. Entries this build does not understand are now written
back untouched; they still cannot reach the session identity or the
tracking decision, which are read from named fields only.
Verified the "no backend change" claim against the intake itself rather
than against the standard bundles' url shape, and found the transport
would have been refused outright.

The intake rejects any body whose content type is not text/plain. This
build deliberately set no request header at all, on the reasoning that
it kept the request simple and avoided a preflight. Both halves of that
were wrong: a same-origin request never preflights, and text/plain is a
safelisted value that does not trigger one even cross-origin. The
standard bundles get away with declaring nothing because fetch and
sendBeacon set it implicitly for a string body; XMLHttpRequest on these
browsers cannot be relied on to do the same.

Nothing client-side could have caught this. The specs and the artifact
check both asserted the absence of headers, so the mistaken belief was
encoded three times over: in the transport, in its spec, and in the fake
XMLHttpRequest of the degraded environment specs, which threw if a
header was set.

Both levels now assert the header, and both fail without it.
Everything this package is checked with so far runs on a modern engine:
the unit suite, the degraded-environment specs and the artifact smoke
test all approximate the target browsers rather than being one. This
adds the missing step, a harness for running the shipped bundle on a
real browser and seeing the result on the device itself.

The page is plain ES5 and renders every check into the DOM, because the
browsers it targets often have no usable developer tools. The server
doubles as a same-origin intake that records what actually arrived, so
the checks assert the wire rather than the SDK's own claims: the bundle
loads, the collection APIs do not throw into the page, an uncaught error
still reaches the page's own handler, the session cookie is written, and
the intake received a text/plain POST whose real path travels inside
ddforward.

One check only has teeth on an old engine: any fetch-era browser adds
the content type to a string body implicitly, so the header assertion
cannot fail there regardless of the SDK. That is exactly why it lives in
this harness and not only in the unit suite.

JSON is parsed with JSON.parse, native since IE8. An eval-based parse
would also break under any Content Security Policy, which the rest of
the package promises not to require.
Cloud device farms meter free sessions by the minute, and the harness
spent over thirty seconds waiting out the SDK's flush timer. The run now
fills the batch to its limit so it flushes over the asynchronous path
immediately, starts itself when opened with ?autorun=1, and keeps its
results across the exit-check reload in sessionStorage. A full pass
takes under a second plus one reload.

The root path did not resolve when a query string was attached, which
made ?autorun=1 a 404: routing now matches on the pathname.

The page-exit check reports SKIP rather than FAIL on modern engines,
which block synchronous XHR during page dismissal by design. Like the
content-type check, it can only genuinely pass or fail on Trident, which
is why it is in this page at all.
Real-device runs surfaced what happens below IE9: the loader snippet
routes every browser without fetch and Promise to this bundle, and on
IE8 the whole evaluation died on Object.defineProperty, which rejects
plain objects there. The throw surfaced as an uncaught error in the
hosting page, and IE8 document mode is routinely forced by enterprise
site lists, so this is reachable, not theoretical.

The promise for those engines inverts: collecting nothing is fine, but
the page must stay untouched. The defineProperty call now falls back to
a plain assignment, the entire module evaluation is guarded so any
construction failure leaves the loader's queued stub in place, and a
spec holds the constructor to that with a throwing defineProperty.

Syntax cannot be guarded at runtime, so the build gate now also parses
the bundle for ES3 reserved words used as property names, which the
IE6/7 engines fail to parse outright. ES5 allows them, meaning neither
the compiler nor the ES5 parse check would object.
Real IE runs found four defects in the harness itself. Tables are now
built with DOM calls: IE9 makes innerHTML read-only on table sections
and IE8 rejects it with its own error, so string rendering worked
everywhere except on the devices this page exists for. The bundle
loader guards its callback, because IE10 and 11 fire both onload and
onreadystatechange and every check ran twice. Payload assertions
aggregate all received requests, since async flushes travel the tunnel
independently and arrive out of order. A rerun stops the previous SDK
instance first instead of leaving two instances reporting at once.

Errors now surface into the page from a separate script block that
survives a syntax error in the main one, which is what identified every
failure above on consoleless browsers. A final check asserts that no
unexpected uncaught error reached the page, which is the whole
acceptance criterion for engines below the support floor. The server
logs each request so the device's traffic is observable from the
serving side.
… harness

Three more findings from real IE6 and IE8 runs.

The formatter added trailing commas to multiline literals. They are
legal ES5, so every static check passed, but IE8 counts a trailing
comma in an array literal as one more undefined element, and IE6 and 7
refuse to parse them in object literals at all. The page is now listed
in .prettierignore, carries a comment saying why, and the commas are
gone.

IE6 predates the native XMLHttpRequest constructor, so the harness's
own requests threw before they could observe anything. It now falls
back to the ActiveX flavour, which ran successfully on a real MSIE 6.0.

The no-unexpected-errors check only rendered when the intake had
received something. On engines below the support floor nothing ever
arrives, and that check is precisely the acceptance criterion there:
it now renders on both paths, and the closing note explains that red
collection rows plus a green cleanliness row is the expected shape.
Self-hosting environments need a complete, coherent copy of the deployed
bundles, and the standard RUM bundle references hash-named chunk files
that are error-prone to collect by hand. The script downloads every
entry bundle by name and recovers the chunk names from the chunk table
webpack embeds in each entry bundle, over plain HTTPS with no
credentials, and fails loudly when any file is missing.

The bucket layout and the entry filenames move into deploymentUtils.js
so the upload and download sides share one definition.
The README pointed at a placeholder static host without saying where
released bundles actually live, and still claimed the package had never
been verified on a real browser engine. Document the CDN layout and the
sync-bundles workflow for self-hosting, and record the real-browser
verification outcome: IE 9, 10 and 11 pass every check in the
verification page, and IE 6 and IE 8 degrade to a silent no-op.
The cookie was written percent-encoded. The modern bundle reads
document.cookie without decoding it, and an encoded value fails its
validation, so a session started here was discarded rather than shared
with a page that loads the standard bundle. Cookies already issued stay
readable: the read path keeps decoding.

The spec meant to catch this decoded the value before handing it to the
modern parser, so it validated a string that never exists in the browser
and passed on a cookie the modern bundle rejects. Removing the decode
makes it fail against the old implementation.

An untracked session is no longer treated as invalid either. The modern
bundle only mints an id once a session is tracked, so rum=0 with no id
is what a sampled-out session looks like, and renewing on a missing id
re-ran the sampling draw on a session that had already been sampled out.
Entries this build does not understand now survive a renewal too.
Four behaviours diverged from the standard bundle in ways that lose or
misreport data:

init overwrote a tracking consent the page had already set. A consent
management platform commonly answers before init runs, and the answer is
the user's; the configuration only supplies a default for a page that
has not answered.

The page exit guard was never released, so a cancelled navigation left
it set and the real exit that followed did nothing: everything recorded
after the cancellation went with the page. It is now released by the
next event, which only a page that is still recording produces.

View events were dated when the update was assembled rather than when
the view started, so the closing update appeared to have happened at the
moment the page was dismissed.

Navigation Timing is read off window rather than as a bare identifier.
Where the property is absent entirely a bare reference throws instead of
evaluating to undefined, and this runs inside the first view emitted
during init. The degraded environment suite now deletes the global
rather than defining it as undefined, which is the only form of the
hazard that reproduces it.
The loader chose the standard bundle whenever Promise and fetch were
present. Those are the two most commonly polyfilled APIs on the pages
this build targets, and a polyfill supplies the API without supplying
the syntax, so a polyfilled IE9 was handed a bundle it cannot parse and
collected nothing. document.documentMode is checked first: only Trident
defines it, it reports the mode the page is actually rendered in, and no
polyfill sets it.

The snippet also carried trailing commas, which an ES3 parser rejects
outright — in a snippet whose whole job is to route browsers that parse
that way. A build gate now parses every documented snippet as ES3, since
a formatter reinserts the comma given the chance.

The page load timings row is marked as uneven rather than supported: on
the IE9 device used for verification none were reported, while the same
code fills them in on a modern browser.
Running an install re-resolved the @alicloud subtree, which has nothing
to do with this branch: those packages are pinned to the floating latest
range, so any install moves them. Only the acorn descriptor is kept.
…ndle

startView ignores everything but the name, and a relative proxy resolves
against the document base url rather than the page url. Both are visible
to a page being ported and neither was written down.
The checks never looked at page load timings, which is why the IE9 run
passed every row while reporting none of them. The new row separates the
two explanations, which call for opposite responses: a browser without
usable Navigation Timing has nothing to report and says so, while one
that has it and still sends no timings is a bug here. The environment
box now shows what the browser actually offers, so a single run settles
which case it is.

The session cookie row also stops printing the whole cookie jar. This
page runs inside the customer's own environment and its results get
screenshotted; the rest of document.cookie belongs to whatever else is
served from that host.
Measured on a real IE9 rather than inferred from an earlier run that
showed none: window.performance and performance.timing are both present
there, and all five timings reach the intake. The defensive read stays,
because the engines below IE9 do not have the API at all.
The comment claimed the guard stops the synchronous request from being
sent twice, which is not what it does: an event arriving between
beforeunload and unload releases it and buys a second request. That is
the intended trade rather than a hole - the guard falls only when there
is new data an exit would have to carry, so the second request is what
delivers that event instead of losing it. Written down so the next
reader does not restore the one-shot guard, and the data loss with it.
stopSession tore the whole pipeline down, so a page that called it never
recorded again. It now ends the session and leaves collection running:
the next event opens a new session with a fresh sampling draw, which is
what the standard bundles do. The current view carries on, since there
is no session renewal signal here to hang a new one off.

init used `running` as its initialised flag, but nothing is running
between init and a consent grant, so a second init in that window
replaced the configuration the first one was waiting on and events went
to the wrong application. A separate flag closes the window; a rejected
configuration still leaves the SDK uninitialised so a corrected call
works.

The lockfile also lost this package's workspace entry when an unrelated
re-resolution was stripped out of it, which broke
`yarn workspace @flashcatcloud/browser-rum-legacy build:bundle` on a
clean checkout. Restored, without the unrelated churn.

The README said the package had never run on real hardware, three
paragraphs after describing what it did on real hardware.
setViewName started a fresh view, which invents a navigation the user
never made and splits the view's counts across two ids. An update has
already gone out under the old name and cannot be retracted, but every
update of a view shares its id and the intake keeps the highest document
version, so renaming in place lands the new name without the phantom
page view.
Three ways the script could hand over something incomplete without
saying so:

A thrown fetch — a refused connection or a DNS failure, which is the
expected shape of trouble for the networks this script exists to serve —
escaped the per-file handling, so the run stopped at the first one and
the operator got a stack trace instead of the list of what was missing
and the warning not to deploy it. Every file is attempted now and all
failures are reported together.

An interrupted run left a half-written directory that looks exactly like
a finished one: an entry bundle without its chunks is unremarkable on
disk. Files now land in a directory named for being unfinished and are
moved into place only once every one of them is there. An existing
output directory is refused rather than merged into, which would have
mixed in the chunks of an older version.

A chunk whose name webpack had to quote did not match the pattern that
reads the chunk table, so it would have been missing from the output
without ever being attempted, and so without ever being reported. Both
shapes match now, and the count of names read is checked against the
count in the table.
The sync script only reads what the release publishes, so it had no
business restructuring how the release works to get there. Sharing one
definition of the bucket layout would keep the two from drifting, but it
bought that by editing production release tooling from a read-only tool,
and the layout it needs is three constants.

deploy-oss.js and lib/deploymentUtils.js go back to what they were. The
script now depends on nothing from the release path and carries its own
copy of the host, the directories and the entry filenames.
A body that stops arriving mid-download, or a disk that fills while it
is being written, escaped the per-file handling: only the request itself
was covered. The run ended on the first one with a stack trace, the
remaining files were never attempted, and the operator never saw the
list of what was missing or the warning not to deploy it - the same
outcome the request handling was added to prevent. Reading and writing
the body are inside the same net now.

An output directory given with a trailing slash put the staging
directory inside it, where the rename cannot land. Every file downloaded
successfully and was then stranded in a hidden directory, and since the
output directory now existed, the guard refused every re-run. The path
is resolved before the staging name is derived from it.

A bundle that plainly loads chunks but whose chunk table does not match
the pattern now fails loudly. It used to be indistinguishable from a
bundle with no chunks at all, so a webpack or terser change to the
emitted runtime would have quietly produced a directory missing the very
files that are hardest to notice missing.

Chunk names decide where this writes and are read out of a downloaded
file, so anything resolving outside the output directory is refused.
The state the modern bundle writes when a session ends is neither an id
nor a tracking decision: it is an expiry marker beside the anonymous
user id it deliberately carries across the expiry. This build required
one of its own fields to be present before it would accept a cookie at
all, so it threw that state away and reset an identifier the other build
was keeping. Any parsed entry is enough now.

The expiry marker is also a known field rather than a foreign one.
Carried forward as foreign it would have ridden into every session this
build writes, and the modern bundle reads any session carrying it as
expired — a new session on every page load. Fixing the parsing without
this would have been worse than leaving it broken.

Ending a session now writes that same expired state instead of deleting
the cookie, so what outlives a session survives it here too. Withdrawing
consent still deletes the cookie outright: there the identifier is the
thing being withdrawn.

Withdrawing consent also stopped sending. Tearing the pipeline down
buffers one last view update, and the buffer sends itself as soon as an
event would take it past its size limit, so a withdrawal with a nearly
full buffer put everything collected before it on the wire immediately
after. The batch is stopped first now, and the spec fills the buffer to
the edge rather than trusting a small one.
… does

What outlives a session stays in this cookie once the session ends - the
anonymous user id among them - but the cookie itself was written to
expire with the session, fifteen minutes out. A visitor coming back
twenty minutes later found it gone, which resets the identifier the
modern bundle keeps for a year and which the previous commit went to
some trouble to preserve.

The cookie now persists for that same year. How long a session lasts is
unchanged: that is decided by the expire entry inside the value, not by
the attribute.

The expiry attribute cannot be read back from document.cookie, so the
spec captures the write instead - which is why nothing caught this.
feat(rum-legacy): ES5 build for browsers without ES2015 support
…docs

feat(deploy): CDN bundle sync script and self-hosting docs
The two lines had drifted far apart: publish carries the releases (npm is
at the version it holds, and the release tags are on it) along with the
repairs that keep a release working, while main had the newer feature
work. Reconciling them makes either branch a place you can release from
and stops the next person guessing which one is true.

Three files needed a decision. The deploy workflows exist on both sides:
publish rebuilt them into a preflight job that validates the tag against
the package version and hands a verified bundle to the deploy job, which
is strictly better than what main had, so that shape wins. The ES5
compatibility gate main added is put back into it, after the build,
where every bundle exists and the check that the modern bundles are
rejected still means something.

The lockfile is regenerated rather than kept as the text merge left it -
a merged lockfile is not a lockfile, and the merged one failed
--immutable.

One spec still carried a hardcoded internal staging address, which is
also why it failed once the two sides met: the host had been scrubbed
from the source on one side and not from the fixture on the other. It
derives the host from the constant now, so the next change to it cannot
leave the fixture behind.

The bundle sync script and its documentation followed the old layout,
where a release overwrote the directory of its major version. The
pipeline this merge adopts publishes each release to its own directory,
so a url pins the version it names, and the script defaults to the full
version.
Merging the two lines brought a CI workflow main did not have, and it
runs checks main never ran. Two of them fail, both because this package
was added while nothing was looking:

The package metadata check requires an .npmignore per package, and this
one had none. It follows the convention the others use — exclude
everything, then name what ships — which here is the bundle alone.

The deploy and source map specs still described three packages. Adding a
fourth changed what those scripts do without changing what the specs
expected of them, so every one of them failed. They now expect the
legacy bundle to be uploaded, renamed and invalidated alongside the
others.

This was raised in review earlier and set aside because these specs did
not run anywhere. They do now.
The previous attempt inserted the legacy command inside the object that
held the slim one, which is two strings in a row and parses as nothing.
Each expectation is now copied as a whole and rewritten.
The repository-wide typecheck compiles the specs of every package
together, which main's CI never did. Two things only show up there:

A spec elsewhere augments the global Window with its own type for the
FC_RUM property, so an interface here extending Window with a different
type for it is two declarations of one global disagreeing. This one no
longer extends Window; it only needs somewhere to put the api.

Two event assembly fixtures predate the view start time being part of a
view's context and had not been given one.

The source map expectations also had the legacy bundle renamed between
the slim bundle's script and its source map, rather than after both. The
renames happen a package at a time.
The fixture gained a start time, which dates the event rather than
travelling inside the view object, so comparing the whole fixture to
what was assembled started failing.
The clock was frozen after the view had already opened, so the view read
the real wall clock for its start and the test read a frozen one two
seconds later - a millisecond between the two lines was enough to fail
it. It froze once on a CI runner and would have again.
…o-main

Merge the publish branch into main
@Fiona2016
Fiona2016 merged commit b8adaf0 into publish Aug 21, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant