diff --git a/.eslintrc.js b/.eslintrc.js index e197bbf95271..f7f62d2bedd9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -433,7 +433,6 @@ module.exports = { 'packages/*/npm/**/*.js', 'packages/dom-event-testing-library/**/*.js', 'packages/react-devtools*/**/*.js', - 'dangerfile.js', 'fixtures', 'packages/react-dom/src/test-utils/*.js', ], diff --git a/.github/workflows/runtime_build_and_test.yml b/.github/workflows/runtime_build_and_test.yml index 2c91491da19e..8789cd75e8fc 100644 --- a/.github/workflows/runtime_build_and_test.yml +++ b/.github/workflows/runtime_build_and_test.yml @@ -1,3 +1,7 @@ +# Keep this name in sync with the `workflow_run.workflows` list in +# runtime_sizebot_comment.yml, which matches on this exact string rather than on +# the file name. Renaming it here alone stops sizebot from ever commenting again, +# and nothing fails: the comment workflow simply never triggers. name: (Runtime) Build and Test on: @@ -930,10 +934,15 @@ jobs: - name: Display structure of build for PR run: ls -R build - run: echo ${{ github.event.pull_request.head.sha || github.sha }} >> build/COMMIT_SHA - - run: node ./scripts/tasks/danger + - name: Measure size changes + # Only measures and records the numbers. The comment is rendered and + # posted by runtime_sizebot_comment.yml, which runs on the workflow_run + # trigger because this job's token is read-only for pull requests from + # forks and so cannot comment. + run: node ./scripts/sizebot/compare-sizes.js - name: Archive sizebot results uses: actions/upload-artifact@v4 with: - name: sizebot-message - path: sizebot-message.md - if-no-files-found: ignore + name: sizebot-results + path: sizebot-results.json + if-no-files-found: error diff --git a/.github/workflows/runtime_sizebot_comment.yml b/.github/workflows/runtime_sizebot_comment.yml new file mode 100644 index 000000000000..5d6909650f41 --- /dev/null +++ b/.github/workflows/runtime_sizebot_comment.yml @@ -0,0 +1,105 @@ +name: (Runtime) Sizebot Comment + +# Posts the build size comparison comment on pull requests. +# +# This has to be a separate `workflow_run` workflow rather than a job inside +# (Runtime) Build and Test: that workflow runs on the `pull_request` trigger, so +# a pull request from a fork gets a read-only token and cannot comment. A +# `workflow_run` workflow always runs in the context of this repository, on the +# default branch, with a writable token. +# +# The measurement happens on the other side of that boundary, in the unprivileged +# sizebot job, which uploads a `sizebot-results` artifact. This workflow only +# downloads that small JSON file and renders it. It deliberately never unpacks a +# build produced by a fork, because it holds a token that can write to the +# repository. + +on: + workflow_run: + workflows: ['(Runtime) Build and Test'] + types: [requested, completed] + +permissions: {} + +concurrency: + # Serialize per pull request. Both the requested and completed handlers read + # the existing comment, decide against it and write it back, so they must not + # interleave. Never cancel: every event either updates the comment or is + # deliberately skipped, and dropping one loses a state transition. + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + +env: + TZ: /usr/share/zoneinfo/America/Los_Angeles + +jobs: + comment: + # Only pull request builds get a size comment. Runs from `push` and + # `workflow_dispatch` have no pull request to comment on. + if: ${{ github.event.workflow_run.event == 'pull_request' }} + name: Comment with size changes + runs-on: ubuntu-latest + permissions: + # We use github.token to download the sizebot results artifact from the + # triggering runtime_build_and_test.yml run + actions: read + # Used to check out the renderer this workflow runs + contents: read + # Used to create and update the sizebot comment on the pull request + pull-requests: write + steps: + # No `ref`, so this is the default branch rather than the pull request. + # The thresholds, the critical bundle list and the comment template all + # come from here and cannot be changed by the pull request being measured. + - uses: actions/checkout@v4 + with: + # This job holds a token that can write to the repository, and it has + # no use for git credentials after the checkout. + persist-credentials: false + + - name: Resolve pull request and existing comment + id: resolve + uses: actions/github-script@v7 + with: + script: | + const {resolve} = require(`${process.env.GITHUB_WORKSPACE}/scripts/sizebot/pull-request-comment.js`); + await resolve({github, context, core}); + + - name: Download sizebot results + if: ${{ steps.resolve.outputs.action == 'continue' && steps.resolve.outputs.download_results == 'true' }} + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: sizebot-results + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Render comment + if: ${{ steps.resolve.outputs.action == 'continue' }} + run: node ./scripts/sizebot/render-comment.js + + - name: Archive full size report + # Only written when the report is too large to fit in a comment, in which + # case the comment links to this artifact. + if: ${{ steps.resolve.outputs.action == 'continue' && hashFiles('sizebot-message.md') != '' }} + uses: actions/upload-artifact@v4 + with: + name: sizebot-message + path: sizebot-message.md + + - name: Post comment + if: ${{ steps.resolve.outputs.action == 'continue' }} + uses: actions/github-script@v7 + with: + script: | + const {post} = require(`${process.env.GITHUB_WORKSPACE}/scripts/sizebot/pull-request-comment.js`); + await post({github, context, core}); + + - name: Fail if the build configuration drifted + # The comment is posted first, so it explains the problem on the pull + # request itself. This step exists so the drift also shows up as a failed + # run rather than only in a comment. + if: ${{ steps.resolve.outputs.action == 'continue' && hashFiles('sizebot-problem.txt') != '' }} + run: | + cat sizebot-problem.txt + exit 1 diff --git a/.gitignore b/.gitignore index 9f0da5163e69..223077751e4c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,12 @@ scripts/flow/*/.flowconfig _SpecRunner.html __benchmarks__ build/ +base-build/ +sizebot-comment.md +sizebot-context.json +sizebot-message.md +sizebot-problem.txt +sizebot-results.json remote-repo/ coverage/ .module-cache diff --git a/dangerfile.js b/dangerfile.js deleted file mode 100644 index b7ee95d75f88..000000000000 --- a/dangerfile.js +++ /dev/null @@ -1,282 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -'use strict'; - -/* eslint-disable no-for-of-loops/no-for-of-loops */ - -// Hi, if this is your first time editing/reading a Dangerfile, here's a summary: -// It's a JS runtime which helps you provide continuous feedback inside GitHub. -// -// You can see the docs here: http://danger.systems/js/ -// -// If you want to test changes Danger, I'd recommend checking out an existing PR -// and then running the `danger pr` command. -// -// You'll need a GitHub token, you can re-use this one: -// -// 0a7d5c3cad9a6dbec2d9 9a5222cf49062a4c1ef7 -// -// (Just remove the space) -// -// So, for example: -// -// `DANGER_GITHUB_API_TOKEN=[ENV_ABOVE] yarn danger pr https://github.com/facebook/react/pull/11865 - -const {markdown, danger, warn} = require('danger'); -const {promisify} = require('util'); -const glob = promisify(require('glob')); -const gzipSize = require('gzip-size'); -const {writeFileSync} = require('fs'); - -const {readFileSync, statSync} = require('fs'); - -const BASE_DIR = 'base-build'; -const HEAD_DIR = 'build'; - -const CRITICAL_THRESHOLD = 0.02; -const SIGNIFICANCE_THRESHOLD = 0.002; -const CRITICAL_ARTIFACT_PATHS = new Set([ - // We always report changes to these bundles, even if the change is - // insignificant or non-existent. - 'oss-stable/react-dom/cjs/react-dom.production.js', - 'oss-stable/react-dom/cjs/react-dom-client.production.js', - 'oss-experimental/react-dom/cjs/react-dom.production.js', - 'oss-experimental/react-dom/cjs/react-dom-client.production.js', - 'facebook-www/ReactDOM-prod.classic.js', - 'facebook-www/ReactDOM-prod.modern.js', -]); - -const kilobyteFormatter = new Intl.NumberFormat('en', { - style: 'unit', - unit: 'kilobyte', - minimumFractionDigits: 2, - maximumFractionDigits: 2, -}); - -function kbs(bytes) { - return kilobyteFormatter.format(bytes / 1000); -} - -const percentFormatter = new Intl.NumberFormat('en', { - style: 'percent', - signDisplay: 'exceptZero', - minimumFractionDigits: 2, - maximumFractionDigits: 2, -}); - -function change(decimal) { - if (decimal === Infinity) { - return 'New file'; - } - if (decimal === -1) { - return 'Deleted'; - } - if (decimal < 0.0001) { - return '='; - } - return percentFormatter.format(decimal); -} - -const header = ` - | Name | +/- | Base | Current | +/- gzip | Base gzip | Current gzip | - | ---- | --- | ---- | ------- | -------- | --------- | ------------ |`; - -function row(result, baseSha, headSha) { - const diffViewUrl = `https://react-builds.vercel.app/commits/${headSha}/files/${result.path}?compare=${baseSha}`; - const rowArr = [ - `| [${result.path}](${diffViewUrl})`, - `**${change(result.change)}**`, - `${kbs(result.baseSize)}`, - `${kbs(result.headSize)}`, - `${change(result.changeGzip)}`, - `${kbs(result.baseSizeGzip)}`, - `${kbs(result.headSizeGzip)}`, - ]; - return rowArr.join(' | '); -} - -(async function () { - // Use git locally to grab the commit which represents the place - // where the branches differ - - const upstreamRepo = danger.github.pr.base.repo.full_name; - if (upstreamRepo !== 'react/react') { - // Exit unless we're running in the main repo - return; - } - - let headSha; - let baseSha; - try { - headSha = String(readFileSync(HEAD_DIR + '/COMMIT_SHA')).trim(); - baseSha = String(readFileSync(BASE_DIR + '/COMMIT_SHA')).trim(); - } catch { - warn( - "Failed to read build artifacts. It's possible a build configuration " + - 'has changed upstream. Try pulling the latest changes from the ' + - 'main branch.' - ); - return; - } - - // Disable sizeBot in a Devtools Pull Request. Because that doesn't affect production bundle size. - const commitFiles = [ - ...danger.git.created_files, - ...danger.git.deleted_files, - ...danger.git.modified_files, - ]; - if ( - commitFiles.every(filename => filename.includes('packages/react-devtools')) - ) - return; - - const resultsMap = new Map(); - - // Find all the head (current) artifacts paths. - const headArtifactPaths = await glob('**/*.js', {cwd: 'build'}); - for (const artifactPath of headArtifactPaths) { - try { - // This will throw if there's no matching base artifact - const baseSize = statSync(BASE_DIR + '/' + artifactPath).size; - const baseSizeGzip = gzipSize.fileSync(BASE_DIR + '/' + artifactPath); - - const headSize = statSync(HEAD_DIR + '/' + artifactPath).size; - const headSizeGzip = gzipSize.fileSync(HEAD_DIR + '/' + artifactPath); - resultsMap.set(artifactPath, { - path: artifactPath, - headSize, - headSizeGzip, - baseSize, - baseSizeGzip, - change: (headSize - baseSize) / baseSize, - changeGzip: (headSizeGzip - baseSizeGzip) / baseSizeGzip, - }); - } catch { - // There's no matching base artifact. This is a new file. - const baseSize = 0; - const baseSizeGzip = 0; - const headSize = statSync(HEAD_DIR + '/' + artifactPath).size; - const headSizeGzip = gzipSize.fileSync(HEAD_DIR + '/' + artifactPath); - resultsMap.set(artifactPath, { - path: artifactPath, - headSize, - headSizeGzip, - baseSize, - baseSizeGzip, - change: Infinity, - changeGzip: Infinity, - }); - } - } - - // Check for base artifacts that were deleted in the head. - const baseArtifactPaths = await glob('**/*.js', {cwd: 'base-build'}); - for (const artifactPath of baseArtifactPaths) { - if (!resultsMap.has(artifactPath)) { - const baseSize = statSync(BASE_DIR + '/' + artifactPath).size; - const baseSizeGzip = gzipSize.fileSync(BASE_DIR + '/' + artifactPath); - const headSize = 0; - const headSizeGzip = 0; - resultsMap.set(artifactPath, { - path: artifactPath, - headSize, - headSizeGzip, - baseSize, - baseSizeGzip, - change: -1, - changeGzip: -1, - }); - } - } - - const results = Array.from(resultsMap.values()); - results.sort((a, b) => b.change - a.change); - - let criticalResults = []; - for (const artifactPath of CRITICAL_ARTIFACT_PATHS) { - const result = resultsMap.get(artifactPath); - if (result === undefined) { - throw new Error( - 'Missing expected bundle. If this was an intentional change to the ' + - 'build configuration, update Dangerfile.js accordingly: ' + - artifactPath - ); - } - criticalResults.push(row(result, baseSha, headSha)); - } - - let significantResults = []; - for (const result of results) { - // If result exceeds critical threshold, add to top section. - if ( - (result.change > CRITICAL_THRESHOLD || - 0 - result.change > CRITICAL_THRESHOLD || - // New file - result.change === Infinity || - // Deleted file - result.change === -1) && - // Skip critical artifacts. We added those earlier, in a fixed order. - !CRITICAL_ARTIFACT_PATHS.has(result.path) - ) { - criticalResults.push(row(result, baseSha, headSha)); - } - - // Do the same for results that exceed the significant threshold. These - // will go into the bottom, collapsed section. Intentionally including - // critical artifacts in this section, too. - if ( - result.change > SIGNIFICANCE_THRESHOLD || - 0 - result.change > SIGNIFICANCE_THRESHOLD || - result.change === Infinity || - result.change === -1 - ) { - significantResults.push(row(result, baseSha, headSha)); - } - } - - const message = ` -Comparing: ${baseSha}...${headSha} - -## Critical size changes - -Includes critical production bundles, as well as any change greater than ${ - CRITICAL_THRESHOLD * 100 - }%: - -${header} -${criticalResults.join('\n')} - -## Significant size changes - -Includes any change greater than ${SIGNIFICANCE_THRESHOLD * 100}%: - -${ - significantResults.length > 0 - ? ` -
-Expand to show -${header} -${significantResults.join('\n')} -
-` - : '(No significant changes)' -} -`; - - // GitHub comments are limited to 65536 characters. - if (message.length > 65536) { - // Make message available as an artifact - writeFileSync('sizebot-message.md', message); - markdown( - 'The size diff is too large to display in a single comment. ' + - `The GitHub action for this pull request contains an artifact called 'sizebot-message.md' with the full message.` - ); - } else { - markdown(message); - } -})(); diff --git a/package.json b/package.json index 29abc8c0dafc..f5e4f1d910ff 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,6 @@ "confusing-browser-globals": "^1.0.9", "core-js": "^3.6.4", "create-react-class": "^15.6.3", - "danger": "^11.2.3", "error-stack-parser": "^2.0.6", "eslint": "^7.7.0", "eslint-config-prettier": "^6.9.0", diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 12ef52669ba4..7ed683b110bd 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -2173,6 +2173,10 @@ function transferReferencedDebugInfo( } } +// Most references have no path, so they can all share the same empty array. +// It's never mutated because only paths with entries get spliced in place. +const EMPTY_REFERENCE_PATH: Array = []; + function getOutlinedModel( response: Response, reference: string, @@ -2180,8 +2184,10 @@ function getOutlinedModel( key: string, map: (response: Response, model: any, parentObject: Object, key: string) => T, ): T { - const path = reference.split(':'); - const id = parseInt(path[0], 16); + // parseInt stops at the ':' so we only need to split when there's a path. + const id = parseInt(reference, 16); + const path = + reference.indexOf(':') === -1 ? EMPTY_REFERENCE_PATH : reference.split(':'); const chunk = getChunk(response, id); if (enableProfilerTimer && enableComponentPerformanceTrack) { if (initializingChunk !== null && isArray(initializingChunk._children)) { @@ -3246,10 +3252,22 @@ function resolveModule( ): void { const chunks = response._chunks; const chunk = chunks.get(id); - const clientReferenceMetadata: ClientReferenceMetadata = parseModel( - response, - model, - ); + const prevHandler = initializingHandler; + initializingHandler = null; + let clientReferenceMetadata: ClientReferenceMetadata; + try { + clientReferenceMetadata = parseModel(response, model); + if (initializingHandler !== null) { + // We resolve the client reference below and have nothing to wait on, + // so the metadata can't reference a row that hasn't arrived. + throw new Error( + 'A client reference was blocked on a row that has not been received yet. ' + + 'This is a bug in React.', + ); + } + } finally { + initializingHandler = prevHandler; + } const clientReference = resolveClientReference<$FlowFixMe>( response._bundlerConfig, clientReferenceMetadata, diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index 0c1f9add5b44..2e846579db36 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -1107,6 +1107,233 @@ describe('ReactFlightDOMEdge', () => { expect(items[5]).toEqual(items[10]); }); + function clientComponent(name, chunkFilename) { + return clientExports( + function Client() { + return {name}; + }, + 'chunk-' + name, + chunkFilename, + Promise.resolve(), + ); + } + + async function renderClients(chunkFilenames) { + const Clients = chunkFilenames.map(chunkFilename => + clientComponent('Client', chunkFilename), + ); + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( +
+ {Clients.map((Client, i) => ( + + ))} +
, + webpackMap, + ), + ); + const [stream1, stream2] = passThrough(stream).tee(); + const payload = await readResult(stream1); + const model = await ReactServerDOMClient.createFromReadableStream(stream2, { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, + }); + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream(model), + ); + expect(await readResult(ssrStream)).toBe( + '
' + 'Client'.repeat(Clients.length) + '
', + ); + return payload; + } + + it('should dedupe strings inside client reference metadata', async () => { + // Bundlers repeat the same chunk in the metadata of every client reference + // that needs it. + const chunk = 'shared/hashed-chunk-0f1e2d3c4b5a6978.js'; + const shared = await renderClients(new Array(10).fill(chunk)); + // The same length, so sharing is the only difference between the two. + const distinct = await renderClients( + Array.from( + {length: 10}, + (_, i) => 'unique/hashed-chunk-' + ('' + i).padStart(16, '0') + '.js', + ), + ); + + // However many references there are, the chunk goes on the wire once, as a + // row that every import row points at. + expect(shared.split(chunk).length - 1).toBe(1); + expect(distinct.length - shared.length).toBeGreaterThan(8 * chunk.length); + + // The client resolves a client reference while parsing its row, so the + // outlined copy has to arrive before every row that points at it. The + // chunk id is too short to be outlined, so it counts the rows. + const beforeOutlinedCopy = shared.slice(0, shared.indexOf(chunk)); + expect(beforeOutlinedCopy).not.toContain('chunk-Client'); + }); + + it('should escape strings inside client reference metadata', async () => { + // A leading $ has to be escaped whether the string gets outlined or not. + const outlinedChunk = '$shared/hashed-chunk-0f1e2d3c4b5a6978.js'; + const inlineChunk = '$chunk.js'; + const outlined = await renderClients(new Array(3).fill(outlinedChunk)); + const inline = await renderClients(new Array(3).fill(inlineChunk)); + + expect(outlined.split('$' + outlinedChunk).length - 1).toBe(1); + expect(inline.split('$' + inlineChunk).length - 1).toBe(3); + }); + + it('should not dedupe import strings below the size limit', async () => { + // A short string costs more to reference than to repeat. + const shortChunk = 'abc/chunk-15.js'; + const longChunk = 'abcd/chunk-16.js'; + const short = await renderClients(new Array(10).fill(shortChunk)); + const long = await renderClients(new Array(10).fill(longChunk)); + + expect(short.split(shortChunk).length - 1).toBe(10); + expect(long.split(longChunk).length - 1).toBe(1); + // The longer chunk is the one that produces the smaller payload. + expect(long.length).toBeLessThan(short.length); + }); + + it('should stop tracking new import strings once the budget is spent', async () => { + // Every chunk is outlined the first time it's seen, so chunks that never + // repeat spend budget too. 32 fillers of 1 KiB fill the 32 KiB budget. + const chunk = 'shared/hashed-chunk-0f1e2d3c4b5a6978.js'; + const repeats = new Array(10).fill(chunk); + const filler = (count, length) => + Array.from({length: count}, (_, i) => + ('filler/chunk-' + i + '-').padEnd(length - 3, 'x').concat('.js'), + ); + const fitsInTheRest = await renderClients(filler(31, 1024).concat(repeats)); + const findsItSpent = await renderClients(filler(32, 1024).concat(repeats)); + + expect(fitsInTheRest.split(chunk).length - 1).toBe(1); + expect(findsItSpent.split(chunk).length - 1).toBe(10); + + // A string outlined before the budget is spent keeps deduping after. + const lastFiller = filler(1, 32768 - 31 * 1024 - chunk.length); + const trackedBefore = await renderClients( + [chunk].concat(filler(31, 1024), lastFiller, repeats), + ); + + expect(trackedBefore.split(chunk).length - 1).toBe(1); + + const bigChunk = 'path/to/' + 'a'.repeat(40000) + '.js'; + const big = await renderClients(new Array(3).fill(bigChunk)); + + expect(big.split(bigChunk).length - 1).toBe(3); + }); + + it('should dedupe import strings produced by toJSON', async () => { + const chunk = 'shared/hashed-chunk-0f1e2d3c4b5a6978.js'; + const payload = await renderClients( + Array.from({length: 3}, () => ({ + toJSON() { + return chunk; + }, + })), + ); + + expect(payload.split(chunk).length - 1).toBe(1); + }); + + it('should error on circular client reference metadata', async () => { + const circular = []; + circular.push(circular); + const Client = clientComponent('Client', circular); + + const errors = []; + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(, webpackMap, { + onError(error) { + errors.push(error.message); + }, + }), + ); + await readResult(stream); + + expect(errors).toEqual([ + expect.stringContaining('Converting circular structure to JSON'), + ]); + }); + + it('should not dedupe strings in the model', async () => { + // Only import metadata is deduped. Keying a map on arbitrary model strings + // would hold them in memory for the rest of the request. + const text = 'a repeated model string well past the import threshold'; + const model = new Array(10).fill(text); + + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(model), + ); + const [stream1, stream2] = passThrough(stream).tee(); + + const payload = await readResult(stream1); + expect(payload.split(text).length - 1).toBe(10); + + const result = await ReactServerDOMClient.createFromReadableStream( + stream2, + { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, + }, + ); + expect(result).toEqual(model); + }); + + // @gate __DEV__ + it('should not dedupe import metadata on the debug channel', async () => { + // The debug channel is a separate transport, so a row it emits can't be + // referenced from the main stream and vice versa. + const chunk = 'shared/hashed-chunk-0f1e2d3c4b5a6978.js'; + const A = clientComponent('Client', chunk); + const B = clientComponent('Client', chunk); + const C = clientComponent('Client', chunk); + + function Server({a, b, c}) { + return ReactServer.createElement('div', null, a, b, c); + } + + let debugContent = ''; + const debugChannel = { + writable: new WritableStream({ + write(value) { + debugContent += Buffer.from(value).toString('utf8'); + }, + }), + }; + + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + // We can't use JSX here because it'll use the Client React. + ReactServer.createElement(Server, { + a: ReactServer.createElement(A), + b: ReactServer.createElement(B), + c: ReactServer.createElement(C), + }), + webpackMap, + {debugChannel}, + ), + ); + const payload = await readResult(stream); + + // The main stream dedupes as usual. + expect(payload.split(chunk).length - 1).toBe(1); + + // The debug channel can't point at that row, so every import row it writes + // spells the chunk out. The chunk id is too short to be outlined, so it + // counts those rows. + expect(debugContent).toContain('chunk-Client'); + expect(debugContent.split(chunk).length).toBe( + debugContent.split('chunk-Client').length, + ); + }); + it('warns if passing a this argument to bind() of a server reference', async () => { const ServerModule = serverExports({ greet: function () {}, @@ -2370,6 +2597,66 @@ describe('ReactFlightDOMEdge', () => { ); }); + async function renderThroughDebugChannel(chunkFilename) { + const Client = clientComponent('Client', chunkFilename); + // The client reference shows up in the owner's props on the debug channel. + function Server({component}) { + return ReactServer.createElement(component, null); + } + + let debugReadableStreamController; + const debugReadableStream = new ReadableStream({ + start(controller) { + debugReadableStreamController = controller; + }, + }); + + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream( + ReactServer.createElement(Server, {component: Client}), + webpackMap, + { + debugChannel: { + writable: new WritableStream({ + write(chunk) { + debugReadableStreamController.enqueue(chunk); + }, + close() { + debugReadableStreamController.close(); + }, + }), + }, + }, + ), + ); + + const response = ReactServerDOMClient.createFromReadableStream(stream, { + serverConsumerManifest: {moduleMap: null, moduleLoading: null}, + debugChannel: {readable: debugReadableStream}, + }); + + function ClientRoot() { + return use(response); + } + + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream(), + ); + return readResult(ssrStream); + } + + it('can resolve a client reference while debug info is still blocked', async () => { + const result = await renderThroughDebugChannel('path/to/chunk.js'); + + expect(result).toBe('Client'); + }); + + it('should escape strings in import metadata on the debug channel', async () => { + const result = await renderThroughDebugChannel('$path/to/chunk.js'); + + expect(result).toBe('Client'); + }); + it('should properly resolve with deduped objects', async () => { const obj = {foo: 'hi'}; diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index d9177cd27645..e3893a75de3b 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -616,6 +616,9 @@ export type Request = { writtenClientReferences: Map, writtenServerReferences: Map, number>, writtenObjects: WeakMap, + writtenImportStrings: Map, + // The combined length of the keys in writtenImportStrings. + writtenImportStringsSize: number, temporaryReferences: void | TemporaryReferenceSet, identifierPrefix: string, identifierCount: number, @@ -741,6 +744,8 @@ function RequestInstance( this.writtenClientReferences = new Map(); this.writtenServerReferences = new Map(); this.writtenObjects = new WeakMap(); + this.writtenImportStrings = new Map(); + this.writtenImportStringsSize = 0; this.temporaryReferences = temporaryReferences; this.identifierPrefix = identifierPrefix || ''; this.identifierCount = 1; @@ -2250,6 +2255,16 @@ let canEmitDebugInfo: boolean = false; let serializedSize = 0; const MAX_ROW_SIZE = 3200; +// Bundler metadata repeats the same chunk URLs across every client reference of +// a route, so strings in it at least this long get outlined and deduplicated +// when they repeat. The threshold is bounded away from zero because outlining +// something as short as an export name costs more than copying it. +const MIN_DEDUPLICATED_IMPORT_STRING_LENGTH = 16; + +// Tracked strings are retained for the rest of the request, so their combined +// length is capped. +const MAX_DEDUPLICATED_IMPORT_STRINGS_SIZE = 32768; + function deferTask(request: Request, task: Task): ReactJSONValue { // Like outlineTask but instead the item is scheduled to be serialized // after its parent in the stream. @@ -3172,9 +3187,15 @@ function serializeClientReference( try { const clientReferenceMetadata: ClientReferenceMetadata = resolveClientReferenceMetadata(request.bundlerConfig, clientReference); + // Stringify before claiming a chunk id so a throw can't leave it pending. + const json = stringifyImportMetadata( + request, + clientReferenceMetadata, + false, + ); request.pendingChunks++; const importId = request.nextChunkId++; - emitImportChunk(request, importId, clientReferenceMetadata, false); + emitImportChunk(request, importId, json, false); writtenClientReferences.set(clientReferenceKey, importId); if (parent[0] === REACT_ELEMENT_TYPE && parentPropertyName === '1') { // If we're encoding the "type" of an element, we can refer @@ -3222,9 +3243,14 @@ function serializeDebugClientReference( try { const clientReferenceMetadata: ClientReferenceMetadata = resolveClientReferenceMetadata(request.bundlerConfig, clientReference); + const json = stringifyImportMetadata( + request, + clientReferenceMetadata, + true, + ); request.pendingDebugChunks++; const importId = request.nextChunkId++; - emitImportChunk(request, importId, clientReferenceMetadata, true); + emitImportChunk(request, importId, json, true); if (parent[0] === REACT_ELEMENT_TYPE && parentPropertyName === '1') { // If we're encoding the "type" of an element, we can refer // to that by a lazy reference instead of directly since React @@ -3594,6 +3620,41 @@ function escapeStringValue(value: string): string { } } +function serializeImportString(request: Request, value: string): string { + // No maximum length because import strings are short and repeat often. + // Deduping model strings too would need one to skip very long strings. + if (value.length < MIN_DEDUPLICATED_IMPORT_STRING_LENGTH) { + return escapeStringValue(value); + } + const writtenStrings = request.writtenImportStrings; + const existing = writtenStrings.get(value); + if (existing !== undefined) { + return existing; + } + const size = request.writtenImportStringsSize + value.length; + if (size > MAX_DEDUPLICATED_IMPORT_STRINGS_SIZE) { + // The map is full. Strings already outlined keep deduping; new ones are + // written out every time. + return escapeStringValue(value); + } + request.writtenImportStringsSize = size; + // Chunk names are almost always shared, so the first occurrence is outlined + // right away instead of waiting for a repeat. + request.pendingChunks++; + const outlinedId = request.nextChunkId++; + // $FlowFixMe[incompatible-type] stringify can return null + const json: string = stringify(escapeStringValue(value)); + // The client reads import metadata synchronously, so this row has to have + // been written by the time the referencing row arrives. Import chunks are + // flushed ahead of regular ones, which regular chunks can't guarantee. + request.completedImportChunks.push( + stringToChunk(outlinedId.toString(16) + ':' + json + '\n'), + ); + const ref = serializeByValueID(outlinedId); + writtenStrings.set(value, ref); + return ref; +} + let modelRoot: null | ReactClientValue = false; function renderModel( @@ -4584,14 +4645,140 @@ function emitErrorChunk( } } +// Null on the debug channel, which can't reference rows in the main stream. +let importStringRequest: null | Request = null; + +function importMetadataReplacer(key: string, value: mixed): mixed { + if (typeof value === 'string') { + const request = importStringRequest; + if (request === null) { + return escapeStringValue(value); + } + return serializeImportString(request, value); + } + return value; +} + +function stringifyImportMetadataWithReplacer( + request: Request, + clientReferenceMetadata: ClientReferenceMetadata, + debug: boolean, +): string { + const prevRequest = importStringRequest; + importStringRequest = __DEV__ && debug ? null : request; + try { + // $FlowFixMe[incompatible-type] stringify can return null + return stringify(clientReferenceMetadata, importMetadataReplacer); + } finally { + importStringRequest = prevRequest; + } +} + +// Bundler metadata is two or three levels deep. The bound is only there so a +// cycle ends up in stringify itself, which throws its own error for it. +const MAX_IMPORT_METADATA_DEPTH = 16; + +const NOT_PLAIN_IMPORT_METADATA = {}; + +// Copies the metadata with every string replaced by its serialized form, so +// that stringify can run without a replacer. Anything stringify would treat +// specially (toJSON, boxed primitives, class instances) makes this give up +// instead, because the copy would not reproduce that treatment. +function transformImportMetadata( + request: Request, + value: mixed, + depth: number, +): mixed { + switch (typeof value) { + case 'string': + return serializeImportString(request, value); + case 'number': + case 'boolean': + case 'undefined': + return value; + case 'object': { + if (value === null) { + return null; + } + if (depth > MAX_IMPORT_METADATA_DEPTH) { + return NOT_PLAIN_IMPORT_METADATA; + } + if (typeof (value as any).toJSON === 'function') { + return NOT_PLAIN_IMPORT_METADATA; + } + if (isArray(value)) { + const length = value.length; + const copy: Array = new Array(length); + for (let i = 0; i < length; i++) { + const element = value[i]; + if (typeof element === 'string') { + copy[i] = serializeImportString(request, element); + continue; + } + const child = transformImportMetadata(request, element, depth + 1); + if (child === NOT_PLAIN_IMPORT_METADATA) { + return NOT_PLAIN_IMPORT_METADATA; + } + copy[i] = child; + } + return copy; + } + const proto = getPrototypeOf(value); + if (proto !== ObjectPrototype && proto !== null) { + return NOT_PLAIN_IMPORT_METADATA; + } + const keys = Object.keys(value); + const copy: {[string]: mixed} = {}; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key in ObjectPrototype) { + // The copy inherits from Object.prototype, so assigning this key would + // hit an accessor like __proto__ or, if the prototype is frozen, throw. + return NOT_PLAIN_IMPORT_METADATA; + } + const element = (value as any)[key]; + if (typeof element === 'string') { + copy[key] = serializeImportString(request, element); + continue; + } + const child = transformImportMetadata(request, element, depth + 1); + if (child === NOT_PLAIN_IMPORT_METADATA) { + return NOT_PLAIN_IMPORT_METADATA; + } + copy[key] = child; + } + return copy; + } + default: + return NOT_PLAIN_IMPORT_METADATA; + } +} + +function stringifyImportMetadata( + request: Request, + clientReferenceMetadata: ClientReferenceMetadata, + debug: boolean, +): string { + if (!(__DEV__ && debug)) { + const copy = transformImportMetadata(request, clientReferenceMetadata, 0); + if (copy !== NOT_PLAIN_IMPORT_METADATA) { + // $FlowFixMe[incompatible-type] stringify can return null + return stringify(copy); + } + } + return stringifyImportMetadataWithReplacer( + request, + clientReferenceMetadata, + debug, + ); +} + function emitImportChunk( request: Request, id: number, - clientReferenceMetadata: ClientReferenceMetadata, + json: string, debug: boolean, ): void { - // $FlowFixMe[incompatible-type] stringify can return null - const json: string = stringify(clientReferenceMetadata); const row = serializeRowHeader('I', id) + json + '\n'; const processedChunk = stringToChunk(row); if (__DEV__ && debug) { diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index a1ebcc3106f5..ba39b2b6acc5 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -592,5 +592,6 @@ "604": "The server render could not complete because client rendering was requested outside a Suspense boundary. See this error's cause for additional details.", "605": "Recoverable Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render so a downstream renderer can recover it. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.", "606": "Expected a suspended recoverable. This is a bug in React. Please file an issue.", - "607": "This library called use() to suspend in a previous render but did not call use() when it finished. This indicates an incorrect use of use(). Learn more: https://react.dev/warnings/conditional-use-of-use" + "607": "This library called use() to suspend in a previous render but did not call use() when it finished. This indicates an incorrect use of use(). Learn more: https://react.dev/warnings/conditional-use-of-use", + "608": "A client reference was blocked on a row that has not been received yet. This is a bug in React." } diff --git a/scripts/sizebot/compare-sizes.js b/scripts/sizebot/compare-sizes.js new file mode 100644 index 000000000000..6e14a604956f --- /dev/null +++ b/scripts/sizebot/compare-sizes.js @@ -0,0 +1,114 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +/* eslint-disable no-for-of-loops/no-for-of-loops */ + +// Measures every build artifact in `build` against the base revision's artifacts +// in `base-build` and writes the raw numbers to `sizebot-results.json`. +// +// This runs in the `pull_request` half of CI, where the GitHub token is +// read-only, so it never talks to the API and never renders anything +// user-facing. `render-comment.js` turns this JSON into the pull request comment +// from a trusted checkout. See `.github/workflows/runtime_sizebot_comment.yml` +// for why the two halves are separate. + +const {promisify} = require('util'); +const glob = promisify(require('glob')); +const gzipSize = require('gzip-size'); +const {readFileSync, statSync, writeFileSync} = require('fs'); + +// Bump on any incompatible change to the JSON below: added required fields, +// renamed or removed fields, or a changed meaning for an existing one. Purely +// additive optional fields do not need a bump. The reader lives on the default +// branch while the writer lives on the pull request branch, so the two can +// legitimately disagree and `render-comment.js` needs to be able to tell. +const RESULTS_VERSION = 1; + +const RESULTS_PATH = 'sizebot-results.json'; +const BASE_DIR = 'base-build'; +const HEAD_DIR = 'build'; + +function measure(dir, artifactPath) { + const file = dir + '/' + artifactPath; + return { + size: statSync(file).size, + sizeGzip: gzipSize.fileSync(file), + }; +} + +function writeResults(results) { + writeFileSync(RESULTS_PATH, JSON.stringify(results, null, 2) + '\n'); +} + +(async function () { + let headSha; + let baseSha; + try { + headSha = String(readFileSync(HEAD_DIR + '/COMMIT_SHA')).trim(); + baseSha = String(readFileSync(BASE_DIR + '/COMMIT_SHA')).trim(); + } catch { + // Let the renderer explain this one. It is expected to happen whenever the + // build configuration changes upstream, which is not a CI failure. + writeResults({ + version: RESULTS_VERSION, + status: 'base-artifacts-unavailable', + }); + return; + } + + // A missing size is recorded as null rather than 0, so the renderer can tell + // "this artifact does not exist on that side" apart from "this artifact is + // empty". It derives the new-file and deleted-file cases from those nulls. + const artifactsByPath = new Map(); + + const headArtifactPaths = await glob('**/*.js', {cwd: HEAD_DIR}); + for (const artifactPath of headArtifactPaths) { + let base; + try { + base = measure(BASE_DIR, artifactPath); + } catch { + // There's no matching base artifact. This is a new file. + base = null; + } + const head = measure(HEAD_DIR, artifactPath); + artifactsByPath.set(artifactPath, { + path: artifactPath, + baseSize: base === null ? null : base.size, + baseSizeGzip: base === null ? null : base.sizeGzip, + headSize: head.size, + headSizeGzip: head.sizeGzip, + }); + } + + // Check for base artifacts that were deleted in the head. + const baseArtifactPaths = await glob('**/*.js', {cwd: BASE_DIR}); + for (const artifactPath of baseArtifactPaths) { + if (!artifactsByPath.has(artifactPath)) { + const base = measure(BASE_DIR, artifactPath); + artifactsByPath.set(artifactPath, { + path: artifactPath, + baseSize: base.size, + baseSizeGzip: base.sizeGzip, + headSize: null, + headSizeGzip: null, + }); + } + } + + // Every artifact is reported, with no threshold filtering. The thresholds and + // the critical bundle list belong to the renderer, so that a pull request + // cannot quietly widen them to hide a regression. + writeResults({ + version: RESULTS_VERSION, + status: 'ok', + baseSha, + headSha, + artifacts: Array.from(artifactsByPath.values()), + }); +})(); diff --git a/scripts/sizebot/pull-request-comment.js b/scripts/sizebot/pull-request-comment.js new file mode 100644 index 000000000000..17056fef4825 --- /dev/null +++ b/scripts/sizebot/pull-request-comment.js @@ -0,0 +1,253 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +/* eslint-disable no-for-of-loops/no-for-of-loops */ + +// The GitHub API half of sizebot, called from `actions/github-script` steps in +// `.github/workflows/runtime_sizebot_comment.yml`. +// +// `resolve` figures out which pull request a `workflow_run` event belongs to, +// finds any comment sizebot has already left on it, and decides whether this +// event should write at all. `post` creates or updates the comment from the body +// that `render-comment.js` produced in between. + +const {readFileSync, writeFileSync} = require('fs'); +const { + MARKER_PREFIX, + extractReport, + parseReportHead, +} = require('./render-comment'); + +const CONTEXT_PATH = 'sizebot-context.json'; +const COMMENT_PATH = 'sizebot-comment.md'; + +const COMMENT_AUTHOR = 'github-actions[bot]'; + +// `pulls.listFiles` stops paginating here, so a pull request larger than this +// cannot be shown to touch only DevTools. +const MAX_LISTABLE_FILES = 3000; + +const DEVTOOLS_PATH = 'packages/react-devtools'; + +async function findExistingComment(github, context, prNumber) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + }); + for (const comment of comments) { + if ( + comment.user.login === COMMENT_AUTHOR && + comment.body.startsWith(MARKER_PREFIX) + ) { + return comment; + } + } + return null; +} + +// `workflow_run.pull_requests` is empty for runs triggered by a fork, and +// neither `commits/{sha}/pulls` nor the search API index fork pull request head +// commits. Looking the branch up by `owner:ref` is what actually works for both +// fork and same-repo pull requests. +async function findPullRequestNumber(github, context, workflowRun) { + if ( + workflowRun.pull_requests != null && + workflowRun.pull_requests.length > 0 + ) { + return workflowRun.pull_requests[0].number; + } + + if (workflowRun.head_repository == null) { + return null; + } + + const {data: pulls} = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + head: `${workflowRun.head_repository.owner.login}:${workflowRun.head_branch}`, + state: 'open', + per_page: 100, + }); + if (pulls.length === 0) { + return null; + } + return pulls[0].number; +} + +async function findPullRequest(github, context, workflowRun) { + const number = await findPullRequestNumber(github, context, workflowRun); + if (number === null) { + return null; + } + // Always finish with `pulls.get`. The list endpoint omits `changed_files`, + // which `isDevToolsOnly` needs, and that is the endpoint the fork path uses. + const {data} = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: number, + }); + return data; +} + +async function isDevToolsOnly(github, context, pullRequest) { + if ( + !Number.isInteger(pullRequest.changed_files) || + // `listFiles` would silently truncate, and a truncated list can look + // DevTools-only when it is not. + pullRequest.changed_files > MAX_LISTABLE_FILES + ) { + return false; + } + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullRequest.number, + per_page: 100, + }); + if (files.length === 0) { + return false; + } + return files.every(file => file.filename.includes(DEVTOOLS_PATH)); +} + +async function resolve({github, context, core}) { + const workflowRun = context.payload.workflow_run; + const action = context.payload.action; + + const pullRequest = await findPullRequest(github, context, workflowRun); + if (pullRequest === null) { + core.info('No open pull request for this run. Nothing to comment on.'); + core.setOutput('action', 'skip'); + return; + } + + // A pull request number must never come from the build artifact, which the + // fork controls. Confirm the one we resolved really does belong to this run. + const runRepo = workflowRun.head_repository?.full_name ?? null; + const pullRequestRepo = pullRequest.head.repo?.full_name ?? null; + if (runRepo === null || pullRequestRepo === null) { + // A deleted fork leaves us no way to check, so don't write anything. + core.info('Head repository is unavailable. Nothing to comment on.'); + core.setOutput('action', 'skip'); + return; + } + if (pullRequestRepo !== runRepo) { + core.setFailed( + `Pull request #${pullRequest.number} has head repository ` + + `${pullRequestRepo}, but the run came from ${runRepo}.` + ); + return; + } + + // Mirrors the sizebot job's own condition in runtime_build_and_test.yml. + if (pullRequest.base.ref !== 'main') { + core.info( + `Pull request #${pullRequest.number} targets ${pullRequest.base.ref}, not main.` + ); + core.setOutput('action', 'skip'); + return; + } + + const existing = await findExistingComment( + github, + context, + pullRequest.number + ); + const existingReportHead = + existing === null ? null : parseReportHead(existing.body); + + // The only event we ever drop: a comment already describes the current head, + // and this event is about an older commit. Without this, a run cancelled by a + // force push reports `cancelled` after the newer run's comment has landed and + // replaces good numbers with a cancellation notice. + if ( + existingReportHead !== null && + existingReportHead === pullRequest.head.sha && + workflowRun.head_sha !== pullRequest.head.sha + ) { + core.info( + `Comment already reports on ${pullRequest.head.sha}; this run is for ` + + `${workflowRun.head_sha}. Leaving it alone.` + ); + core.setOutput('action', 'skip'); + return; + } + + const devtoolsOnly = + action === 'completed' + ? await isDevToolsOnly(github, context, pullRequest) + : false; + + writeFileSync( + CONTEXT_PATH, + JSON.stringify( + { + action, + prNumber: pullRequest.number, + prHeadSha: pullRequest.head.sha, + runHeadSha: workflowRun.head_sha, + runUrl: workflowRun.html_url, + runStatus: workflowRun.status, + runConclusion: workflowRun.conclusion, + devtoolsOnly, + existingCommentId: existing === null ? null : existing.id, + existingReportHead, + existingReport: existing === null ? null : extractReport(existing.body), + commentRunUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + }, + null, + 2 + ) + '\n' + ); + + core.setOutput('action', 'continue'); + // Only a completed run can have produced results to download. + core.setOutput('download_results', String(action === 'completed')); +} + +async function post({github, context, core}) { + const sizebotContext = JSON.parse(readFileSync(CONTEXT_PATH, 'utf8')); + const body = readFileSync(COMMENT_PATH, 'utf8'); + + async function create() { + const {data} = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: sizebotContext.prNumber, + body, + }); + core.info(`Created ${data.html_url}`); + } + + if (sizebotContext.existingCommentId === null) { + await create(); + return; + } + + try { + const {data} = await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: sizebotContext.existingCommentId, + body, + }); + core.info(`Updated ${data.html_url}`); + } catch (error) { + if (error.status !== 404) { + throw error; + } + // Someone deleted the comment between resolving it and writing to it. + core.info('Existing comment is gone, posting a new one.'); + await create(); + } +} + +module.exports = {post, resolve}; diff --git a/scripts/sizebot/render-comment.js b/scripts/sizebot/render-comment.js new file mode 100644 index 000000000000..32eceef8d859 --- /dev/null +++ b/scripts/sizebot/render-comment.js @@ -0,0 +1,489 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +/* eslint-disable no-for-of-loops/no-for-of-loops */ + +// Turns `sizebot-results.json` into the body of the sizebot pull request +// comment. Runs from a checkout of the default branch, never from the pull +// request branch, so the thresholds, the critical bundle list and the table +// itself cannot be influenced by the pull request being measured. Everything it +// reads out of the results file is therefore treated as untrusted input. +// +// Reads `sizebot-context.json` (written by the resolve step) and, when the build +// produced one, `sizebot-results.json`. Writes `sizebot-comment.md`, plus +// `sizebot-message.md` when the report is too large to fit in a comment and +// `sizebot-problem.txt` when the build configuration no longer matches this +// file's expectations. + +const {existsSync, readFileSync, writeFileSync} = require('fs'); + +// Results shapes this file knows how to read. `compare-sizes.js` on the pull +// request branch may be older or newer than this list. +const SUPPORTED_VERSIONS = new Set([1]); +const SUPPORTED_STATUSES = new Set(['ok', 'base-artifacts-unavailable']); + +const CRITICAL_THRESHOLD = 0.02; +const SIGNIFICANCE_THRESHOLD = 0.002; +const CRITICAL_ARTIFACT_PATHS = new Set([ + // We always report changes to these bundles, even if the change is + // insignificant or non-existent. + 'oss-stable/react-dom/cjs/react-dom.production.js', + 'oss-stable/react-dom/cjs/react-dom-client.production.js', + 'oss-experimental/react-dom/cjs/react-dom.production.js', + 'oss-experimental/react-dom/cjs/react-dom-client.production.js', + 'facebook-www/ReactDOM-prod.classic.js', + 'facebook-www/ReactDOM-prod.modern.js', +]); + +// GitHub comments are limited to 65536 characters. +const MAX_COMMENT_LENGTH = 65536; + +// Both the notice and the report are delimited so each can be rewritten without +// disturbing the other, and so a report can be read back out of a comment +// verbatim when a newer build supersedes it. Relying on "everything after the +// notice" instead would swallow the footer and append a second one every time. +const MARKER_PREFIX = ''; +const NOTICE_END = ''; +const REPORT_START = ''; +const REPORT_END = ''; + +const CONTEXT_PATH = 'sizebot-context.json'; +const RESULTS_PATH = 'sizebot-results.json'; +const COMMENT_PATH = 'sizebot-comment.md'; +const MESSAGE_PATH = 'sizebot-message.md'; +const PROBLEM_PATH = 'sizebot-problem.txt'; + +// Build artifact paths end up inside markdown link text and inside a URL, so +// anything that could break out of either is rejected rather than escaped. +const SAFE_ARTIFACT_PATH = /^[A-Za-z0-9_@./+-]+$/; + +function isSafeArtifactPath(value) { + return ( + typeof value === 'string' && + value.length > 0 && + value.length < 512 && + SAFE_ARTIFACT_PATH.test(value) && + !value.includes('..') && + !value.startsWith('/') + ); +} + +function isSize(value) { + return value === null || (Number.isFinite(value) && value >= 0); +} + +function isSha(value) { + return typeof value === 'string' && /^[0-9a-f]{7,40}$/.test(value); +} + +const kilobyteFormatter = new Intl.NumberFormat('en', { + style: 'unit', + unit: 'kilobyte', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +function kbs(bytes) { + // An artifact that exists on only one side has no size on the other. The + // report has always shown that as 0.00 kB rather than an empty cell. + return kilobyteFormatter.format((bytes === null ? 0 : bytes) / 1000); +} + +const percentFormatter = new Intl.NumberFormat('en', { + style: 'percent', + signDisplay: 'exceptZero', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +function ratio(baseSize, headSize) { + if (baseSize === null) { + return Infinity; + } + if (headSize === null) { + return -1; + } + return (headSize - baseSize) / baseSize; +} + +function change(decimal) { + if (decimal === Infinity) { + return 'New file'; + } + if (decimal === -1) { + return 'Deleted'; + } + // Compare the magnitude, not the signed value. Testing `decimal < 0.0001` + // reported every size decrease as unchanged, which is why `signDisplay: + // 'exceptZero'` above never had a negative number to render. + if (Math.abs(decimal) < 0.0001) { + return '='; + } + return percentFormatter.format(decimal); +} + +const header = `| Name | +/- | Base | Current | +/- gzip | Base gzip | Current gzip | +| ---- | --- | ---- | ------- | -------- | --------- | ------------ |`; + +function row(result, baseSha, headSha) { + const diffViewUrl = `https://react-builds.vercel.app/commits/${headSha}/files/${result.path}?compare=${baseSha}`; + const rowArr = [ + `| [${result.path}](${diffViewUrl})`, + `**${change(result.change)}**`, + `${kbs(result.baseSize)}`, + `${kbs(result.headSize)}`, + `${change(result.changeGzip)}`, + `${kbs(result.baseSizeGzip)}`, + `${kbs(result.headSizeGzip)}`, + ]; + return rowArr.join(' | '); +} + +function validateResults(raw) { + if (raw === null || typeof raw !== 'object') { + return {ok: false, reason: 'malformed'}; + } + // Checked before anything else so a shape this file cannot read produces a + // clear message instead of a misrendered table. + if (!SUPPORTED_VERSIONS.has(raw.version)) { + return {ok: false, reason: 'unsupported-version'}; + } + if (!SUPPORTED_STATUSES.has(raw.status)) { + return {ok: false, reason: 'malformed'}; + } + if (raw.status === 'base-artifacts-unavailable') { + return {ok: true, results: {status: raw.status}}; + } + if (!isSha(raw.baseSha) || !isSha(raw.headSha)) { + return {ok: false, reason: 'malformed'}; + } + if (!Array.isArray(raw.artifacts)) { + return {ok: false, reason: 'malformed'}; + } + for (const artifact of raw.artifacts) { + if (artifact === null || typeof artifact !== 'object') { + return {ok: false, reason: 'malformed'}; + } + if (!isSafeArtifactPath(artifact.path)) { + return {ok: false, reason: 'malformed'}; + } + if ( + !isSize(artifact.baseSize) || + !isSize(artifact.baseSizeGzip) || + !isSize(artifact.headSize) || + !isSize(artifact.headSizeGzip) + ) { + return {ok: false, reason: 'malformed'}; + } + if (artifact.baseSize === null && artifact.headSize === null) { + return {ok: false, reason: 'malformed'}; + } + } + return {ok: true, results: raw}; +} + +function renderTable(results) { + const {baseSha, headSha} = results; + + const resultsMap = new Map(); + for (const artifact of results.artifacts) { + resultsMap.set(artifact.path, { + ...artifact, + change: ratio(artifact.baseSize, artifact.headSize), + changeGzip: ratio(artifact.baseSizeGzip, artifact.headSizeGzip), + }); + } + + const sorted = Array.from(resultsMap.values()); + sorted.sort((a, b) => b.change - a.change); + + const criticalResults = []; + const missingCriticalPaths = []; + for (const artifactPath of CRITICAL_ARTIFACT_PATHS) { + const result = resultsMap.get(artifactPath); + if (result === undefined) { + missingCriticalPaths.push(artifactPath); + continue; + } + criticalResults.push(row(result, baseSha, headSha)); + } + + const significantResults = []; + for (const result of sorted) { + // If result exceeds critical threshold, add to top section. + if ( + (Math.abs(result.change) > CRITICAL_THRESHOLD || + // New file + result.change === Infinity || + // Deleted file + result.change === -1) && + // Skip critical artifacts. We added those earlier, in a fixed order. + !CRITICAL_ARTIFACT_PATHS.has(result.path) + ) { + criticalResults.push(row(result, baseSha, headSha)); + } + + // Do the same for results that exceed the significant threshold. These + // will go into the bottom, collapsed section. Intentionally including + // critical artifacts in this section, too. + if ( + Math.abs(result.change) > SIGNIFICANCE_THRESHOLD || + result.change === Infinity || + result.change === -1 + ) { + significantResults.push(row(result, baseSha, headSha)); + } + } + + const markdown = `Comparing: ${baseSha}...${headSha} + +## Critical size changes + +Includes critical production bundles, as well as any change greater than ${ + CRITICAL_THRESHOLD * 100 + }%: + +${header} +${criticalResults.join('\n')} + +## Significant size changes + +Includes any change greater than ${SIGNIFICANCE_THRESHOLD * 100}%: + +${ + significantResults.length > 0 + ? `
+Expand to show + +${header} +${significantResults.join('\n')} +
` + : '(No significant changes)' +}`; + + return {markdown, missingCriticalPaths}; +} + +function renderCompletedReport(context) { + const {runConclusion, runUrl, devtoolsOnly} = context; + + // The common outcome for a first-time contributor's pull request: the run is + // created but held until a maintainer approves it. + if (runConclusion === 'action_required') { + return { + markdown: `[The build for this commit](${runUrl}) needs maintainer approval before it can run, so there is no size report yet.`, + missingCriticalPaths: [], + }; + } + + if (runConclusion !== 'success') { + return { + markdown: `The build for this commit did not complete, so there is no size report. See [the workflow run](${runUrl}) for details.`, + missingCriticalPaths: [], + }; + } + + if (devtoolsOnly) { + return { + markdown: + 'No size report: this pull request only touches `packages/react-devtools`, which does not affect production bundle size.', + missingCriticalPaths: [], + }; + } + + if (!existsSync(RESULTS_PATH)) { + return { + markdown: `The build succeeded but produced no size results, so there is no size report. See [the workflow run](${runUrl}) for details.`, + missingCriticalPaths: [], + }; + } + + let raw; + try { + raw = JSON.parse(readFileSync(RESULTS_PATH, 'utf8')); + } catch { + raw = null; + } + + const validated = validateResults(raw); + if (!validated.ok) { + if (validated.reason === 'unsupported-version') { + return { + markdown: + 'This pull request produced a size report in a format this repository no longer reads. ' + + 'Merge the latest changes from the `main` branch to pick up the current one.', + missingCriticalPaths: [], + }; + } + return { + markdown: `The size results for this commit could not be read, so there is no size report. See [the workflow run](${runUrl}) for details.`, + missingCriticalPaths: [], + }; + } + + if (validated.results.status === 'base-artifacts-unavailable') { + return { + markdown: + "Failed to read build artifacts. It's possible a build configuration has changed upstream. " + + 'Try pulling the latest changes from the `main` branch.', + missingCriticalPaths: [], + }; + } + + return renderTable(validated.results); +} + +function renderNotice(context, reportHead) { + const {action, prHeadSha, runHeadSha, runStatus, runUrl} = context; + const lines = []; + + // One rule covers both the case where an older run's results arrive after the + // head moved, and the case where a new build supersedes a report already on + // display: the report simply is not about the pull request's current head. + if (reportHead !== null && reportHead !== prHeadSha) { + lines.push( + `These sizes are for ${reportHead}, which is no longer the head of this pull request.` + ); + if (action === 'requested') { + lines.push(`A build for ${runHeadSha} is in progress.`); + } + } else if (action === 'requested' && runStatus === 'waiting') { + lines.push( + `[The build for this commit](${runUrl}) is waiting for maintainer approval before it can run.` + ); + } + + if (lines.length === 0) { + return ''; + } + return lines.map(line => `> ${line}`).join('\n> \n'); +} + +function renderBody(context) { + let reportHead; + let report; + let missingCriticalPaths = []; + + if (context.action === 'requested') { + // Only a comment that names the commit it describes holds real numbers. A + // previous placeholder has body text too, but carrying that forward would + // pin the comment to a stale run link instead of refreshing it. + if ( + context.existingReportHead !== null && + context.existingReport !== null + ) { + // Keep the numbers from the previous build visible. The notice below + // explains that they describe an older commit. + reportHead = context.existingReportHead; + report = context.existingReport; + } else { + reportHead = null; + report = `A size report will appear here when [the build](${context.runUrl}) finishes.`; + } + } else { + reportHead = context.runHeadSha; + const rendered = renderCompletedReport(context); + report = rendered.markdown; + missingCriticalPaths = rendered.missingCriticalPaths; + } + + if (missingCriticalPaths.length > 0) { + report = + '> [!CAUTION]\n' + + '> These critical bundles are missing from the build. If that was an intentional\n' + + '> change to the build configuration, update `CRITICAL_ARTIFACT_PATHS` in\n' + + '> `scripts/sizebot/render-comment.js`:\n' + + missingCriticalPaths.map(p => `> - \`${p}\``).join('\n') + + '\n\n' + + report; + } + + const notice = renderNotice(context, reportHead); + const footerSha = reportHead === null ? context.runHeadSha : reportHead; + + function assemble(reportRegion) { + return `${MARKER_PREFIX} report-head=${ + reportHead === null ? 'none' : reportHead + } --> +${NOTICE_START} +${notice === '' ? '' : `> [!WARNING]\n${notice}\n`}${NOTICE_END} +${REPORT_START} +${reportRegion} +${REPORT_END} + +Generated by sizebot against ${footerSha} +`; + } + + return { + body: assemble(report), + report, + assemble, + reportHead, + missingCriticalPaths, + }; +} + +// Reads the report region back out of a comment, so a completed report can be +// carried forward when a new build is requested for a newer commit. +function extractReport(body) { + const start = body.indexOf(REPORT_START); + const end = body.indexOf(REPORT_END); + if (start === -1 || end === -1 || end < start) { + return null; + } + const report = body.slice(start + REPORT_START.length, end).trim(); + return report === '' ? null : report; +} + +function parseReportHead(body) { + const match = + //.exec(body); + if (match === null || match[1] === 'none') { + return null; + } + return match[1]; +} + +function main() { + const context = JSON.parse(readFileSync(CONTEXT_PATH, 'utf8')); + const {body, report, assemble, missingCriticalPaths} = renderBody(context); + + let comment = body; + if (body.length > MAX_COMMENT_LENGTH) { + // The link resolves because the artifact is uploaded to this same run, + // before the comment is posted. + writeFileSync(MESSAGE_PATH, report + '\n'); + comment = assemble( + `The size diff is too large to display in a single comment. [This workflow run](${context.commentRunUrl}) contains an artifact called \`sizebot-message.md\` with the full report.` + ); + } + writeFileSync(COMMENT_PATH, comment); + + if (missingCriticalPaths.length > 0) { + writeFileSync( + PROBLEM_PATH, + `Missing expected bundles:\n${missingCriticalPaths.join('\n')}\n` + ); + } + + process.stdout.write(comment); +} + +module.exports = { + MARKER_PREFIX, + extractReport, + parseReportHead, + renderBody, +}; + +if (require.main === module) { + main(); +} diff --git a/scripts/tasks/danger.js b/scripts/tasks/danger.js deleted file mode 100644 index d5aab0be414e..000000000000 --- a/scripts/tasks/danger.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -'use strict'; - -const path = require('path'); -const spawn = require('child_process').spawn; - -const extension = process.platform === 'win32' ? '.cmd' : ''; - -// sizebot public_repo token (this is publicly visible on purpose) -const token = 'ghp_UfuUaoow8veN3ZV1' + 'sGquTDgiVjRDmL2qLY1D'; -spawn( - path.join('node_modules', '.bin', 'danger-ci' + extension), - [ - '--id', - process.env.RELEASE_CHANNEL === 'experimental' ? 'experimental' : 'stable', - ], - { - // Allow colors to pass through - stdio: 'inherit', - env: { - ...process.env, - DANGER_GITHUB_API_TOKEN: token, - }, - } -).on('close', function (code) { - if (code !== 0) { - console.error('Danger failed'); - } else { - console.log('Danger passed'); - } - - process.exit(code); -}); diff --git a/yarn.lock b/yarn.lock index e047451cd08f..ce640aa8fda9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2840,36 +2840,6 @@ resolved "https://registry.yarnpkg.com/@fluent/syntax/-/syntax-0.19.0.tgz#43f882faba6908b0f1013f6a94e009d0dfbdcb77" integrity sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ== -"@gitbeaker/core@^21.7.0": - version "21.7.0" - resolved "https://registry.yarnpkg.com/@gitbeaker/core/-/core-21.7.0.tgz#fcf7a12915d39f416e3f316d0a447a814179b8e5" - integrity sha512-cw72rE7tA27wc6JJe1WqeAj9v/6w0S7XJcEji+bRNjTlUfE1zgfW0Gf1mbGUi7F37SOABGCosQLfg9Qe63aIqA== - dependencies: - "@gitbeaker/requester-utils" "^21.7.0" - form-data "^3.0.0" - li "^1.3.0" - xcase "^2.0.1" - -"@gitbeaker/node@^21.3.0": - version "21.7.0" - resolved "https://registry.yarnpkg.com/@gitbeaker/node/-/node-21.7.0.tgz#2c19613f44ee497a8808c555abec614ebd2dfcad" - integrity sha512-OdM3VcTKYYqboOsnbiPcO0XimXXpYK4gTjARBZ6BWc+1LQXKmqo+OH6oUbyxOoaFu9hHECafIt3WZU3NM4sZTg== - dependencies: - "@gitbeaker/core" "^21.7.0" - "@gitbeaker/requester-utils" "^21.7.0" - form-data "^3.0.0" - got "^11.1.4" - xcase "^2.0.1" - -"@gitbeaker/requester-utils@^21.7.0": - version "21.7.0" - resolved "https://registry.yarnpkg.com/@gitbeaker/requester-utils/-/requester-utils-21.7.0.tgz#e9a9cfaf268d2a99eb7bbdc930943240a5f88878" - integrity sha512-eLTaVXlBnh8Qimj6QuMMA06mu/mLcJm3dy8nqhhn/Vm/D25sPrvpGwmbfFyvzj6QujPqtHvFfsCHtyZddL01qA== - dependencies: - form-data "^3.0.0" - query-string "^6.12.1" - xcase "^2.0.1" - "@humanwhocodes/config-array@^0.11.14": version "0.11.14" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" @@ -3409,7 +3379,7 @@ node-fetch "^2.6.7" universal-user-agent "^6.0.0" -"@octokit/rest@^16.43.0 || ^17.11.0 || ^18.12.0", "@octokit/rest@^18.12.0": +"@octokit/rest@^18.12.0": version "18.12.0" resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-18.12.0.tgz#f06bc4952fc87130308d810ca9d00e79f6988881" integrity sha512-gDPiOHlyGavxr72y0guQEhLsemgVjwRePayJ+FcKc2SJqKUbxbkvf5kAZEWA/MKvsfYlQAMVzNJE3ezQcxMJ2Q== @@ -5163,13 +5133,6 @@ async-each@^1.0.1: resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== -async-retry@1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/async-retry/-/async-retry-1.2.3.tgz#a6521f338358d322b1a0012b79030c6f411d1ce0" - integrity sha512-tfDb02Th6CE6pJUF2gjW5ZVjsgwlucVXOEQMvEX9JgSJMs9gAX+Nz3xRuJBKuUYjTSYORqvDBORdAQ3LU59g7Q== - dependencies: - retry "0.12.0" - async@^2.0.0, async@^2.6.3: version "2.6.3" resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" @@ -5926,11 +5889,6 @@ buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3: resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI= -buffer-equal-constant-time@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" - integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== - buffer-fill@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" @@ -6208,7 +6166,7 @@ chalk@^1.0.0, chalk@^1.1.3: strip-ansi "^3.0.0" supports-color "^2.0.0" -chalk@^2.0.0, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: +chalk@^2.0.0, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -6549,7 +6507,7 @@ colors@1.0.3: resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs= -colors@1.4.0, colors@^1.1.2: +colors@1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== @@ -6581,7 +6539,7 @@ commander@^10.0.1: resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== -commander@^2.18.0, commander@^2.20.0, commander@^2.6.0, commander@^2.8.1: +commander@^2.20.0, commander@^2.6.0, commander@^2.8.1: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -6833,11 +6791,6 @@ core-js@^3.6.4: resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.4.tgz#440a83536b458114b9cb2ac1580ba377dc470647" integrity sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw== -core-js@^3.8.2: - version "3.27.2" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.27.2.tgz#85b35453a424abdcacb97474797815f4d62ebbf7" - integrity sha512-9ashVQskuh5AZEZ1JdQWp1GqSoC1e1G87MzRqg2gIfVAQ7Qn9K+uFj8EcniUFA4P2NLZfV+TOlX1SzoKfo+s7w== - core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -7124,49 +7077,6 @@ currently-unhandled@^0.4.1: dependencies: array-find-index "^1.0.1" -danger@^11.2.3: - version "11.2.3" - resolved "https://registry.yarnpkg.com/danger/-/danger-11.2.3.tgz#2a0a6d3581478005d0f2abf5b3995a8409165067" - integrity sha512-NNDOUDZWCi1fqEicSWbnk8lOOoqY+vwekB8twUiknEyyvDDKypWEcUolq6SNg9Kd6HMqnX80K8U8nqzmDRC1QQ== - dependencies: - "@gitbeaker/node" "^21.3.0" - "@octokit/rest" "^18.12.0" - async-retry "1.2.3" - chalk "^2.3.0" - commander "^2.18.0" - core-js "^3.8.2" - debug "^4.1.1" - fast-json-patch "^3.0.0-1" - get-stdin "^6.0.0" - http-proxy-agent "^5.0.0" - https-proxy-agent "^5.0.1" - hyperlinker "^1.0.0" - json5 "^2.1.0" - jsonpointer "^5.0.0" - jsonwebtoken "^9.0.0" - lodash.find "^4.6.0" - lodash.includes "^4.3.0" - lodash.isobject "^3.0.2" - lodash.keys "^4.0.8" - lodash.mapvalues "^4.6.0" - lodash.memoize "^4.1.2" - memfs-or-file-map-to-github-branch "^1.2.1" - micromatch "^4.0.4" - node-cleanup "^2.1.2" - node-fetch "^2.6.7" - override-require "^1.1.1" - p-limit "^2.1.0" - parse-diff "^0.7.0" - parse-git-config "^2.0.3" - parse-github-url "^1.0.2" - parse-link-header "^2.0.0" - pinpoint "^1.1.0" - prettyjson "^1.2.1" - readline-sync "^1.4.9" - regenerator-runtime "^0.13.9" - require-from-string "^2.0.2" - supports-hyperlinks "^1.0.1" - data-uri-to-buffer@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" @@ -7665,13 +7575,6 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== -ecdsa-sig-formatter@1.0.11: - version "1.0.11" - resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" - integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== - dependencies: - safe-buffer "^5.0.1" - ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -8583,13 +8486,6 @@ expand-brackets@^2.1.4: snapdragon "^0.8.1" to-regex "^3.0.1" -expand-tilde@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502" - integrity sha1-l+gBqgUt8CRU3kawK/YhZCzchQI= - dependencies: - homedir-polyfill "^1.0.1" - expect@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" @@ -8751,7 +8647,7 @@ fast-glob@^3.2.9: merge2 "^1.3.0" micromatch "^4.0.4" -fast-json-patch@3.1.1, fast-json-patch@^3.0.0-1: +fast-json-patch@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/fast-json-patch/-/fast-json-patch-3.1.1.tgz#85064ea1b1ebf97a3f7ad01e23f9337e72c66947" integrity sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ== @@ -8985,11 +8881,6 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" -filter-obj@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b" - integrity sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ== - finalhandler@1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.1.tgz#0c575f1d1d324ddd1da35ad7ece3df7d19088019" @@ -9195,15 +9086,6 @@ form-data-encoder@^2.1.2: resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-2.1.4.tgz#261ea35d2a70d48d30ec7a9603130fa5515e9cd5" integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw== -form-data@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" - integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - mime-types "^2.1.12" - form-data@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" @@ -9250,11 +9132,6 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== -fs-exists-sync@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add" - integrity sha1-mC1ok6+RjnLQjeyehnP/K1qNat0= - fs-extra@11.2.0: version "11.2.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" @@ -9446,15 +9323,6 @@ gifsicle@^5.0.0: execa "^5.0.0" logalot "^2.0.0" -git-config-path@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/git-config-path/-/git-config-path-1.0.1.tgz#6d33f7ed63db0d0e118131503bab3aca47d54664" - integrity sha1-bTP37WPbDQ4RgTFQO6s6ykfVRmQ= - dependencies: - extend-shallow "^2.0.1" - fs-exists-sync "^0.1.0" - homedir-polyfill "^1.0.0" - glob-parent@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" @@ -9714,7 +9582,7 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" -got@^11.1.4, got@^11.8.5: +got@^11.8.5: version "11.8.6" resolved "https://registry.yarnpkg.com/got/-/got-11.8.6.tgz#276e827ead8772eddbcfc97170590b841823233a" integrity sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g== @@ -9873,11 +9741,6 @@ has-bigints@^1.0.1: resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== -has-flag@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51" - integrity sha1-6CB68cx7MNRGzHC3NLXovhj4jVE= - has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -10025,7 +9888,7 @@ hermes-parser@^0.25.1: dependencies: hermes-estree "0.25.1" -homedir-polyfill@^1.0.0, homedir-polyfill@^1.0.1: +homedir-polyfill@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8" integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA== @@ -10214,11 +10077,6 @@ human-signals@^4.3.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.1.tgz#ab7f811e851fca97ffbd2c1fe9a958964de321b2" integrity sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ== -hyperlinker@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/hyperlinker/-/hyperlinker-1.0.0.tgz#23dc9e38a206b208ee49bc2d6c8ef47027df0c0e" - integrity sha512-Ty8UblRWFEcfSuIaajM34LdPXIhbs1ajEX/BBPv24J+enSVaEVY63xQ6lTO9VRYS5LAoghIG0IDJ+p+IPzKUQQ== - hyphenate-style-name@^1.0.2, hyphenate-style-name@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.3.tgz#097bb7fa0b8f1a9cf0bd5c734cf95899981a9b48" @@ -10438,7 +10296,7 @@ ini@2.0.0, ini@~2.0.0: resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== -ini@^1.3.4, ini@^1.3.5, ini@~1.3.0, ini@~1.3.3: +ini@^1.3.4, ini@~1.3.0, ini@~1.3.3: version "1.3.5" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== @@ -11738,13 +11596,6 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" -json5@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.0.tgz#e7a0c62c48285c628d20a10b85c89bb807c32850" - integrity sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ== - dependencies: - minimist "^1.2.0" - json5@^2.1.2: version "2.1.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" @@ -11778,21 +11629,6 @@ jsonify@~0.0.0: resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" integrity sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM= -jsonpointer@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.1.tgz#2110e0af0900fd37467b5907ecd13a7884a1b559" - integrity sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ== - -jsonwebtoken@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz#d0faf9ba1cc3a56255fe49c0961a67e520c1926d" - integrity sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw== - dependencies: - jws "^3.2.2" - lodash "^4.17.21" - ms "^2.1.1" - semver "^7.3.8" - jsx-ast-utils@^1.3.4: version "1.4.1" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-1.4.1.tgz#3867213e8dd79bf1e8f2300c0cfc1efb182c0df1" @@ -11813,23 +11649,6 @@ junk@^3.1.0: resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1" integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ== -jwa@^1.4.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.2.tgz#16011ac6db48de7b102777e57897901520eec7b9" - integrity sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw== - dependencies: - buffer-equal-constant-time "^1.0.1" - ecdsa-sig-formatter "1.0.11" - safe-buffer "^5.0.1" - -jws@^3.2.2: - version "3.2.3" - resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.3.tgz#5ac0690b460900a27265de24520526853c0b8ca1" - integrity sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g== - dependencies: - jwa "^1.4.2" - safe-buffer "^5.0.1" - keyv@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.0.0.tgz#44923ba39e68b12a7cec7df6c3268c031f2ef373" @@ -11948,11 +11767,6 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" -li@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/li/-/li-1.3.0.tgz#22c59bcaefaa9a8ef359cf759784e4bf106aea1b" - integrity sha1-IsWbyu+qmo7zWc91l4TkvxBq6hs= - lie@~3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" @@ -12066,11 +11880,6 @@ lodash.difference@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.difference/-/lodash.difference-4.5.0.tgz#9ccb4e505d486b91651345772885a2df27fd017c" integrity sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw= -lodash.find@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/lodash.find/-/lodash.find-4.6.0.tgz#cb0704d47ab71789ffa0de8b97dd926fb88b13b1" - integrity sha1-ywcE1Hq3F4n/oN6Ll92Sb7iLE7E= - lodash.flatten@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" @@ -12081,11 +11890,6 @@ lodash.hasin@4.5.2: resolved "https://registry.yarnpkg.com/lodash.hasin/-/lodash.hasin-4.5.2.tgz#f91e352378d21ef7090b9e7687c2ca35c5b4d52a" integrity sha1-+R41I3jSHvcJC552h8LKNcW01So= -lodash.includes@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" - integrity sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8= - lodash.isempty@4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e" @@ -12096,31 +11900,11 @@ lodash.isnil@4.0.0: resolved "https://registry.yarnpkg.com/lodash.isnil/-/lodash.isnil-4.0.0.tgz#49e28cd559013458c814c5479d3c663a21bfaa6c" integrity sha1-SeKM1VkBNFjIFMVHnTxmOiG/qmw= -lodash.isobject@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/lodash.isobject/-/lodash.isobject-3.0.2.tgz#3c8fb8d5b5bf4bf90ae06e14f2a530a4ed935e1d" - integrity sha1-PI+41bW/S/kK4G4U8qUwpO2TXh0= - lodash.isplainobject@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= -lodash.keys@^4.0.8: - version "4.2.0" - resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-4.2.0.tgz#a08602ac12e4fb83f91fc1fb7a360a4d9ba35205" - integrity sha1-oIYCrBLk+4P5H8H7ejYKTZujUgU= - -lodash.mapvalues@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c" - integrity sha1-G6+lAF3p3W9PJmaMMMo3IwzJaJw= - -lodash.memoize@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" - integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= - lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" @@ -12384,13 +12168,6 @@ mem@^5.0.0: mimic-fn "^2.1.0" p-is-promise "^2.1.0" -memfs-or-file-map-to-github-branch@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/memfs-or-file-map-to-github-branch/-/memfs-or-file-map-to-github-branch-1.2.1.tgz#fdb9a85408262316a9bd5567409bf89be7d72f96" - integrity sha512-I/hQzJ2a/pCGR8fkSQ9l5Yx+FQ4e7X6blNHyWBm2ojeFLT3GVzGkTj7xnyWpdclrr7Nq4dmx3xrvu70m3ypzAQ== - dependencies: - "@octokit/rest" "^16.43.0 || ^17.11.0 || ^18.12.0" - memfs@^3.4.3: version "3.5.1" resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.5.1.tgz#f0cd1e2bfaef58f6fe09bfb9c2288f07fea099ec" @@ -12756,11 +12533,6 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== -node-cleanup@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/node-cleanup/-/node-cleanup-2.1.2.tgz#7ac19abd297e09a7f72a71545d951b517e4dde2c" - integrity sha1-esGavSl+Caf3KnFUXZUbUX5N3iw= - node-domexception@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" @@ -13248,11 +13020,6 @@ osenv@0.0.3: resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.0.3.tgz#cd6ad8ddb290915ad9e22765576025d411f29cb6" integrity sha1-zWrY3bKQkVrZ4idlV2Al1BHynLY= -override-require@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/override-require/-/override-require-1.1.1.tgz#6ae22fadeb1f850ffb0cf4c20ff7b87e5eb650df" - integrity sha1-auIvresfhQ/7DPTCD/e4fl62UN8= - p-cancelable@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa" @@ -13326,13 +13093,6 @@ p-limit@^2.0.0, p-limit@^2.2.0: dependencies: p-try "^2.0.0" -p-limit@^2.1.0: - version "2.2.2" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.2.tgz#61279b67721f5287aa1c13a9a7fbbc48c9291b1e" - integrity sha512-WGR+xHecKTr7EbUEhyLSh5Dube9JtdiG78ufaeLxTgpudf/20KqyMioIUZJAezlTIi6evxuoUs9YXc11cU+yzQ== - dependencies: - p-try "^2.0.0" - p-limit@^3.0.2, p-limit@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" @@ -13454,11 +13214,6 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" -parse-diff@^0.7.0: - version "0.7.1" - resolved "https://registry.yarnpkg.com/parse-diff/-/parse-diff-0.7.1.tgz#9b7a2451c3725baf2c87c831ba192d40ee2237d4" - integrity sha512-1j3l8IKcy4yRK2W4o9EYvJLSzpAVwz4DXqCewYyx2vEwk2gcf3DBPqc8Fj4XV3K33OYJ08A8fWwyu/ykD/HUSg== - parse-filepath@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/parse-filepath/-/parse-filepath-1.0.2.tgz#a632127f53aaf3d15876f5872f3ffac763d6c891" @@ -13468,20 +13223,6 @@ parse-filepath@^1.0.2: map-cache "^0.2.0" path-root "^0.1.1" -parse-git-config@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/parse-git-config/-/parse-git-config-2.0.3.tgz#6fb840d4a956e28b971c97b33a5deb73a6d5b6bb" - integrity sha512-Js7ueMZOVSZ3tP8C7E3KZiHv6QQl7lnJ+OkbxoaFazzSa2KyEHqApfGbU3XboUgUnq4ZuUmskUpYKTNx01fm5A== - dependencies: - expand-tilde "^2.0.2" - git-config-path "^1.0.1" - ini "^1.3.5" - -parse-github-url@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/parse-github-url/-/parse-github-url-1.0.2.tgz#242d3b65cbcdda14bb50439e3242acf6971db395" - integrity sha512-kgBf6avCbO3Cn6+RnzRGLkUsv4ZVqv/VfAYkRsyBcgkshNvVBkRn1FEZcW0Jb+npXQWm2vHPnnOqFteZxRRGNw== - parse-json@7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-7.1.1.tgz#68f7e6f0edf88c54ab14c00eb700b753b14e2120" @@ -13510,13 +13251,6 @@ parse-json@^5.2.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" -parse-link-header@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/parse-link-header/-/parse-link-header-2.0.0.tgz#949353e284f8aa01f2ac857a98f692b57733f6b7" - integrity sha512-xjU87V0VyHZybn2RrCX5TIFGxTVZE6zqqZWMPlIKiSKuWh/X5WZdt+w1Ki1nXB+8L/KtL+nZ4iq+sfI6MrhhMw== - dependencies: - xtend "~4.0.1" - parse-node-version@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parse-node-version/-/parse-node-version-1.0.1.tgz#e2b5dbede00e7fa9bc363607f53327e8b073189b" @@ -13766,11 +13500,6 @@ pino@8.20.0: sonic-boom "^3.7.0" thread-stream "^2.0.0" -pinpoint@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/pinpoint/-/pinpoint-1.1.0.tgz#0cf7757a6977f1bf7f6a32207b709e377388e874" - integrity sha1-DPd1eml38b9/ajIge3CeN3OI6HQ= - pirates@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/pirates/-/pirates-3.0.2.tgz#7e6f85413fd9161ab4e12b539b06010d85954bb9" @@ -14008,14 +13737,6 @@ pretty-format@^29.7.0: ansi-styles "^5.0.0" react-is "^18.0.0" -prettyjson@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/prettyjson/-/prettyjson-1.2.1.tgz#fcffab41d19cab4dfae5e575e64246619b12d289" - integrity sha1-/P+rQdGcq0365eV15kJGYZsS0ok= - dependencies: - colors "^1.1.2" - minimist "^1.2.0" - process-nextick-args@^2.0.0, process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -14198,16 +13919,6 @@ query-string@^5.0.1: object-assign "^4.1.0" strict-uri-encode "^1.0.0" -query-string@^6.12.1: - version "6.14.1" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.14.1.tgz#7ac2dca46da7f309449ba0f86b1fd28255b0c86a" - integrity sha512-XDxAeVmpfu1/6IjyT/gXHOl+S0vQ9owggJ30hhWKdHAsNPOcasn5o9BW0eejZqL2e4vMjhAxoW3jVHcD6mbcYw== - dependencies: - decode-uri-component "^0.2.0" - filter-obj "^1.1.0" - split-on-first "^1.0.0" - strict-uri-encode "^2.0.0" - querystring-es3@~0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" @@ -14517,11 +14228,6 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" -readline-sync@^1.4.9: - version "1.4.10" - resolved "https://registry.yarnpkg.com/readline-sync/-/readline-sync-1.4.10.tgz#41df7fbb4b6312d673011594145705bf56d8873b" - integrity sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw== - real-require@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" @@ -14578,11 +14284,6 @@ regenerator-runtime@^0.13.4: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697" integrity sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA== -regenerator-runtime@^0.13.9: - version "0.13.11" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" - integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== - regenerator-runtime@^0.14.0: version "0.14.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" @@ -14872,11 +14573,6 @@ ret@~0.1.10: resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== -retry@0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" - integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= - retry@^0.13.1: version "0.13.1" resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" @@ -15234,7 +14930,7 @@ semver@^7.1.1: resolved "https://registry.yarnpkg.com/semver/-/semver-7.1.1.tgz#29104598a197d6cbe4733eeecbe968f7b43a9667" integrity sha512-WfuG+fl6eh3eZ2qAf6goB7nhiCd7NPXhmyFxigB/TOkQyeLP8w8GsVehvtGNtnNmyboz4TgeK40B1Kbql/8c5A== -semver@^7.2.1, semver@^7.3.8: +semver@^7.2.1: version "7.3.8" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798" integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A== @@ -15700,11 +15396,6 @@ spdy@^4.0.2: select-hose "^2.0.0" spdy-transport "^3.0.0" -split-on-first@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f" - integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw== - split-string@^3.0.1, split-string@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" @@ -15806,11 +15497,6 @@ strict-uri-encode@^1.0.0: resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= -strict-uri-encode@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" - integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY= - string-length@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" @@ -16088,7 +15774,7 @@ supports-color@^2.0.0: resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= -supports-color@^5.0.0, supports-color@^5.3.0, supports-color@^5.4.0: +supports-color@^5.3.0, supports-color@^5.4.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== @@ -16109,14 +15795,6 @@ supports-color@^8.0.0: dependencies: has-flag "^4.0.0" -supports-hyperlinks@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-1.0.1.tgz#71daedf36cc1060ac5100c351bb3da48c29c0ef7" - integrity sha512-HHi5kVSefKaJkGYXbDuKbUGRVxqnWGn3J2e39CYcNJEfWciGq2zYtOhXLTlvrOZW1QU7VX67w7fMmWafHX9Pfw== - dependencies: - has-flag "^2.0.0" - supports-color "^5.0.0" - supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" @@ -17532,11 +17210,6 @@ ws@^7: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== -xcase@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/xcase/-/xcase-2.0.1.tgz#c7fa72caa0f440db78fd5673432038ac984450b9" - integrity sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw== - xdg-basedir@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13"