mirror of
https://github.com/hanzoai/next.js.git
synced 2026-10-30 14:46:38 +00:00
### What? Adds `next internal upload-trace` CLI command for uploading CPU profiles and Turbopack traces to Vercel Blob storage. ### Why? When investigating performance issues, the Next.js team often needs CPU profiles and Turbopack traces from contributors and bug reporters. Currently there's no streamlined way to collect these files. This command provides a single step to upload all profiling output after a build. ### How? - **`.next-profiles` directory**: CPU profiles (`--experimental-cpu-prof`) and Turbopack traces (`--internal-trace`) now write to `.next-profiles/` instead of separate locations, consolidating all profiling output. - **`next internal upload-trace [directory]`**: New CLI command that uploads `.cpuprofile` files and `trace-turbopack` from `.next-profiles/` to Vercel Blob via a secure handshake with an external API endpoint. - Validates file headers before upload (JSON for `.cpuprofile`, binary for `trace-turbopack`) - Skips empty files - Shows progress bar in TTY environments - Groups all files under a single server-assigned session ID - Only logs the session ID to the user (no internal blob URLs exposed) - **`@vercel/blob` compiled**: Vendored into `packages/next/src/compiled/@vercel/blob/` via ncc. - **`--internal-trace` CLI flag**: Added to `next build` and `next dev` to enable Turbopack tracing to `.next-profiles/trace-turbopack`. - **Security**: The server controls the storage path (client only sends filename), session continuity uses HMAC-signed tokens (stateless across serverless instances), `allowOverwrite: false` prevents replacing existing uploads, and filenames are validated against an allowlist. - **Tests**: Added production test suite with a mock HTTP server. Usage: ```bash next build --experimental-cpu-prof --internal-trace next internal upload-trace ```
148 lines
4.2 KiB
TypeScript
148 lines
4.2 KiB
TypeScript
import { nextTestSetup, isNextStart } from 'e2e-utils'
|
|
import { runNextCommand } from 'next-test-utils'
|
|
import http from 'http'
|
|
import path from 'path'
|
|
import fs from 'fs'
|
|
|
|
describe('upload-trace', () => {
|
|
if (!isNextStart) {
|
|
it('skipped for non-start mode', () => {})
|
|
return
|
|
}
|
|
|
|
const { next, isTurbopack, skipped } = nextTestSetup({
|
|
files: __dirname,
|
|
skipStart: true,
|
|
skipDeployment: true,
|
|
buildCommand: 'pnpm next build --experimental-cpu-prof --internal-trace',
|
|
})
|
|
|
|
if (skipped) return
|
|
|
|
it('should upload profiles and trace to the mock endpoint after build', async () => {
|
|
const buildResult = await next.build()
|
|
expect(buildResult.exitCode).toBe(0)
|
|
|
|
const profilesDir = path.join(next.testDir, '.next-profiles')
|
|
expect(fs.existsSync(profilesDir)).toBe(true)
|
|
|
|
const allFiles = fs.readdirSync(profilesDir)
|
|
const cpuProfiles = allFiles.filter((f: string) =>
|
|
f.endsWith('.cpuprofile')
|
|
)
|
|
expect(cpuProfiles.length).toBeGreaterThan(0)
|
|
|
|
if (isTurbopack) {
|
|
expect(allFiles).toContain('trace-turbopack')
|
|
}
|
|
|
|
const uploadableFiles = allFiles.filter(
|
|
(f: string) => f.endsWith('.cpuprofile') || f === 'trace-turbopack'
|
|
)
|
|
const expectedUploadCount = uploadableFiles.length
|
|
|
|
const handshakeRequests: any[] = []
|
|
|
|
const mockServer = http.createServer(async (req, res) => {
|
|
const chunks: Buffer[] = []
|
|
for await (const chunk of req) {
|
|
chunks.push(chunk as Buffer)
|
|
}
|
|
const body = Buffer.concat(chunks).toString('utf-8')
|
|
|
|
let parsed: any = null
|
|
try {
|
|
parsed = JSON.parse(body)
|
|
} catch {
|
|
// Not JSON — binary blob upload
|
|
}
|
|
|
|
// Handle the upload-trace token handshake (sends { filename })
|
|
if (parsed && parsed.filename) {
|
|
handshakeRequests.push(parsed)
|
|
|
|
res.writeHead(200, { 'content-type': 'application/json' })
|
|
res.end(
|
|
JSON.stringify({
|
|
clientToken: 'vercel_blob_client_TESTSTOREID_dGVzdC5wYXlsb2Fk',
|
|
pathname: `profiles/${parsed.filename}`,
|
|
sessionId: 'test-session-id',
|
|
sessionToken: 'test-session-token',
|
|
})
|
|
)
|
|
return
|
|
}
|
|
|
|
// Handle @vercel/blob put() — the actual blob upload
|
|
res.writeHead(200, { 'content-type': 'application/json' })
|
|
res.end(
|
|
JSON.stringify({
|
|
url: 'https://test.blob.vercel-storage.com/profiles/test',
|
|
downloadUrl:
|
|
'https://test.blob.vercel-storage.com/profiles/test?download=1',
|
|
pathname: 'profiles/test',
|
|
contentType: 'application/octet-stream',
|
|
contentDisposition: 'attachment; filename="test"',
|
|
})
|
|
)
|
|
})
|
|
|
|
await new Promise<void>((resolve) => {
|
|
mockServer.listen(0, '127.0.0.1', resolve)
|
|
})
|
|
|
|
const address = mockServer.address() as { port: number }
|
|
const mockUrl = `http://127.0.0.1:${address.port}`
|
|
|
|
try {
|
|
const result = await runNextCommand(['internal', 'upload-trace'], {
|
|
cwd: next.testDir,
|
|
stderr: true,
|
|
stdout: true,
|
|
env: {
|
|
__NEXT_UPLOAD_TRACE_URL_OVERRIDE: mockUrl,
|
|
VERCEL_BLOB_API_URL: mockUrl,
|
|
},
|
|
})
|
|
|
|
if (result.code !== 0) {
|
|
console.log('upload-trace stdout:', result.stdout)
|
|
console.log('upload-trace stderr:', result.stderr)
|
|
}
|
|
|
|
expect(result.code).toBe(0)
|
|
expect(handshakeRequests.length).toBe(expectedUploadCount)
|
|
for (const req of handshakeRequests) {
|
|
expect(req.filename).toBeTruthy()
|
|
}
|
|
expect(result.stdout + result.stderr).toContain(
|
|
'All files uploaded successfully'
|
|
)
|
|
} finally {
|
|
mockServer.close()
|
|
}
|
|
})
|
|
|
|
it('should fail gracefully when no profiles directory exists', async () => {
|
|
const emptyDir = path.join(next.testDir, 'empty-project')
|
|
fs.mkdirSync(emptyDir, { recursive: true })
|
|
|
|
const result = await runNextCommand(
|
|
['internal', 'upload-trace', emptyDir],
|
|
{
|
|
cwd: next.testDir,
|
|
stderr: true,
|
|
stdout: true,
|
|
env: {
|
|
__NEXT_UPLOAD_TRACE_URL_OVERRIDE: 'http://127.0.0.1:1',
|
|
},
|
|
}
|
|
)
|
|
|
|
expect(result.code).toBe(1)
|
|
expect(result.stdout + result.stderr).toContain(
|
|
'Profiles directory not found'
|
|
)
|
|
})
|
|
})
|