diff --git a/package-lock.json b/package-lock.json index 5524d936..db721641 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6373,8 +6373,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/available-typed-arrays": { "version": "1.0.7", @@ -6404,7 +6403,6 @@ "version": "1.7.7", "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", - "dev": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -7346,7 +7344,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -8267,7 +8264,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -9536,7 +9532,6 @@ "version": "1.15.6", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", - "dev": true, "funding": [ { "type": "individual", @@ -9575,10 +9570,9 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -14685,8 +14679,7 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/psl": { "version": "1.9.0", @@ -17930,6 +17923,8 @@ "version": "0.19.1", "license": "Apache-2.0", "dependencies": { + "axios": "^1.7.7", + "form-data": "^4.0.1", "unplugin": "^1.14.1" }, "devDependencies": { diff --git a/packages/build-plugins/package.json b/packages/build-plugins/package.json index 8727e572..b646c978 100644 --- a/packages/build-plugins/package.json +++ b/packages/build-plugins/package.json @@ -28,6 +28,8 @@ "dist/esm/**/*.d.ts" ], "dependencies": { + "axios": "^1.7.7", + "form-data": "^4.0.1", "unplugin": "^1.14.1" }, "devDependencies": { diff --git a/packages/build-plugins/src/httpUtils.ts b/packages/build-plugins/src/httpUtils.ts new file mode 100644 index 00000000..6d28eeca --- /dev/null +++ b/packages/build-plugins/src/httpUtils.ts @@ -0,0 +1,46 @@ +/* +Copyright 2025 Splunk Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import axios from 'axios'; +import { createReadStream } from 'fs'; +import * as FormData from 'form-data'; + +interface FileUpload { + filePath: string; + fieldName: string; +} + +interface UploadOptions { + url: string; + file: FileUpload; + parameters: { [key: string]: string | number }; +} + +export const uploadFile = async ({ url, file, parameters }: UploadOptions): Promise => { + const formData = new FormData(); + + formData.append(file.fieldName, createReadStream(file.filePath)); + + for (const [ key, value ] of Object.entries(parameters)) { + formData.append(key, value); + } + + await axios.put(url, formData, { + headers: { + ...formData.getHeaders(), + } + }); +}; diff --git a/packages/build-plugins/src/index.ts b/packages/build-plugins/src/index.ts index fd593629..4cdcee26 100644 --- a/packages/build-plugins/src/index.ts +++ b/packages/build-plugins/src/index.ts @@ -16,17 +16,40 @@ limitations under the License. import { createUnplugin, UnpluginFactory } from 'unplugin'; -import { BannerPlugin, WebpackPluginInstance } from 'webpack'; -import { computeSourceMapId, getCodeSnippet } from './utils'; +import type { WebpackPluginInstance } from 'webpack'; +import { + computeSourceMapId, + getCodeSnippet, JS_FILE_REGEX, + PLUGIN_NAME +} from './utils'; +import { applySourceMapsUpload } from './webpack'; export interface OllyWebPluginOptions { - // define your plugin options here + /** Plugin configuration for source map ID injection and source map file uploads */ + sourceMaps: { + /** the Splunk Observability realm */ + realm: string; + + /** API token used to authenticate the file upload requests. This is not the same as the rumAccessToken used in SplunkRum.init(). */ + token: string; + + /** Optional. If provided, this should match the "applicationName" used where SplunkRum.init() is called. */ + applicationName?: string; + + /** Optional. If provided, this should match the "version" used where SplunkRum.init() is called. */ + version?: string; + + /** Optional. If true, the plugin will inject source map IDs into the final JavaScript bundles, but it will not upload any source map files. */ + disableUpload?: boolean; + } } -const unpluginFactory: UnpluginFactory = () => ({ - name: 'OllyWebPlugin', +const unpluginFactory: UnpluginFactory = (options) => ({ + name: PLUGIN_NAME, webpack(compiler) { - compiler.hooks.thisCompilation.tap('OllyWebPlugin', () => { + const { webpack } = compiler; + const { BannerPlugin } = webpack; + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, () => { const bannerPlugin = new BannerPlugin({ banner: ({ chunk }) => { if (!chunk.hash) { @@ -37,11 +60,15 @@ const unpluginFactory: UnpluginFactory = () => }, entryOnly: false, footer: true, - include: /\.(js|mjs)$/, + include: JS_FILE_REGEX, raw: true, }); bannerPlugin.apply(compiler); }); + + if (!options.sourceMaps.disableUpload) { + applySourceMapsUpload(compiler, options); + } } }); diff --git a/packages/build-plugins/src/utils.ts b/packages/build-plugins/src/utils.ts index 261c827b..45f123ec 100644 --- a/packages/build-plugins/src/utils.ts +++ b/packages/build-plugins/src/utils.ts @@ -14,8 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ - import { createHash } from 'crypto'; +import { createReadStream } from 'fs'; + +export const PLUGIN_NAME = 'OllyWebPlugin'; + +export const JS_FILE_REGEX = /\.(js|cjs|mjs)$/; + +function shaToSourceMapId(sha: string) { + return [ + sha.slice(0, 8), + sha.slice(8, 12), + sha.slice(12, 16), + sha.slice(16, 20), + sha.slice(20, 32), + ].join('-'); +} /** * Returns a standardized GUID value to use for a sourceMapId. @@ -26,14 +40,19 @@ export function computeSourceMapId(content: string): string { const sha256 = createHash('sha256') .update(content, 'utf-8') .digest('hex'); - const guid = [ - sha256.slice(0, 8), - sha256.slice(8, 12), - sha256.slice(12, 16), - sha256.slice(16, 20), - sha256.slice(20, 32), - ].join('-'); - return guid; + return shaToSourceMapId(sha256); +} + +export async function computeSourceMapIdFromFile(sourceMapFilePath: string): Promise { + const hash = createHash('sha256').setEncoding('hex'); + + const fileStream = createReadStream(sourceMapFilePath); + for await (const chunk of fileStream) { + hash.update(chunk); + } + + const sha = hash.digest('hex'); + return shaToSourceMapId(sha); } // eslint-disable-next-line quotes @@ -43,3 +62,7 @@ export function getCodeSnippet(sourceMapId: string): string { return SNIPPET_TEMPLATE.replace('__SOURCE_MAP_ID_PLACEHOLDER__', sourceMapId); } +export function getSourceMapUploadUrl(realm: string, idPathParam: string): string { + const API_BASE_URL = process.env.O11Y_API_BASE_URL || `https://api.${realm}.signalfx.com`; + return `${API_BASE_URL}/v1/sourcemaps/id/${idPathParam}`; +} diff --git a/packages/build-plugins/src/webpack/index.ts b/packages/build-plugins/src/webpack/index.ts new file mode 100644 index 00000000..282abf59 --- /dev/null +++ b/packages/build-plugins/src/webpack/index.ts @@ -0,0 +1,116 @@ +/* +Copyright 2025 Splunk Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { computeSourceMapIdFromFile, getSourceMapUploadUrl, JS_FILE_REGEX, PLUGIN_NAME } from '../utils'; +import { join } from 'path'; +import { uploadFile } from '../httpUtils'; +import { AxiosError } from 'axios'; +import type { Compiler } from 'webpack'; +import { OllyWebPluginOptions } from '../index'; + +/** + * The part of the webpack plugin responsible for uploading source maps from the output directory. + */ +export function applySourceMapsUpload(compiler: Compiler, options: OllyWebPluginOptions): void { + const logger = compiler.getInfrastructureLogger(PLUGIN_NAME); + + compiler.hooks.afterEmit.tapAsync( + PLUGIN_NAME, + async (compilation, callback) => { + /* + * find JS assets' related source map assets, and collect them to an array + */ + const sourceMaps = []; + compilation.assetsInfo.forEach(((assetInfo, asset) => { + if (asset.match(JS_FILE_REGEX) && typeof assetInfo?.related?.sourceMap === 'string') { + sourceMaps.push(assetInfo.related.sourceMap); + } + })); + + if (sourceMaps.length > 0) { + logger.info('Uploading %d source maps to %s', sourceMaps.length, getSourceMapUploadUrl(options.sourceMaps.realm, '{id}')); + } else { + logger.warn('No source maps found.'); + logger.warn('Make sure that source maps are enabled to utilize the %s plugin.', PLUGIN_NAME); + } + + const uploadResults = { + success: 0, + failed: 0, + }; + const parameters = Object.fromEntries([ + ['appName', options.sourceMaps.applicationName], + ['appVersion', options.sourceMaps.version], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ].filter(([_, value]) => typeof value !== 'undefined')); + + /* + * resolve the source map assets to a file system path, then upload the files + */ + for (const sourceMap of sourceMaps) { + const sourceMapPath = join(compiler.outputPath, sourceMap); + const sourceMapId = await computeSourceMapIdFromFile(sourceMapPath); + const url = getSourceMapUploadUrl(options.sourceMaps.realm, sourceMapId); + + logger.log('Uploading %s', sourceMap); + logger.debug('PUT', url); + try { + logger.status(new Array(uploadResults.success).fill('.').join('')); + await uploadFile({ + file: { + filePath: sourceMapPath, + fieldName: 'file', + }, + url, + parameters + }); + uploadResults.success++; + } catch (e) { + uploadResults.failed++; + const ae = e as AxiosError; + + const unableToUploadMessage = `Unable to upload ${sourceMapPath}`; + if (ae.response && ae.response.status === 413) { + logger.warn(ae.response.status, ae.response.statusText); + logger.warn(unableToUploadMessage); + } else if (ae.response) { + logger.error(ae.response.status, ae.response.statusText); + logger.error(ae.response.data); + logger.error(unableToUploadMessage); + } else if (ae.request) { + logger.error(`Response from ${url} was not received`); + logger.error(ae.cause); + logger.error(unableToUploadMessage); + } else { + logger.error(`Request to ${url} could not be sent`); + logger.error(e); + logger.error(unableToUploadMessage); + } + } + } + + if (uploadResults.success > 0) { + logger.info('Successfully uploaded %d source map(s)', uploadResults.success); + } + if (uploadResults.failed > 0) { + logger.error('Failed to upload %d source map(s)', uploadResults.failed); + } + + logger.status('Uploading finished\n'); + callback(); + } + ); +}