From a79a44eb3b37ee9957332fa27c9dd11b559f64f7 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 18 Aug 2026 05:02:01 -0700 Subject: [PATCH 1/5] feat(deploy): add sync-bundles script to download deployed bundles 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. --- scripts/deploy/deploy-oss.js | 29 ++------ scripts/deploy/lib/deploymentUtils.js | 27 ++++++- scripts/deploy/sync-bundles.js | 103 ++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 25 deletions(-) create mode 100644 scripts/deploy/sync-bundles.js diff --git a/scripts/deploy/deploy-oss.js b/scripts/deploy/deploy-oss.js index a4ed765a74..30bbe903d5 100644 --- a/scripts/deploy/deploy-oss.js +++ b/scripts/deploy/deploy-oss.js @@ -4,20 +4,7 @@ const OpenApi = require('@alicloud/openapi-client') const { printLog, printError, runMain } = require('../lib/executionUtils') const { forEachFile } = require('../lib/filesUtils') -const { buildBundleFolder, packages } = require('./lib/deploymentUtils') - -const AWS_CONFIG = { - prod: { - dir: '/browser-sdk', - endpoint: 'flashduty-public.oss-cn-beijing.aliyuncs.com', - cdnURL: 'static.flashcat.cloud', - }, - staging: { - dir: '/browser-sdk-staging', - endpoint: 'flashduty-public.oss-cn-beijing.aliyuncs.com', - cdnURL: 'static.flashcat.cloud', - }, -} +const { buildBundleFolder, packages, ossEnvironments } = require('./lib/deploymentUtils') const client = new OSS({ region: process.env.OSS_REGION, @@ -47,18 +34,18 @@ if (require.main === module) { } async function main(env, version) { - const awsConfig = AWS_CONFIG[env] + const ossConfig = ossEnvironments[env] let cloudfrontPathsToInvalidate = [] for (const { packageName } of packages) { - const pathsToInvalidate = await uploadPackage(awsConfig, packageName, version) + const pathsToInvalidate = await uploadPackage(ossConfig, packageName, version) cloudfrontPathsToInvalidate.push(...pathsToInvalidate) } await refreshCdnCache(cloudfrontPathsToInvalidate) } // 读取bundle文件夹,上传到ali oss,并刷新cdn缓存 -async function uploadPackage(awsConfig, packageName, version) { +async function uploadPackage(ossConfig, packageName, version) { const cloudfrontPathsToInvalidate = [] const bundleFolder = buildBundleFolder(packageName) @@ -68,11 +55,11 @@ async function uploadPackage(awsConfig, packageName, version) { } const relativeBundlePath = bundlePath.replace(`${bundleFolder}/`, '') - const uploadPath = generateUploadPath(awsConfig, relativeBundlePath, version) + const uploadPath = generateUploadPath(ossConfig, relativeBundlePath, version) // 上传到ali oss const uploadResult = await client.put(uploadPath, bundlePath) // 自有域名 - const ownDomainUrl = uploadResult.url.replace(awsConfig.endpoint, awsConfig.cdnURL) + const ownDomainUrl = uploadResult.url.replace(ossConfig.endpoint, ossConfig.cdnURL) printLog(`成功将 ${bundlePath} 上传到 ${uploadPath},开始刷新缓存`) cloudfrontPathsToInvalidate.push(ownDomainUrl) }) @@ -80,8 +67,8 @@ async function uploadPackage(awsConfig, packageName, version) { return cloudfrontPathsToInvalidate } // ex: /browser-sdk/v4/datadog-rum.js -function generateUploadPath(awsConfig, relativeBundlePath, version) { - return `${awsConfig.dir}/${version}/${relativeBundlePath}` +function generateUploadPath(ossConfig, relativeBundlePath, version) { + return `${ossConfig.dir}/${version}/${relativeBundlePath}` } async function refreshCdnCache(ossFilePath) { diff --git a/scripts/deploy/lib/deploymentUtils.js b/scripts/deploy/lib/deploymentUtils.js index 1d73395397..22ec44b14a 100644 --- a/scripts/deploy/lib/deploymentUtils.js +++ b/scripts/deploy/lib/deploymentUtils.js @@ -1,10 +1,28 @@ +// bundleFilename is the entry file webpack emits (see packages/*/webpack.config.js). The upload +// side never needs it — it walks the bundle folder — but sync-bundles.js downloads by name because +// the bucket cannot be listed without credentials. const packages = [ - { packageName: 'logs', service: 'browser-logs-sdk' }, - { packageName: 'rum', service: 'browser-rum-sdk' }, - { packageName: 'rum-slim', service: 'browser-rum-sdk' }, - { packageName: 'rum-legacy', service: 'browser-rum-sdk' }, + { packageName: 'logs', service: 'browser-logs-sdk', bundleFilename: 'flashcat-logs.js' }, + { packageName: 'rum', service: 'browser-rum-sdk', bundleFilename: 'flashcat-rum.js' }, + { packageName: 'rum-slim', service: 'browser-rum-sdk', bundleFilename: 'flashcat-rum-slim.js' }, + { packageName: 'rum-legacy', service: 'browser-rum-sdk', bundleFilename: 'fc-rum-legacy.js' }, ] +// Bucket layout and public domain, shared by the upload (deploy-oss.js) and download +// (sync-bundles.js) sides so the two cannot drift apart. +const ossEnvironments = { + prod: { + dir: '/browser-sdk', + endpoint: 'flashduty-public.oss-cn-beijing.aliyuncs.com', + cdnURL: 'static.flashcat.cloud', + }, + staging: { + dir: '/browser-sdk-staging', + endpoint: 'flashduty-public.oss-cn-beijing.aliyuncs.com', + cdnURL: 'static.flashcat.cloud', + }, +} + // ex: datadog-rum-v4.js, chunks/recorder-8d8a8dfab6958424038f-datadog-rum.js const buildRootUploadPath = (filePath, version) => { // We don't suffix chunk names as they are referenced by the main bundle. Renaming them would require updates via Webpack, adding unnecessary complexity for minimal value. @@ -29,6 +47,7 @@ const buildBundleFolder = (packageName) => `packages/${packageName}/bundle` module.exports = { packages, + ossEnvironments, buildRootUploadPath, buildDatacenterUploadPath, buildBundleFolder, diff --git a/scripts/deploy/sync-bundles.js b/scripts/deploy/sync-bundles.js new file mode 100644 index 0000000000..30e457f235 --- /dev/null +++ b/scripts/deploy/sync-bundles.js @@ -0,0 +1,103 @@ +'use strict' + +const fs = require('fs') +const path = require('path') +const { printLog, printError, runMain } = require('../lib/executionUtils') +const { packages, ossEnvironments } = require('./lib/deploymentUtils') + +/** + * Download the deployed bundles from the CDN into a local directory, to serve them from another + * origin. Private deployments self-host every asset — their networks often cannot reach the public + * CDN at all — and this is the supported way to obtain a complete, coherent set. + * + * Usage: + * node sync-bundles.js [env] [version] [outputDir] + * env = prod|staging, defaults to prod + * version = the major-version directory, ex: v0. Defaults to v from lerna.json + * outputDir = defaults to ./cdn-bundles + * + * The script needs no credentials: it downloads over plain HTTPS from the same URLs a page would + * load. The bucket cannot be listed that way, so the entry bundles are fetched by name (declared + * next to the upload configuration in lib/deploymentUtils.js) and the hash-named chunk files are + * recovered from the chunk table webpack embeds in each entry bundle. Downloading chunks by hand is + * exactly the error-prone step this script exists to remove. + */ + +// Matches the webpack runtime's chunk url construction, ex: +// "chunks/"+t+"-"+{recorder:"1d21...",profiler:"617a..."}[t]+"-flashcat-rum.js" +const CHUNK_TABLE_RE = /"chunks\/"\+\w+\+"-"\+(\{[^{}]*\})\[\w+\]\+"-([\w.-]+\.js)"/ +const CHUNK_ENTRY_RE = /([\w$]+):"([a-f0-9]+)"/g + +if (require.main === module) { + const env = process.argv[2] || 'prod' + const version = process.argv[3] || `v${require('../../lerna.json').version.split('.')[0]}` + const outputDir = process.argv[4] || './cdn-bundles' + + const ossConfig = ossEnvironments[env] + if (!ossConfig) { + printError(`Unknown env "${env}", expected one of: ${Object.keys(ossEnvironments).join(', ')}`) + process.exit(1) + } + + runMain(async () => { + await main(ossConfig, version, outputDir) + }) +} + +async function main(ossConfig, version, outputDir) { + const baseUrl = `https://${ossConfig.cdnURL}${ossConfig.dir}/${version}` + const failures = [] + + for (const { bundleFilename } of packages) { + const content = await download(baseUrl, bundleFilename, outputDir, failures) + if (content === undefined) { + continue + } + + for (const chunkPath of extractChunkPaths(content)) { + await download(baseUrl, chunkPath, outputDir, failures) + } + } + + if (failures.length > 0) { + printError('Some files could not be downloaded:') + for (const failure of failures) { + printError(` - ${failure}`) + } + printError('The output directory is incomplete, do not deploy it.') + process.exit(1) + } + + printLog(`\nDone. Serve the content of ${outputDir} and load the bundles from that origin.`) +} + +async function download(baseUrl, filePath, outputDir, failures) { + const url = `${baseUrl}/${filePath}` + const response = await fetch(url) + + if (!response.ok) { + failures.push(`${url} (HTTP ${response.status})`) + return undefined + } + + const content = Buffer.from(await response.arrayBuffer()) + const outputPath = path.join(outputDir, filePath) + fs.mkdirSync(path.dirname(outputPath), { recursive: true }) + fs.writeFileSync(outputPath, content) + printLog(`✅ ${url} → ${outputPath} (${content.length} bytes)`) + return content.toString('utf-8') +} + +function extractChunkPaths(bundleContent) { + const table = CHUNK_TABLE_RE.exec(bundleContent) + if (!table) { + return [] + } + + const [, entries, entryFilename] = table + const chunkPaths = [] + for (const [, chunkName, hash] of entries.matchAll(CHUNK_ENTRY_RE)) { + chunkPaths.push(`chunks/${chunkName}-${hash}-${entryFilename}`) + } + return chunkPaths +} From d081a3a419bd515225e31de3f72940eb165b1064 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 18 Aug 2026 05:02:01 -0700 Subject: [PATCH 2/5] docs(rum-legacy): document bundle distribution and real-browser results 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. --- packages/rum-legacy/README.md | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/rum-legacy/README.md b/packages/rum-legacy/README.md index db0afebfcf..a592138de1 100644 --- a/packages/rum-legacy/README.md +++ b/packages/rum-legacy/README.md @@ -35,6 +35,30 @@ hosting page stays untouched. `Object.defineProperty` on plain objects, which IE guarded individually, and the build gate additionally rejects ES3 reserved words used as property names, which those engines cannot even parse and no runtime guard could catch. +## Getting the bundles + +Released bundles are served from the CDN under a major-version directory: + +``` +https://static.flashcat.cloud/browser-sdk/v0/fc-rum-legacy.js +https://static.flashcat.cloud/browser-sdk/v0/flashcat-rum.js +``` + +The directory always holds the latest release of that major version, so re-downloading the same url +is how a self-hosted copy is updated. + +Environments that self-host — the norm for the networks this build targets, where the public CDN is +often unreachable at all — should not pick files by hand: the standard RUM bundle loads hash-named +chunk files that must match it exactly. `scripts/deploy/sync-bundles.js` downloads a complete, +coherent set instead: + +```bash +node scripts/deploy/sync-bundles.js prod v0 ./cdn-bundles +``` + +It needs no credentials, fails loudly if any file is missing, and the resulting directory is served +as-is from the hosting origin — the `` in the snippet below. + ## Setup Both builds share the `FC_RUM` global and the same call sequence, so the page carries one snippet. @@ -180,9 +204,11 @@ Guarantees that could be asserted vacuously are checked by removing the implemen confirming a spec fails: the ES5 gate, the event schema validation, the page exit ordering, the sampling and consent gates, and the listener guards. -That covers missing runtime APIs and unsupported syntax. It does not cover the behaviour of an -actual old browser engine. **This package has not been verified on real hardware**, and that -verification is a separate step before any support commitment is made. +That covers missing runtime APIs and unsupported syntax. The behaviour of the actual engines was +verified separately, on real browsers through a cloud device farm (BrowserStack): IE 9, 10 and 11 +pass every check in the verification page below, including the two that only mean anything on a +real Trident engine, and IE 6 and IE 8 were confirmed to degrade to a silent no-op that leaves the +hosting page untouched. ## Verifying on a real browser From ea7c2faceef6f538b3d92fdfe4db296c5f065974 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 20:07:11 -0700 Subject: [PATCH 3/5] fix(deploy): make the bundle sync keep the promise it prints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/deploy/sync-bundles.js | 56 +++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/scripts/deploy/sync-bundles.js b/scripts/deploy/sync-bundles.js index 30e457f235..1eb35ed85f 100644 --- a/scripts/deploy/sync-bundles.js +++ b/scripts/deploy/sync-bundles.js @@ -26,7 +26,10 @@ const { packages, ossEnvironments } = require('./lib/deploymentUtils') // Matches the webpack runtime's chunk url construction, ex: // "chunks/"+t+"-"+{recorder:"1d21...",profiler:"617a..."}[t]+"-flashcat-rum.js" const CHUNK_TABLE_RE = /"chunks\/"\+\w+\+"-"\+(\{[^{}]*\})\[\w+\]\+"-([\w.-]+\.js)"/ -const CHUNK_ENTRY_RE = /([\w$]+):"([a-f0-9]+)"/g +// Webpack quotes a key that is not a valid identifier, so both shapes have to match. A chunk this +// misses would be silently absent from the output rather than reported, which is the one failure +// this script must not have. +const CHUNK_ENTRY_RE = /(?:"([^"]+)"|([\w$]+)):"([a-f0-9]+)"/g if (require.main === module) { const env = process.argv[2] || 'prod' @@ -48,14 +51,31 @@ async function main(ossConfig, version, outputDir) { const baseUrl = `https://${ossConfig.cdnURL}${ossConfig.dir}/${version}` const failures = [] + /* + * Downloaded into a directory named for being unfinished, and moved into place only once every + * file is there. A run that is interrupted — Ctrl-C, a CI timeout, a killed process — never gets + * to print its warning, so the only thing left to tell a complete set from a half-written one is + * the name on disk. An entry bundle without its chunks looks entirely normal otherwise. + * + * An existing output directory is refused rather than merged into or deleted: merging would + * leave the chunks of an older version alongside the new ones, and deleting a directory the + * operator named is not this script's call to make. + */ + if (fs.existsSync(outputDir)) { + printError(`${outputDir} already exists. Remove it, or pass a different output directory.`) + process.exit(1) + } + const stagingDir = `${outputDir}.incomplete` + fs.rmSync(stagingDir, { recursive: true, force: true }) + for (const { bundleFilename } of packages) { - const content = await download(baseUrl, bundleFilename, outputDir, failures) + const content = await download(baseUrl, bundleFilename, stagingDir, failures) if (content === undefined) { continue } for (const chunkPath of extractChunkPaths(content)) { - await download(baseUrl, chunkPath, outputDir, failures) + await download(baseUrl, chunkPath, stagingDir, failures) } } @@ -64,16 +84,27 @@ async function main(ossConfig, version, outputDir) { for (const failure of failures) { printError(` - ${failure}`) } - printError('The output directory is incomplete, do not deploy it.') + printError(`Left in ${stagingDir}, which is incomplete. Do not deploy it.`) process.exit(1) } + fs.renameSync(stagingDir, outputDir) printLog(`\nDone. Serve the content of ${outputDir} and load the bundles from that origin.`) } async function download(baseUrl, filePath, outputDir, failures) { const url = `${baseUrl}/${filePath}` - const response = await fetch(url) + + let response + try { + response = await fetch(url) + } catch (error) { + // A refused connection or a DNS failure is the expected shape of trouble here: this script runs + // for people whose network cannot reach much. Letting it throw would skip the summary below and + // the "do not deploy" warning, leaving a stack trace as the only thing the operator sees. + failures.push(`${url} (${error.message})`) + return undefined + } if (!response.ok) { failures.push(`${url} (HTTP ${response.status})`) @@ -96,8 +127,19 @@ function extractChunkPaths(bundleContent) { const [, entries, entryFilename] = table const chunkPaths = [] - for (const [, chunkName, hash] of entries.matchAll(CHUNK_ENTRY_RE)) { - chunkPaths.push(`chunks/${chunkName}-${hash}-${entryFilename}`) + for (const [, quotedName, bareName, hash] of entries.matchAll(CHUNK_ENTRY_RE)) { + chunkPaths.push(`chunks/${quotedName ?? bareName}-${hash}-${entryFilename}`) + } + + // A chunk the pattern above failed to read would go missing without ever being attempted, and so + // without ever reaching the failure list. Counting the entries in the table is the cheap way to + // notice that, and this is a directory someone is about to serve to their users. + const declared = (entries.match(/:"/g) || []).length + if (chunkPaths.length !== declared) { + throw new Error( + `Read ${chunkPaths.length} of ${declared} chunk names from ${entryFilename}; the pattern needs updating` + ) } + return chunkPaths } From 0bbf9e8b32c9c42bfb8bd277cafb38e748f1b832 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 20:12:59 -0700 Subject: [PATCH 4/5] refactor(deploy): leave the release path alone in the sync script 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. --- scripts/deploy/deploy-oss.js | 29 +++++++++++++++++------- scripts/deploy/lib/deploymentUtils.js | 27 ++++------------------ scripts/deploy/sync-bundles.js | 32 ++++++++++++++++++++------- 3 files changed, 49 insertions(+), 39 deletions(-) diff --git a/scripts/deploy/deploy-oss.js b/scripts/deploy/deploy-oss.js index 30bbe903d5..a4ed765a74 100644 --- a/scripts/deploy/deploy-oss.js +++ b/scripts/deploy/deploy-oss.js @@ -4,7 +4,20 @@ const OpenApi = require('@alicloud/openapi-client') const { printLog, printError, runMain } = require('../lib/executionUtils') const { forEachFile } = require('../lib/filesUtils') -const { buildBundleFolder, packages, ossEnvironments } = require('./lib/deploymentUtils') +const { buildBundleFolder, packages } = require('./lib/deploymentUtils') + +const AWS_CONFIG = { + prod: { + dir: '/browser-sdk', + endpoint: 'flashduty-public.oss-cn-beijing.aliyuncs.com', + cdnURL: 'static.flashcat.cloud', + }, + staging: { + dir: '/browser-sdk-staging', + endpoint: 'flashduty-public.oss-cn-beijing.aliyuncs.com', + cdnURL: 'static.flashcat.cloud', + }, +} const client = new OSS({ region: process.env.OSS_REGION, @@ -34,18 +47,18 @@ if (require.main === module) { } async function main(env, version) { - const ossConfig = ossEnvironments[env] + const awsConfig = AWS_CONFIG[env] let cloudfrontPathsToInvalidate = [] for (const { packageName } of packages) { - const pathsToInvalidate = await uploadPackage(ossConfig, packageName, version) + const pathsToInvalidate = await uploadPackage(awsConfig, packageName, version) cloudfrontPathsToInvalidate.push(...pathsToInvalidate) } await refreshCdnCache(cloudfrontPathsToInvalidate) } // 读取bundle文件夹,上传到ali oss,并刷新cdn缓存 -async function uploadPackage(ossConfig, packageName, version) { +async function uploadPackage(awsConfig, packageName, version) { const cloudfrontPathsToInvalidate = [] const bundleFolder = buildBundleFolder(packageName) @@ -55,11 +68,11 @@ async function uploadPackage(ossConfig, packageName, version) { } const relativeBundlePath = bundlePath.replace(`${bundleFolder}/`, '') - const uploadPath = generateUploadPath(ossConfig, relativeBundlePath, version) + const uploadPath = generateUploadPath(awsConfig, relativeBundlePath, version) // 上传到ali oss const uploadResult = await client.put(uploadPath, bundlePath) // 自有域名 - const ownDomainUrl = uploadResult.url.replace(ossConfig.endpoint, ossConfig.cdnURL) + const ownDomainUrl = uploadResult.url.replace(awsConfig.endpoint, awsConfig.cdnURL) printLog(`成功将 ${bundlePath} 上传到 ${uploadPath},开始刷新缓存`) cloudfrontPathsToInvalidate.push(ownDomainUrl) }) @@ -67,8 +80,8 @@ async function uploadPackage(ossConfig, packageName, version) { return cloudfrontPathsToInvalidate } // ex: /browser-sdk/v4/datadog-rum.js -function generateUploadPath(ossConfig, relativeBundlePath, version) { - return `${ossConfig.dir}/${version}/${relativeBundlePath}` +function generateUploadPath(awsConfig, relativeBundlePath, version) { + return `${awsConfig.dir}/${version}/${relativeBundlePath}` } async function refreshCdnCache(ossFilePath) { diff --git a/scripts/deploy/lib/deploymentUtils.js b/scripts/deploy/lib/deploymentUtils.js index 22ec44b14a..1d73395397 100644 --- a/scripts/deploy/lib/deploymentUtils.js +++ b/scripts/deploy/lib/deploymentUtils.js @@ -1,28 +1,10 @@ -// bundleFilename is the entry file webpack emits (see packages/*/webpack.config.js). The upload -// side never needs it — it walks the bundle folder — but sync-bundles.js downloads by name because -// the bucket cannot be listed without credentials. const packages = [ - { packageName: 'logs', service: 'browser-logs-sdk', bundleFilename: 'flashcat-logs.js' }, - { packageName: 'rum', service: 'browser-rum-sdk', bundleFilename: 'flashcat-rum.js' }, - { packageName: 'rum-slim', service: 'browser-rum-sdk', bundleFilename: 'flashcat-rum-slim.js' }, - { packageName: 'rum-legacy', service: 'browser-rum-sdk', bundleFilename: 'fc-rum-legacy.js' }, + { packageName: 'logs', service: 'browser-logs-sdk' }, + { packageName: 'rum', service: 'browser-rum-sdk' }, + { packageName: 'rum-slim', service: 'browser-rum-sdk' }, + { packageName: 'rum-legacy', service: 'browser-rum-sdk' }, ] -// Bucket layout and public domain, shared by the upload (deploy-oss.js) and download -// (sync-bundles.js) sides so the two cannot drift apart. -const ossEnvironments = { - prod: { - dir: '/browser-sdk', - endpoint: 'flashduty-public.oss-cn-beijing.aliyuncs.com', - cdnURL: 'static.flashcat.cloud', - }, - staging: { - dir: '/browser-sdk-staging', - endpoint: 'flashduty-public.oss-cn-beijing.aliyuncs.com', - cdnURL: 'static.flashcat.cloud', - }, -} - // ex: datadog-rum-v4.js, chunks/recorder-8d8a8dfab6958424038f-datadog-rum.js const buildRootUploadPath = (filePath, version) => { // We don't suffix chunk names as they are referenced by the main bundle. Renaming them would require updates via Webpack, adding unnecessary complexity for minimal value. @@ -47,7 +29,6 @@ const buildBundleFolder = (packageName) => `packages/${packageName}/bundle` module.exports = { packages, - ossEnvironments, buildRootUploadPath, buildDatacenterUploadPath, buildBundleFolder, diff --git a/scripts/deploy/sync-bundles.js b/scripts/deploy/sync-bundles.js index 1eb35ed85f..660d71632b 100644 --- a/scripts/deploy/sync-bundles.js +++ b/scripts/deploy/sync-bundles.js @@ -3,7 +3,23 @@ const fs = require('fs') const path = require('path') const { printLog, printError, runMain } = require('../lib/executionUtils') -const { packages, ossEnvironments } = require('./lib/deploymentUtils') + +/* + * Where the release lands, mirrored from deploy-oss.js rather than shared with it. This script only + * reads what that one publishes, and a download tool has no business editing the release path to + * get its bearings. If the bucket layout ever moves, these move with it. + */ +const CDN_HOST = 'static.flashcat.cloud' +const CDN_DIRECTORIES = { + prod: '/browser-sdk', + staging: '/browser-sdk-staging', +} + +/* + * The entry file each package emits, from its own webpack.config.js. Fetched by name because the + * bucket cannot be listed without credentials, which this script deliberately does not use. + */ +const ENTRY_BUNDLES = ['flashcat-logs.js', 'flashcat-rum.js', 'flashcat-rum-slim.js', 'fc-rum-legacy.js'] /** * Download the deployed bundles from the CDN into a local directory, to serve them from another @@ -36,19 +52,19 @@ if (require.main === module) { const version = process.argv[3] || `v${require('../../lerna.json').version.split('.')[0]}` const outputDir = process.argv[4] || './cdn-bundles' - const ossConfig = ossEnvironments[env] - if (!ossConfig) { - printError(`Unknown env "${env}", expected one of: ${Object.keys(ossEnvironments).join(', ')}`) + const directory = CDN_DIRECTORIES[env] + if (!directory) { + printError(`Unknown env "${env}", expected one of: ${Object.keys(CDN_DIRECTORIES).join(', ')}`) process.exit(1) } runMain(async () => { - await main(ossConfig, version, outputDir) + await main(directory, version, outputDir) }) } -async function main(ossConfig, version, outputDir) { - const baseUrl = `https://${ossConfig.cdnURL}${ossConfig.dir}/${version}` +async function main(directory, version, outputDir) { + const baseUrl = `https://${CDN_HOST}${directory}/${version}` const failures = [] /* @@ -68,7 +84,7 @@ async function main(ossConfig, version, outputDir) { const stagingDir = `${outputDir}.incomplete` fs.rmSync(stagingDir, { recursive: true, force: true }) - for (const { bundleFilename } of packages) { + for (const bundleFilename of ENTRY_BUNDLES) { const content = await download(baseUrl, bundleFilename, stagingDir, failures) if (content === undefined) { continue From ddd57e0a4980cb5167cbfc06c588c79223eefb6b Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 20:28:52 -0700 Subject: [PATCH 5/5] fix(deploy): close the paths where the sync gave up quietly 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. --- scripts/deploy/sync-bundles.js | 36 ++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/scripts/deploy/sync-bundles.js b/scripts/deploy/sync-bundles.js index 660d71632b..80fe2b8688 100644 --- a/scripts/deploy/sync-bundles.js +++ b/scripts/deploy/sync-bundles.js @@ -34,7 +34,7 @@ const ENTRY_BUNDLES = ['flashcat-logs.js', 'flashcat-rum.js', 'flashcat-rum-slim * * The script needs no credentials: it downloads over plain HTTPS from the same URLs a page would * load. The bucket cannot be listed that way, so the entry bundles are fetched by name (declared - * next to the upload configuration in lib/deploymentUtils.js) and the hash-named chunk files are + * at the top of this file) and the hash-named chunk files are * recovered from the chunk table webpack embeds in each entry bundle. Downloading chunks by hand is * exactly the error-prone step this script exists to remove. */ @@ -77,6 +77,9 @@ async function main(directory, version, outputDir) { * leave the chunks of an older version alongside the new ones, and deleting a directory the * operator named is not this script's call to make. */ + // Resolved first: a trailing slash would otherwise put the staging directory inside the output + // directory, where the rename cannot land and where the guard above then blocks every re-run. + outputDir = path.resolve(outputDir) if (fs.existsSync(outputDir)) { printError(`${outputDir} already exists. Remove it, or pass a different output directory.`) process.exit(1) @@ -127,17 +130,38 @@ async function download(baseUrl, filePath, outputDir, failures) { return undefined } - const content = Buffer.from(await response.arrayBuffer()) const outputPath = path.join(outputDir, filePath) - fs.mkdirSync(path.dirname(outputPath), { recursive: true }) - fs.writeFileSync(outputPath, content) - printLog(`✅ ${url} → ${outputPath} (${content.length} bytes)`) - return content.toString('utf-8') + // A chunk name is read out of a downloaded file, so it decides where this writes. Nothing may + // land outside the directory the operator named, however the name reached us. + if (path.relative(outputDir, outputPath).startsWith('..')) { + failures.push(`${url} (resolves outside the output directory)`) + return undefined + } + + try { + // Reading the body and writing it are inside the same net as the request. A connection that + // drops mid-body, or a disk that fills up, is the same kind of trouble as one that never + // connects, and it has to reach the summary rather than end the run with a stack trace. + const content = Buffer.from(await response.arrayBuffer()) + fs.mkdirSync(path.dirname(outputPath), { recursive: true }) + fs.writeFileSync(outputPath, content) + printLog(`✅ ${url} → ${outputPath} (${content.length} bytes)`) + return content.toString('utf-8') + } catch (error) { + failures.push(`${url} (${error.message})`) + return undefined + } } function extractChunkPaths(bundleContent) { const table = CHUNK_TABLE_RE.exec(bundleContent) if (!table) { + // No table and no chunks is the normal case for most of these bundles. No table in a bundle + // that plainly loads chunks means the pattern has fallen behind the emitted runtime, and the + // chunks would go missing without ever being attempted - and so without ever being reported. + if (bundleContent.indexOf('chunks/') !== -1) { + throw new Error('This bundle loads chunks but its chunk table did not match; the pattern needs updating') + } return [] }