From ca5f12003088865f0518dbe6beedf80d4571a896 Mon Sep 17 00:00:00 2001 From: Luca Date: Fri, 3 Apr 2026 15:26:08 +0200 Subject: [PATCH 1/3] feat: add initial nextjs upload plugin --- bun.lock | 110 +++++++++- package.json | 2 +- packages/bundler-plugin/package.json | 32 ++- packages/bundler-plugin/src/index.ts | 19 ++ packages/nextjs-config/package.json | 41 ++++ packages/nextjs-config/src/index.ts | 5 + .../src/with-faststats-sourcemaps.ts | 204 ++++++++++++++++++ packages/nextjs-config/tsconfig.json | 18 ++ packages/nextjs-config/tsdown.config.ts | 10 + 9 files changed, 427 insertions(+), 14 deletions(-) create mode 100644 packages/nextjs-config/package.json create mode 100644 packages/nextjs-config/src/index.ts create mode 100644 packages/nextjs-config/src/with-faststats-sourcemaps.ts create mode 100644 packages/nextjs-config/tsconfig.json create mode 100644 packages/nextjs-config/tsdown.config.ts diff --git a/bun.lock b/bun.lock index ab36caa..8ff949a 100644 --- a/bun.lock +++ b/bun.lock @@ -36,6 +36,20 @@ "typescript": "^5.9.3 || ^6.0.0", }, }, + "packages/nextjs-config": { + "name": "@faststats/sourcemap-uploader-nextjs", + "version": "0.1.0", + "devDependencies": { + "@faststats/sourcemap-uploader-plugin": "workspace:*", + "@types/node": "^25.0.0", + "tsdown": "^0.21.0", + "typescript": "6.0.2", + }, + "peerDependencies": { + "@faststats/sourcemap-uploader-plugin": ">=0.4.0", + "next": ">=14.0.0", + }, + }, "packages/proguard-plugin": { "name": "@faststats/proguard-mappings-upload-plugin", "version": "0.1.0", @@ -170,8 +184,60 @@ "@faststats/proguard-mappings-upload-plugin": ["@faststats/proguard-mappings-upload-plugin@workspace:packages/proguard-plugin"], + "@faststats/sourcemap-uploader-nextjs": ["@faststats/sourcemap-uploader-nextjs@workspace:packages/nextjs-config"], + "@faststats/sourcemap-uploader-plugin": ["@faststats/sourcemap-uploader-plugin@workspace:packages/bundler-plugin"], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -202,7 +268,25 @@ "@module-federation/webpack-bundler-runtime": ["@module-federation/webpack-bundler-runtime@0.22.0", "", { "dependencies": { "@module-federation/runtime": "0.22.0", "@module-federation/sdk": "0.22.0" } }, "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@next/env": ["@next/env@15.4.8", "", {}, "sha512-LydLa2MDI1NMrOFSkO54mTc8iIHSttj6R6dthITky9ylXV2gCGi0bHQjVCtLGRshdRPjyh2kXbxJukDtBWQZtQ=="], + + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Pf6zXp7yyQEn7sqMxur6+kYcywx5up1J849psyET7/8pG2gQTVMjU3NzgIt8SeEP5to3If/SaWmaA6H6ysBr1A=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-xla6AOfz68a6kq3gRQccWEvFC/VRGJmA/QuSLENSO7CZX5WIEkSz7r1FdXUjtGCQ1c2M+ndUAH7opdfLK1PQbw=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-y3fmp+1Px/SJD+5ntve5QLZnGLycsxsVPkTzAc3zUiXYSOlTPqT8ynfmt6tt4fSo1tAhDPmryXpYKEAcoAPDJw=="], + + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-DX/L8VHzrr1CfwaVjBQr3GWCqNNFgyWJbeQ10Lx/phzbQo3JNAxUok1DZ8JHRGcL6PgMRgj6HylnLNndxn4Z6A=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-9fLAAXKAL3xEIFdKdzG5rUSvSiZTLLTCc6JKq1z04DR4zY7DbAPcRvNm3K1inVhTiQCs19ZRAgUerHiVKMZZIA=="], + + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-s45V7nfb5g7dbS7JK6XZDcapicVrMMvX2uYgOHP16QuKH/JA285oy6HcxlKqwUNaFY/UC6EvQ8QZUOo19cBKSA=="], + + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-KjgeQyOAq7t/HzAJcWPGA8X+4WY03uSCZ2Ekk98S9OgCFsb6lfBE3dbUzUuEQAN2THbwYgFfxX2yFTCMm8Kehw=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-Exsmf/+42fWVnLMaZHzshukTBxZrSwuuLKFvqhGHJ+mC1AokqieLY/XzAl3jc/CqhXLqLY3RRjkKJ9YnLPcRWg=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -326,6 +410,8 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.9.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-P8foouaP+y/p+hhEGBoZpzMbpVvUMwPjDpcy6wN7EYfvvyISD1USuV27qWkczecihwuPJzQ1lDBuL8ERcavTyg=="], "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SIzEkvtNdzdI50FJDaIQ6kQGqgSSdFPcdn0wqmmONN6iGKjy6hsT+EH99GP65FsfV7DLZTh2NmtTIRl2kdoz5Q=="], @@ -452,6 +538,8 @@ "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -464,6 +552,8 @@ "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "dotenv": ["dotenv@8.6.0", "", {}, "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g=="], @@ -588,6 +678,8 @@ "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "next": ["next@15.4.8", "", { "dependencies": { "@next/env": "15.4.8", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.8", "@next/swc-darwin-x64": "15.4.8", "@next/swc-linux-arm64-gnu": "15.4.8", "@next/swc-linux-arm64-musl": "15.4.8", "@next/swc-linux-x64-gnu": "15.4.8", "@next/swc-linux-x64-musl": "15.4.8", "@next/swc-win32-arm64-msvc": "15.4.8", "@next/swc-win32-x64-msvc": "15.4.8", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-jwOXTz/bo0Pvlf20FSb6VXVeWRssA2vbvq9SdrOPEg9x8E1B27C2rQtvriAn600o9hH61kjrVRexEffv3JybuA=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], @@ -622,7 +714,7 @@ "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], - "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + "postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], @@ -632,6 +724,10 @@ "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -654,12 +750,16 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -688,6 +788,8 @@ "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], @@ -762,7 +864,7 @@ "@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], - "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + "@rspack/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], "@types/webpack/@types/node": ["@types/node@24.11.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw=="], @@ -780,6 +882,8 @@ "vite/esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="], + "vite/postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + "@types/webpack/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "@types/webpack/webpack/terser-webpack-plugin": ["terser-webpack-plugin@5.3.16", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q=="], diff --git a/package.json b/package.json index 5b39f39..08fe990 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "scripts": { "build": "turbo run build", - "build:packages": "turbo run build --filter=@faststats/sourcemap-uploader-plugin && bun run build:proguard-plugin", + "build:packages": "turbo run build --filter=@faststats/sourcemap-uploader-plugin --filter=@faststats/sourcemap-uploader-nextjs && bun run build:proguard-plugin", "build:proguard-plugin": "bun run --cwd packages/proguard-plugin build", "sync-proguard-version": "bun scripts/sync-proguard-version.ts", "dev": "turbo run dev", diff --git a/packages/bundler-plugin/package.json b/packages/bundler-plugin/package.json index c7ff73f..28d0b35 100644 --- a/packages/bundler-plugin/package.json +++ b/packages/bundler-plugin/package.json @@ -10,6 +10,8 @@ "url": "https://github.com/faststats-dev/sourcemaps" }, "type": "module", + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", "files": [ "dist", "README.md" @@ -18,43 +20,53 @@ "exports": { ".": { "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" }, "./vite": { "types": "./dist/bundler/vite.d.mts", - "import": "./dist/bundler/vite.mjs" + "import": "./dist/bundler/vite.mjs", + "default": "./dist/bundler/vite.mjs" }, "./rollup": { "types": "./dist/bundler/rollup.d.mts", - "import": "./dist/bundler/rollup.mjs" + "import": "./dist/bundler/rollup.mjs", + "default": "./dist/bundler/rollup.mjs" }, "./rolldown": { "types": "./dist/bundler/rolldown.d.mts", - "import": "./dist/bundler/rolldown.mjs" + "import": "./dist/bundler/rolldown.mjs", + "default": "./dist/bundler/rolldown.mjs" }, "./webpack": { "types": "./dist/bundler/webpack.d.mts", - "import": "./dist/bundler/webpack.mjs" + "import": "./dist/bundler/webpack.mjs", + "default": "./dist/bundler/webpack.mjs" }, "./rspack": { "types": "./dist/bundler/rspack.d.mts", - "import": "./dist/bundler/rspack.mjs" + "import": "./dist/bundler/rspack.mjs", + "default": "./dist/bundler/rspack.mjs" }, "./esbuild": { "types": "./dist/bundler/esbuild.d.mts", - "import": "./dist/bundler/esbuild.mjs" + "import": "./dist/bundler/esbuild.mjs", + "default": "./dist/bundler/esbuild.mjs" }, "./unloader": { "types": "./dist/bundler/unloader.d.mts", - "import": "./dist/bundler/unloader.mjs" + "import": "./dist/bundler/unloader.mjs", + "default": "./dist/bundler/unloader.mjs" }, "./farm": { "types": "./dist/bundler/farm.d.mts", - "import": "./dist/bundler/farm.mjs" + "import": "./dist/bundler/farm.mjs", + "default": "./dist/bundler/farm.mjs" }, "./bun": { "types": "./dist/bundler/bun.d.mts", - "import": "./dist/bundler/bun.mjs" + "import": "./dist/bundler/bun.mjs", + "default": "./dist/bundler/bun.mjs" } }, "scripts": { diff --git a/packages/bundler-plugin/src/index.ts b/packages/bundler-plugin/src/index.ts index 50612c4..5bde321 100644 --- a/packages/bundler-plugin/src/index.ts +++ b/packages/bundler-plugin/src/index.ts @@ -544,6 +544,25 @@ const unpluginInstance = createUnplugin( }, ); +export async function uploadSourcemapsFromDirectory( + outputDir: string, + options: BundlerPluginOptions, +): Promise { + const buildId = + options.buildId ?? + getGitCommitHashSync() ?? + `random_${crypto.randomUUID()}`; + try { + const sourcemaps = await collectFromOutputDirectory(outputDir); + if (sourcemaps.length === 0) { + return; + } + await uploadAndMaybeDelete(options, buildId, sourcemaps, outputDir); + } catch (error) { + await handleUploadError(options, error); + } +} + export const sourcemapsPlugin = unpluginInstance; export const vite = sourcemapsPlugin.vite; export const rollup = sourcemapsPlugin.rollup; diff --git a/packages/nextjs-config/package.json b/packages/nextjs-config/package.json new file mode 100644 index 0000000..2d4944c --- /dev/null +++ b/packages/nextjs-config/package.json @@ -0,0 +1,41 @@ +{ + "name": "@faststats/sourcemap-uploader-nextjs", + "version": "0.1.0", + "publishConfig": { + "access": "public", + "provenance": true + }, + "repository": { + "type": "git", + "url": "https://github.com/faststats-dev/sourcemaps" + }, + "type": "module", + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "files": [ + "dist" + ], + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsdown", + "lint": "biome check .", + "check-types": "tsc --noEmit" + }, + "peerDependencies": { + "@faststats/sourcemap-uploader-plugin": ">=0.4.0", + "next": ">=14.0.0" + }, + "devDependencies": { + "@faststats/sourcemap-uploader-plugin": "workspace:*", + "@types/node": "^25.0.0", + "tsdown": "^0.21.0", + "typescript": "6.0.2" + } +} diff --git a/packages/nextjs-config/src/index.ts b/packages/nextjs-config/src/index.ts new file mode 100644 index 0000000..267e959 --- /dev/null +++ b/packages/nextjs-config/src/index.ts @@ -0,0 +1,5 @@ +export { + type NextConfigLike, + type WithFaststatsSourcemapsOptions, + withFaststatsSourcemaps, +} from "./with-faststats-sourcemaps"; diff --git a/packages/nextjs-config/src/with-faststats-sourcemaps.ts b/packages/nextjs-config/src/with-faststats-sourcemaps.ts new file mode 100644 index 0000000..bbaedba --- /dev/null +++ b/packages/nextjs-config/src/with-faststats-sourcemaps.ts @@ -0,0 +1,204 @@ +import { isAbsolute, join } from "node:path"; +import type { BundlerPluginOptions } from "@faststats/sourcemap-uploader-plugin"; +import { uploadSourcemapsFromDirectory } from "@faststats/sourcemap-uploader-plugin"; +import createSourcemapsWebpackPlugin from "@faststats/sourcemap-uploader-plugin/webpack"; + +export type WithFaststatsSourcemapsOptions = BundlerPluginOptions & { + useWebpackPlugin?: boolean | "auto"; + useRunAfterProductionCompile?: boolean | "auto"; +}; + +type CompileParams = { distDir: string; projectDir: string }; + +type WebpackConfigLike = { + plugins?: unknown[]; + [key: string]: unknown; +}; + +type WebpackContextLike = { + dev: boolean; + [key: string]: unknown; +}; + +export type NextConfigLike = { + webpack?: ( + config: WebpackConfigLike, + context: WebpackContextLike, + ) => WebpackConfigLike; + compiler?: { + runAfterProductionCompile?: (params: CompileParams) => void | Promise; + [key: string]: unknown; + }; + [key: string]: unknown; +}; + +type NextConfigInternal = { + webpack?: ( + config: WebpackConfigLike, + context: WebpackContextLike, + ) => WebpackConfigLike; + compiler?: { + runAfterProductionCompile?: (params: CompileParams) => void | Promise; + [key: string]: unknown; + }; +}; + +const PLUGIN_OPTION_KEYS: ReadonlySet = new Set([ + "enabled", + "endpoint", + "authToken", + "buildId", + "maxUploadBodyBytes", + "failOnError", + "deleteAfterUpload", + "globalKey", + "fetchImpl", + "onUploadSuccess", + "onUploadError", + "useWebpackPlugin", + "useRunAfterProductionCompile", +]); + +function isPluginOptionsOnly(value: object): boolean { + const keys = Object.keys(value); + if (keys.length === 0) { + return true; + } + return keys.every((key) => PLUGIN_OPTION_KEYS.has(key)); +} + +function resolvePluginEnabled( + options: WithFaststatsSourcemapsOptions, +): boolean { + const enabled = options.enabled; + if (typeof enabled === "function") { + return enabled(undefined); + } + return enabled ?? true; +} + +function isLikelyTurbopackBuild(): boolean { + if (process.env.TURBOPACK === "1") { + return true; + } + return process.argv.some((arg) => arg.includes("turbopack")); +} + +function resolveUseWebpackPlugin( + options: WithFaststatsSourcemapsOptions, +): boolean { + const value = options.useWebpackPlugin ?? "auto"; + if (value === true) { + return true; + } + if (value === false) { + return false; + } + return !isLikelyTurbopackBuild(); +} + +function resolveUseRunAfterProductionCompile( + options: WithFaststatsSourcemapsOptions, +): boolean { + const value = options.useRunAfterProductionCompile ?? "auto"; + if (value === true) { + return true; + } + if (value === false) { + return false; + } + return isLikelyTurbopackBuild(); +} + +function resolveDistDir(params: CompileParams): string { + return isAbsolute(params.distDir) + ? params.distDir + : join(params.projectDir, params.distDir); +} + +function applyWithFaststatsSourcemaps( + nextConfig: T, + pluginOptions: WithFaststatsSourcemapsOptions, +): T { + if (!resolvePluginEnabled(pluginOptions)) { + return nextConfig; + } + + const useWebpackPlugin = resolveUseWebpackPlugin(pluginOptions); + const useHook = resolveUseRunAfterProductionCompile(pluginOptions); + + const { + useWebpackPlugin: _omitWebpack, + useRunAfterProductionCompile: _omitHook, + ...bundlerOptions + } = pluginOptions; + + const internal = nextConfig as unknown as NextConfigInternal; + const previousWebpack = internal.webpack; + const previousRunAfterProductionCompile = + internal.compiler?.runAfterProductionCompile; + + const chainWebpack = previousWebpack + ? (previousWebpack as ( + config: WebpackConfigLike, + context: WebpackContextLike, + ) => WebpackConfigLike) + : undefined; + + return { + ...nextConfig, + webpack(config: WebpackConfigLike, context: WebpackContextLike) { + const resolved = chainWebpack ? chainWebpack(config, context) : config; + if ( + useWebpackPlugin && + !context.dev && + process.env.NODE_ENV === "production" + ) { + resolved.plugins ??= []; + resolved.plugins.push(createSourcemapsWebpackPlugin(bundlerOptions)); + } + return resolved; + }, + compiler: { + ...(internal.compiler ?? {}), + runAfterProductionCompile: async (params: CompileParams) => { + if (typeof previousRunAfterProductionCompile === "function") { + await previousRunAfterProductionCompile(params); + } + if (!useHook || process.env.NODE_ENV !== "production") { + return; + } + await uploadSourcemapsFromDirectory( + resolveDistDir(params), + bundlerOptions, + ); + }, + }, + } as T; +} + +export function withFaststatsSourcemaps( + pluginOptions: WithFaststatsSourcemapsOptions, +): (nextConfig: T) => T; +export function withFaststatsSourcemaps( + nextConfig: T, + pluginOptions?: WithFaststatsSourcemapsOptions, +): T; +export function withFaststatsSourcemaps( + nextConfigOrPluginOptions: unknown, + maybePluginOptions?: WithFaststatsSourcemapsOptions, +): unknown { + if (maybePluginOptions !== undefined) { + return applyWithFaststatsSourcemaps( + nextConfigOrPluginOptions, + maybePluginOptions, + ); + } + if (isPluginOptionsOnly(nextConfigOrPluginOptions as object)) { + const pluginOptions = + nextConfigOrPluginOptions as WithFaststatsSourcemapsOptions; + return (nextConfig: T) => + applyWithFaststatsSourcemaps(nextConfig, pluginOptions); + } + return applyWithFaststatsSourcemaps(nextConfigOrPluginOptions, {}); +} diff --git a/packages/nextjs-config/tsconfig.json b/packages/nextjs-config/tsconfig.json new file mode 100644 index 0000000..be9f4c7 --- /dev/null +++ b/packages/nextjs-config/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "types": ["node"], + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true + }, + "include": ["**/*.ts"] +} diff --git a/packages/nextjs-config/tsdown.config.ts b/packages/nextjs-config/tsdown.config.ts new file mode 100644 index 0000000..2e73630 --- /dev/null +++ b/packages/nextjs-config/tsdown.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + }, + format: ["esm"], + clean: true, + outDir: "dist", +}); From 38ceae28017f8e85d94a111bd1b286268b032d8a Mon Sep 17 00:00:00 2001 From: Luca Date: Fri, 3 Apr 2026 15:55:49 +0200 Subject: [PATCH 2/3] feat: enhance sourcemaps bundler plugin with debug logging and directory scanning options --- packages/bundler-plugin/src/index.ts | 392 +++++++++++++++--- .../src/with-faststats-sourcemaps.ts | 25 +- 2 files changed, 353 insertions(+), 64 deletions(-) diff --git a/packages/bundler-plugin/src/index.ts b/packages/bundler-plugin/src/index.ts index 5bde321..032451a 100644 --- a/packages/bundler-plugin/src/index.ts +++ b/packages/bundler-plugin/src/index.ts @@ -1,4 +1,5 @@ -import { readdir, readFile, rm } from "node:fs/promises"; +import type { Stats } from "node:fs"; +import { readdir, readFile, realpath, rm, stat } from "node:fs/promises"; import { dirname, isAbsolute, join, relative } from "node:path"; import type { NormalizedOutputOptions, @@ -91,10 +92,44 @@ export type BundlerPluginOptions = { fetchImpl?: typeof fetch; onUploadSuccess?: (payload: UploadPayload) => void | Promise; onUploadError?: (error: unknown) => void | Promise; + sourcemapScanSkipDirectoryNames?: string[]; + sourcemapScanRoots?: string[]; + debug?: boolean; }; +const MAP_SCAN_ENTRY_CONCURRENCY = 64; +const MAP_READ_CONCURRENCY = 48; +const BATCH_SIZE_ESTIMATE_MARGIN_CAP = 2048; + const pluginName = "sourcemaps-bundler-plugin"; +const DEBUG_ENV_KEY = "FASTSTATS_SOURCEMAPS_DEBUG"; + +const isSourcemapsDebug = (options: BundlerPluginOptions): boolean => + options.debug === true || process.env[DEBUG_ENV_KEY] === "1"; + +const debugLog = ( + options: BundlerPluginOptions, + message: string, + details?: Record, +): void => { + if (!isSourcemapsDebug(options)) { + return; + } + const suffix = + details && Object.keys(details).length > 0 + ? ` ${JSON.stringify(details)}` + : ""; + console.error(`[faststats:sourcemaps] ${message}${suffix}`); +}; + +type ScanProgress = { + dirsEntered: number; + revisitSkipped: number; + filesSeen: number; + skippedByName: number; +}; + const resolveEnabled = ( enabled: BundlerPluginOptions["enabled"], framework: BundlerName | undefined, @@ -148,7 +183,14 @@ const postSourcemaps = async ( options: BundlerPluginOptions, payload: UploadPayload, ): Promise => { + const body = JSON.stringify(payload); + debugLog(options, "upload POST start", { + filesInBatch: payload.files.length, + bodyBytes: Buffer.byteLength(body, "utf8"), + endpoint: options.endpoint ?? DEFAULT_ENDPOINT, + }); const fetchImpl = options.fetchImpl ?? fetch; + const t0 = Date.now(); const response = await fetchImpl(options.endpoint ?? DEFAULT_ENDPOINT, { method: "POST", headers: { @@ -157,7 +199,12 @@ const postSourcemaps = async ( ? { authorization: `Bearer ${options.authToken}` } : {}), }, - body: JSON.stringify(payload), + body, + }); + debugLog(options, "upload POST response", { + ms: Date.now() - t0, + status: response.status, + ok: response.ok, }); if (!response.ok) { @@ -165,9 +212,6 @@ const postSourcemaps = async ( } }; -const payloadSizeBytes = (payload: UploadPayload): number => - Buffer.byteLength(JSON.stringify(payload), "utf8"); - const createUploadBatches = ( buildId: string, files: UploadFile[], @@ -177,44 +221,77 @@ const createUploadBatches = ( throw new Error("maxUploadBodyBytes must be a positive number"); } + const batchMargin = Math.min( + BATCH_SIZE_ESTIMATE_MARGIN_CAP, + Math.max(0, Math.floor(maxUploadBodyBytes * 0.05)), + ); + const budget = Math.max(1, maxUploadBodyBytes - batchMargin); const uploadedAt = new Date().toISOString(); - const batches: UploadPayload[] = []; - let currentBatch: UploadFile[] = []; + const filePieceBytes = files.map((f) => + Buffer.byteLength( + JSON.stringify({ fileName: f.fileName, content: f.content }), + "utf8", + ), + ); - const toPayload = (batch: UploadFile[]): UploadPayload => ({ - type: "javascript", + const probe = JSON.stringify({ + type: "javascript" as const, buildId, uploadedAt, - files: batch, + files: [], }); + const filesMarker = '"files":['; + const mi = probe.indexOf(filesMarker); + if (mi === -1) { + throw new Error("createUploadBatches: could not parse empty payload shape"); + } + const head = probe.slice(0, mi + filesMarker.length); + const tail = probe.slice(mi + filesMarker.length); + const headTailBytes = Buffer.byteLength(head + tail, "utf8"); - const assertWithinLimit = (batch: UploadFile[], fileName: string) => { - if (payloadSizeBytes(toPayload(batch)) > maxUploadBodyBytes) { - throw new Error( - `Sourcemap "${fileName}" exceeds maxUploadBodyBytes limit`, - ); - } - }; - - for (const file of files) { - const nextBatch = [...currentBatch, file]; - - if (payloadSizeBytes(toPayload(nextBatch)) <= maxUploadBodyBytes) { - currentBatch = nextBatch; - continue; + const batches: UploadPayload[] = []; + let idx = 0; + while (idx < files.length) { + const batchFiles: UploadFile[] = []; + let batchBytes = headTailBytes; + + while (idx < files.length) { + const file = files[idx] as UploadFile; + const pBytes = filePieceBytes[idx] as number; + const extra = batchFiles.length > 0 ? 1 : 0; + if (batchBytes + extra + pBytes <= budget) { + batchFiles.push(file); + batchBytes += extra + pBytes; + idx++; + continue; + } + if (batchFiles.length > 0) { + break; + } + const solo: UploadPayload = { + type: "javascript", + buildId, + uploadedAt, + files: [file], + }; + const soloBytes = Buffer.byteLength(JSON.stringify(solo), "utf8"); + if (soloBytes > maxUploadBodyBytes) { + throw new Error( + `Sourcemap "${file.fileName}" exceeds maxUploadBodyBytes limit`, + ); + } + batches.push(solo); + idx++; } - if (currentBatch.length === 0) { - assertWithinLimit([file], file.fileName); + if (batchFiles.length > 0) { + batches.push({ + type: "javascript", + buildId, + uploadedAt, + files: batchFiles, + }); } - - batches.push(toPayload(currentBatch)); - currentBatch = [file]; - assertWithinLimit(currentBatch, file.fileName); - } - - if (currentBatch.length > 0) { - batches.push(toPayload(currentBatch)); } return batches; @@ -243,35 +320,190 @@ const deleteFiles = async ( ); }; -const scanDirectoryRecursively = async (rootDir: string): Promise => { - const entries = await readdir(rootDir, { withFileTypes: true }); - const nested = await Promise.all( - entries.map(async (entry) => { - const fullPath = join(rootDir, entry.name); - if (entry.isDirectory()) { - return scanDirectoryRecursively(fullPath); - } - return [fullPath]; - }), - ); +const scanDirectoryForMapPaths = async ( + dirPath: string, + visitedRealDirs: Set, + skipDirectoryNames: ReadonlySet, + options: BundlerPluginOptions, + progress: ScanProgress, +): Promise => { + let canonical: string; + try { + canonical = await realpath(dirPath); + } catch { + return []; + } + if (visitedRealDirs.has(canonical)) { + progress.revisitSkipped++; + if ( + isSourcemapsDebug(options) && + (progress.revisitSkipped <= 8 || progress.revisitSkipped % 500 === 0) + ) { + debugLog(options, "scan skip revisiting path", { + revisitSkipped: progress.revisitSkipped, + canonical, + }); + } + return []; + } + visitedRealDirs.add(canonical); + progress.dirsEntered++; + if ( + isSourcemapsDebug(options) && + (progress.dirsEntered <= 16 || progress.dirsEntered % 250 === 0) + ) { + debugLog(options, "scan entered directory", { + dirsEntered: progress.dirsEntered, + filesSeen: progress.filesSeen, + canonical, + }); + } + + let dirents: import("node:fs").Dirent[]; + try { + dirents = await readdir(dirPath, { withFileTypes: true }); + } catch { + return []; + } - return nested.flat(); + const mapPaths: string[] = []; + for (let c = 0; c < dirents.length; c += MAP_SCAN_ENTRY_CONCURRENCY) { + const chunk = dirents.slice(c, c + MAP_SCAN_ENTRY_CONCURRENCY); + const nested = await Promise.all( + chunk.map(async (entry) => { + const name = entry.name; + if (skipDirectoryNames.has(name)) { + progress.skippedByName++; + if ( + isSourcemapsDebug(options) && + (progress.skippedByName <= 12 || + progress.skippedByName % 500 === 0) + ) { + debugLog(options, "scan skip directory by name", { + skippedByName: progress.skippedByName, + name, + parent: dirPath, + }); + } + return [] as string[]; + } + const fullPath = join(dirPath, name); + if (entry.isDirectory()) { + return scanDirectoryForMapPaths( + fullPath, + visitedRealDirs, + skipDirectoryNames, + options, + progress, + ); + } + if (entry.isFile() && name.endsWith(".map")) { + progress.filesSeen++; + if ( + isSourcemapsDebug(options) && + (progress.filesSeen <= 20 || progress.filesSeen % 2000 === 0) + ) { + debugLog(options, "scan map file", { + filesSeen: progress.filesSeen, + path: fullPath, + }); + } + return [fullPath]; + } + if (!entry.isFile() && !entry.isDirectory()) { + let st: Stats; + try { + st = await stat(fullPath); + } catch { + return []; + } + if (st.isDirectory()) { + return scanDirectoryForMapPaths( + fullPath, + visitedRealDirs, + skipDirectoryNames, + options, + progress, + ); + } + if (st.isFile() && name.endsWith(".map")) { + progress.filesSeen++; + return [fullPath]; + } + } + return []; + }), + ); + for (const part of nested) { + mapPaths.push(...part); + } + } + return mapPaths; }; const collectFromOutputDirectory = async ( outputDir: string, + options: BundlerPluginOptions, ): Promise => { - const files = await scanDirectoryRecursively(outputDir); - const sourcemapFiles = files.filter((filePath) => filePath.endsWith(".map")); - return Promise.all( - sourcemapFiles.map(async (filePath) => { - const content = await readFile(filePath, "utf8"); - return { - fileName: relative(outputDir, filePath), - content, - } satisfies UploadFile; - }), + const skipDirectoryNames = new Set( + options.sourcemapScanSkipDirectoryNames ?? [], ); + const roots = + options.sourcemapScanRoots && options.sourcemapScanRoots.length > 0 + ? options.sourcemapScanRoots.map((r) => join(outputDir, r)) + : [outputDir]; + debugLog(options, "collectFromOutputDirectory start", { + outputDir, + roots, + skipDirectoryNames: [...skipDirectoryNames], + }); + const progress: ScanProgress = { + dirsEntered: 0, + revisitSkipped: 0, + filesSeen: 0, + skippedByName: 0, + }; + const scanT0 = Date.now(); + const visited = new Set(); + const sourcemapFiles: string[] = []; + for (const root of roots) { + const found = await scanDirectoryForMapPaths( + root, + visited, + skipDirectoryNames, + options, + progress, + ); + sourcemapFiles.push(...found); + } + debugLog(options, "collectFromOutputDirectory scan done", { + ms: Date.now() - scanT0, + dirsEntered: progress.dirsEntered, + revisitSkipped: progress.revisitSkipped, + filesSeen: progress.filesSeen, + skippedByName: progress.skippedByName, + mapPaths: sourcemapFiles.length, + }); + const readT0 = Date.now(); + const result: UploadFile[] = []; + for (let i = 0; i < sourcemapFiles.length; i += MAP_READ_CONCURRENCY) { + const slice = sourcemapFiles.slice(i, i + MAP_READ_CONCURRENCY); + const part = await Promise.all( + slice.map(async (filePath) => { + const content = await readFile(filePath, "utf8"); + return { + fileName: relative(outputDir, filePath), + content, + } satisfies UploadFile; + }), + ); + result.push(...part); + } + debugLog(options, "collectFromOutputDirectory read maps done", { + ms: Date.now() - readT0, + mapFilesRead: result.length, + }); + return result; }; const getRecord = (value: unknown): Record | undefined => @@ -320,12 +552,29 @@ const uploadAndMaybeDelete = async ( return; } + debugLog(options, "batching uploads", { + mapFileCount: files.length, + maxUploadBodyBytes: + options.maxUploadBodyBytes ?? DEFAULT_MAX_UPLOAD_BODY_BYTES, + }); + const batchT0 = Date.now(); const batches = createUploadBatches( buildId, files, options.maxUploadBodyBytes ?? DEFAULT_MAX_UPLOAD_BODY_BYTES, ); + debugLog(options, "batching done", { + ms: Date.now() - batchT0, + batchCount: batches.length, + }); + let batchIndex = 0; for (const payload of batches) { + batchIndex++; + debugLog(options, "upload batch", { + index: batchIndex, + of: batches.length, + files: payload.files.length, + }); await postSourcemaps(options, payload); await options.onUploadSuccess?.(payload); } @@ -357,6 +606,9 @@ const rollupWriteBundle = (options: BundlerPluginOptions, buildId: string) => ): Promise { try { const sourcemaps = collectFromBundle(bundle); + debugLog(options, "rollup writeBundle map outputs", { + mapCount: sourcemaps.length, + }); if (sourcemaps.length === 0) return; const outputDir = @@ -407,12 +659,17 @@ const applyWebpackLikeHooks = ( async (assets) => { try { const sourcemaps = collectFromWebpackAssets(assets); + debugLog(options, "webpack processAssets", { + mapAssetCount: sourcemaps.length, + totalAssetCount: Object.keys(assets).length, + }); if (sourcemaps.length === 0) { return; } const outputPath = compiler.options.output?.path; if (!outputPath) { + debugLog(options, "webpack processAssets missing output.path", {}); return; } await uploadAndMaybeDelete(options, buildId, sourcemaps, outputPath); @@ -530,7 +787,10 @@ const unpluginInstance = createUnplugin( return; } - const sourcemaps = await collectFromOutputDirectory(outputDir); + const sourcemaps = await collectFromOutputDirectory( + outputDir, + options, + ); if (sourcemaps.length === 0) { return; } @@ -548,17 +808,33 @@ export async function uploadSourcemapsFromDirectory( outputDir: string, options: BundlerPluginOptions, ): Promise { + debugLog(options, "uploadSourcemapsFromDirectory start", { outputDir }); + const buildT0 = Date.now(); const buildId = options.buildId ?? getGitCommitHashSync() ?? `random_${crypto.randomUUID()}`; + debugLog(options, "uploadSourcemapsFromDirectory buildId", { + buildId, + resolvedMs: Date.now() - buildT0, + }); try { - const sourcemaps = await collectFromOutputDirectory(outputDir); + const sourcemaps = await collectFromOutputDirectory(outputDir, options); if (sourcemaps.length === 0) { + debugLog(options, "uploadSourcemapsFromDirectory no map files", { + outputDir, + }); return; } await uploadAndMaybeDelete(options, buildId, sourcemaps, outputDir); + debugLog(options, "uploadSourcemapsFromDirectory complete", { + outputDir, + mapFiles: sourcemaps.length, + }); } catch (error) { + debugLog(options, "uploadSourcemapsFromDirectory error", { + message: error instanceof Error ? error.message : String(error), + }); await handleUploadError(options, error); } } diff --git a/packages/nextjs-config/src/with-faststats-sourcemaps.ts b/packages/nextjs-config/src/with-faststats-sourcemaps.ts index bbaedba..ad6401f 100644 --- a/packages/nextjs-config/src/with-faststats-sourcemaps.ts +++ b/packages/nextjs-config/src/with-faststats-sourcemaps.ts @@ -55,6 +55,9 @@ const PLUGIN_OPTION_KEYS: ReadonlySet = new Set([ "fetchImpl", "onUploadSuccess", "onUploadError", + "sourcemapScanSkipDirectoryNames", + "sourcemapScanRoots", + "debug", "useWebpackPlugin", "useRunAfterProductionCompile", ]); @@ -78,10 +81,13 @@ function resolvePluginEnabled( } function isLikelyTurbopackBuild(): boolean { - if (process.env.TURBOPACK === "1") { + const turbo = process.env.TURBOPACK; + if (turbo === "1" || turbo === "auto") { return true; } - return process.argv.some((arg) => arg.includes("turbopack")); + return process.argv.some( + (arg) => arg.includes("turbopack") || arg.includes("--turbo"), + ); } function resolveUseWebpackPlugin( @@ -168,10 +174,17 @@ function applyWithFaststatsSourcemaps( if (!useHook || process.env.NODE_ENV !== "production") { return; } - await uploadSourcemapsFromDirectory( - resolveDistDir(params), - bundlerOptions, - ); + await uploadSourcemapsFromDirectory(resolveDistDir(params), { + ...bundlerOptions, + sourcemapScanSkipDirectoryNames: + bundlerOptions.sourcemapScanSkipDirectoryNames !== undefined + ? bundlerOptions.sourcemapScanSkipDirectoryNames + : ["cache"], + sourcemapScanRoots: + bundlerOptions.sourcemapScanRoots !== undefined + ? bundlerOptions.sourcemapScanRoots + : ["static", "server"], + }); }, }, } as T; From 321939616f36fe403245786df3934eeeddc4a2ee Mon Sep 17 00:00:00 2001 From: Luca Date: Fri, 3 Apr 2026 16:11:08 +0200 Subject: [PATCH 3/3] feat: introduce @faststats/sourcemap-uploader-core package and integrate with existing plugins --- README.md | 2 + bun.lock | 15 + package.json | 2 +- packages/bundler-plugin/README.md | 2 +- packages/bundler-plugin/package.json | 25 +- packages/bundler-plugin/src/index.ts | 653 +++--------------- packages/bundler-plugin/tests/plugin.test.ts | 2 +- packages/javascript-core/package.json | 37 + packages/javascript-core/src/build.ts | 14 + packages/javascript-core/src/constants.ts | 7 + packages/javascript-core/src/debug.ts | 22 + .../src/utils => javascript-core/src}/git.ts | 18 +- packages/javascript-core/src/index.ts | 26 + packages/javascript-core/src/types.ts | 27 + packages/javascript-core/src/upload.ts | 483 +++++++++++++ packages/javascript-core/tsconfig.json | 18 + packages/javascript-core/tsdown.config.ts | 10 + packages/nextjs-config/package.json | 10 +- .../src/with-faststats-sourcemaps.ts | 9 +- 19 files changed, 791 insertions(+), 591 deletions(-) create mode 100644 packages/javascript-core/package.json create mode 100644 packages/javascript-core/src/build.ts create mode 100644 packages/javascript-core/src/constants.ts create mode 100644 packages/javascript-core/src/debug.ts rename packages/{bundler-plugin/src/utils => javascript-core/src}/git.ts (73%) create mode 100644 packages/javascript-core/src/index.ts create mode 100644 packages/javascript-core/src/types.ts create mode 100644 packages/javascript-core/src/upload.ts create mode 100644 packages/javascript-core/tsconfig.json create mode 100644 packages/javascript-core/tsdown.config.ts diff --git a/README.md b/README.md index 492fbc7..26f70bd 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,10 @@ A monorepo for sourcemap upload infrastructure. ## Structure +- **`packages/javascript-core`** — shared JS sourcemap upload core used by the bundler and Next.js adapters - **`apps/backend`** — Rust (Axum) API server that ingests sourcemap uploads - **`packages/bundler-plugin`** — Universal unplugin adapter set (Vite, Rolldown, Webpack and more) that uploads sourcemaps after builds +- **`packages/nextjs-config`** — Next.js config wrapper that composes the shared JS core with the bundler plugin where needed - **`packages/proguard-plugin`** — a Gradle plugin for uploading ProGuard obfuscation mappings ## Development diff --git a/bun.lock b/bun.lock index 8ff949a..cebffc7 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "name": "@faststats/sourcemap-uploader-plugin", "version": "0.4.0", "dependencies": { + "@faststats/sourcemap-uploader-core": "workspace:*", "unplugin": "^3.0.0", }, "devDependencies": { @@ -36,9 +37,21 @@ "typescript": "^5.9.3 || ^6.0.0", }, }, + "packages/javascript-core": { + "name": "@faststats/sourcemap-uploader-core", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^25.0.0", + "tsdown": "^0.21.0", + "typescript": "6.0.2", + }, + }, "packages/nextjs-config": { "name": "@faststats/sourcemap-uploader-nextjs", "version": "0.1.0", + "dependencies": { + "@faststats/sourcemap-uploader-core": "workspace:*", + }, "devDependencies": { "@faststats/sourcemap-uploader-plugin": "workspace:*", "@types/node": "^25.0.0", @@ -184,6 +197,8 @@ "@faststats/proguard-mappings-upload-plugin": ["@faststats/proguard-mappings-upload-plugin@workspace:packages/proguard-plugin"], + "@faststats/sourcemap-uploader-core": ["@faststats/sourcemap-uploader-core@workspace:packages/javascript-core"], + "@faststats/sourcemap-uploader-nextjs": ["@faststats/sourcemap-uploader-nextjs@workspace:packages/nextjs-config"], "@faststats/sourcemap-uploader-plugin": ["@faststats/sourcemap-uploader-plugin@workspace:packages/bundler-plugin"], diff --git a/package.json b/package.json index 08fe990..41c5d61 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "scripts": { "build": "turbo run build", - "build:packages": "turbo run build --filter=@faststats/sourcemap-uploader-plugin --filter=@faststats/sourcemap-uploader-nextjs && bun run build:proguard-plugin", + "build:packages": "turbo run build --filter=@faststats/sourcemap-uploader-core --filter=@faststats/sourcemap-uploader-plugin --filter=@faststats/sourcemap-uploader-nextjs && bun run build:proguard-plugin", "build:proguard-plugin": "bun run --cwd packages/proguard-plugin build", "sync-proguard-version": "bun scripts/sync-proguard-version.ts", "dev": "turbo run dev", diff --git a/packages/bundler-plugin/README.md b/packages/bundler-plugin/README.md index bcb2256..af63053 100644 --- a/packages/bundler-plugin/README.md +++ b/packages/bundler-plugin/README.md @@ -27,7 +27,7 @@ Unplugin-based sourcemap uploader that works across all unplugin adapters: ## Usage ```ts -import sourcemapsPlugin from "@sourcemaps/bundler-plugin/vite"; +import sourcemapsPlugin from "@faststats/sourcemap-uploader-plugin/vite"; export default { plugins: [ diff --git a/packages/bundler-plugin/package.json b/packages/bundler-plugin/package.json index 28d0b35..884d4d5 100644 --- a/packages/bundler-plugin/package.json +++ b/packages/bundler-plugin/package.json @@ -14,57 +14,58 @@ "module": "./dist/index.mjs", "files": [ "dist", + "src", "README.md" ], - "types": "./dist/index.d.mts", + "types": "./src/index.ts", "exports": { ".": { - "types": "./dist/index.d.mts", + "types": "./src/index.ts", "import": "./dist/index.mjs", "default": "./dist/index.mjs" }, "./vite": { - "types": "./dist/bundler/vite.d.mts", + "types": "./src/bundler/vite.ts", "import": "./dist/bundler/vite.mjs", "default": "./dist/bundler/vite.mjs" }, "./rollup": { - "types": "./dist/bundler/rollup.d.mts", + "types": "./src/bundler/rollup.ts", "import": "./dist/bundler/rollup.mjs", "default": "./dist/bundler/rollup.mjs" }, "./rolldown": { - "types": "./dist/bundler/rolldown.d.mts", + "types": "./src/bundler/rolldown.ts", "import": "./dist/bundler/rolldown.mjs", "default": "./dist/bundler/rolldown.mjs" }, "./webpack": { - "types": "./dist/bundler/webpack.d.mts", + "types": "./src/bundler/webpack.ts", "import": "./dist/bundler/webpack.mjs", "default": "./dist/bundler/webpack.mjs" }, "./rspack": { - "types": "./dist/bundler/rspack.d.mts", + "types": "./src/bundler/rspack.ts", "import": "./dist/bundler/rspack.mjs", "default": "./dist/bundler/rspack.mjs" }, "./esbuild": { - "types": "./dist/bundler/esbuild.d.mts", + "types": "./src/bundler/esbuild.ts", "import": "./dist/bundler/esbuild.mjs", "default": "./dist/bundler/esbuild.mjs" }, "./unloader": { - "types": "./dist/bundler/unloader.d.mts", + "types": "./src/bundler/unloader.ts", "import": "./dist/bundler/unloader.mjs", "default": "./dist/bundler/unloader.mjs" }, "./farm": { - "types": "./dist/bundler/farm.d.mts", + "types": "./src/bundler/farm.ts", "import": "./dist/bundler/farm.mjs", "default": "./dist/bundler/farm.mjs" }, "./bun": { - "types": "./dist/bundler/bun.d.mts", + "types": "./src/bundler/bun.ts", "import": "./dist/bundler/bun.mjs", "default": "./dist/bundler/bun.mjs" } @@ -73,6 +74,7 @@ "build": "tsdown", "lint": "biome check .", "check-types": "tsc --noEmit", + "pretest": "bun run --cwd ../javascript-core build", "test": "vitest run" }, "devDependencies": { @@ -90,6 +92,7 @@ "typescript": "^5.9.3 || ^6.0.0" }, "dependencies": { + "@faststats/sourcemap-uploader-core": "workspace:*", "unplugin": "^3.0.0" } } diff --git a/packages/bundler-plugin/src/index.ts b/packages/bundler-plugin/src/index.ts index 032451a..2782335 100644 --- a/packages/bundler-plugin/src/index.ts +++ b/packages/bundler-plugin/src/index.ts @@ -1,6 +1,17 @@ -import type { Stats } from "node:fs"; -import { readdir, readFile, realpath, rm, stat } from "node:fs/promises"; -import { dirname, isAbsolute, join, relative } from "node:path"; +import { readFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { + collectFromOutputDirectory, + collectUploadCandidates, + createBuildMetadataInjection, + debugLog, + handleUploadError, + type JavaScriptSourcemapOptions, + resolveBuildId, + resolveGlobalKey, + type UploadFile, + uploadAndMaybeDelete, +} from "@faststats/sourcemap-uploader-core"; import type { NormalizedOutputOptions, OutputAsset, @@ -8,12 +19,8 @@ import type { OutputOptions, } from "rollup"; import { createUnplugin } from "unplugin"; -import { getGitCommitHashSync } from "./utils/git"; -const DEFAULT_ENDPOINT = "https://sourcemaps.faststats.dev/v0/upload"; -const DEFAULT_MAX_UPLOAD_BODY_BYTES = 50 * 1024 * 1024; - -type BundlerName = +export type BundlerName = | "vite" | "rollup" | "rolldown" @@ -68,99 +75,23 @@ type NativeBuildContextLike = { getNativeBuildContext?: () => unknown; }; -export type UploadFile = { - fileName: string; - content: string; -}; - -export type UploadPayload = { - type: "javascript"; - buildId: string; - uploadedAt: string; - files: UploadFile[]; -}; +export type { + UploadFile, + UploadPayload, +} from "@faststats/sourcemap-uploader-core"; -export type BundlerPluginOptions = { +export type BundlerPluginOptions = JavaScriptSourcemapOptions & { enabled?: boolean | ((framework: BundlerName | undefined) => boolean); - endpoint?: string; - authToken?: string; - buildId?: string; - maxUploadBodyBytes?: number; - failOnError?: boolean; - deleteAfterUpload?: boolean; - globalKey?: string; - fetchImpl?: typeof fetch; - onUploadSuccess?: (payload: UploadPayload) => void | Promise; - onUploadError?: (error: unknown) => void | Promise; - sourcemapScanSkipDirectoryNames?: string[]; - sourcemapScanRoots?: string[]; - debug?: boolean; }; -const MAP_SCAN_ENTRY_CONCURRENCY = 64; -const MAP_READ_CONCURRENCY = 48; -const BATCH_SIZE_ESTIMATE_MARGIN_CAP = 2048; - const pluginName = "sourcemaps-bundler-plugin"; -const DEBUG_ENV_KEY = "FASTSTATS_SOURCEMAPS_DEBUG"; - -const isSourcemapsDebug = (options: BundlerPluginOptions): boolean => - options.debug === true || process.env[DEBUG_ENV_KEY] === "1"; - -const debugLog = ( - options: BundlerPluginOptions, - message: string, - details?: Record, -): void => { - if (!isSourcemapsDebug(options)) { - return; - } - const suffix = - details && Object.keys(details).length > 0 - ? ` ${JSON.stringify(details)}` - : ""; - console.error(`[faststats:sourcemaps] ${message}${suffix}`); -}; - -type ScanProgress = { - dirsEntered: number; - revisitSkipped: number; - filesSeen: number; - skippedByName: number; -}; - const resolveEnabled = ( enabled: BundlerPluginOptions["enabled"], framework: BundlerName | undefined, ): boolean => typeof enabled === "function" ? enabled(framework) : (enabled ?? true); -const createGlobalInjection = (globalKey: string, buildId: string): string => - `globalThis[${JSON.stringify(globalKey)}]={buildId:${JSON.stringify(buildId)}};`; - -const collectUploadCandidates = ( - entries: Array<[string, unknown]>, -): UploadFile[] => - entries - .filter(([fileName]) => fileName.endsWith(".map")) - .flatMap(([fileName, source]) => { - if (typeof source === "string") { - return [{ fileName, content: source }]; - } - - if ( - source && - typeof source === "object" && - "toString" in source && - typeof source.toString === "function" - ) { - return [{ fileName, content: source.toString() }]; - } - - return []; - }); - const createBanner = ( existingBanner: OutputOptions["banner"], injection: string, @@ -179,414 +110,6 @@ const createBanner = ( return injection; }; -const postSourcemaps = async ( - options: BundlerPluginOptions, - payload: UploadPayload, -): Promise => { - const body = JSON.stringify(payload); - debugLog(options, "upload POST start", { - filesInBatch: payload.files.length, - bodyBytes: Buffer.byteLength(body, "utf8"), - endpoint: options.endpoint ?? DEFAULT_ENDPOINT, - }); - const fetchImpl = options.fetchImpl ?? fetch; - const t0 = Date.now(); - const response = await fetchImpl(options.endpoint ?? DEFAULT_ENDPOINT, { - method: "POST", - headers: { - "content-type": "application/json", - ...(options.authToken - ? { authorization: `Bearer ${options.authToken}` } - : {}), - }, - body, - }); - debugLog(options, "upload POST response", { - ms: Date.now() - t0, - status: response.status, - ok: response.ok, - }); - - if (!response.ok) { - throw new Error(`Sourcemap upload failed with status ${response.status}`); - } -}; - -const createUploadBatches = ( - buildId: string, - files: UploadFile[], - maxUploadBodyBytes: number, -): UploadPayload[] => { - if (!Number.isFinite(maxUploadBodyBytes) || maxUploadBodyBytes <= 0) { - throw new Error("maxUploadBodyBytes must be a positive number"); - } - - const batchMargin = Math.min( - BATCH_SIZE_ESTIMATE_MARGIN_CAP, - Math.max(0, Math.floor(maxUploadBodyBytes * 0.05)), - ); - const budget = Math.max(1, maxUploadBodyBytes - batchMargin); - const uploadedAt = new Date().toISOString(); - const filePieceBytes = files.map((f) => - Buffer.byteLength( - JSON.stringify({ fileName: f.fileName, content: f.content }), - "utf8", - ), - ); - - const probe = JSON.stringify({ - type: "javascript" as const, - buildId, - uploadedAt, - files: [], - }); - const filesMarker = '"files":['; - const mi = probe.indexOf(filesMarker); - if (mi === -1) { - throw new Error("createUploadBatches: could not parse empty payload shape"); - } - const head = probe.slice(0, mi + filesMarker.length); - const tail = probe.slice(mi + filesMarker.length); - const headTailBytes = Buffer.byteLength(head + tail, "utf8"); - - const batches: UploadPayload[] = []; - let idx = 0; - while (idx < files.length) { - const batchFiles: UploadFile[] = []; - let batchBytes = headTailBytes; - - while (idx < files.length) { - const file = files[idx] as UploadFile; - const pBytes = filePieceBytes[idx] as number; - const extra = batchFiles.length > 0 ? 1 : 0; - if (batchBytes + extra + pBytes <= budget) { - batchFiles.push(file); - batchBytes += extra + pBytes; - idx++; - continue; - } - if (batchFiles.length > 0) { - break; - } - const solo: UploadPayload = { - type: "javascript", - buildId, - uploadedAt, - files: [file], - }; - const soloBytes = Buffer.byteLength(JSON.stringify(solo), "utf8"); - if (soloBytes > maxUploadBodyBytes) { - throw new Error( - `Sourcemap "${file.fileName}" exceeds maxUploadBodyBytes limit`, - ); - } - batches.push(solo); - idx++; - } - - if (batchFiles.length > 0) { - batches.push({ - type: "javascript", - buildId, - uploadedAt, - files: batchFiles, - }); - } - } - - return batches; -}; - -const handleUploadError = async ( - options: BundlerPluginOptions, - error: unknown, -): Promise => { - await options.onUploadError?.(error); - if (options.failOnError ?? true) { - throw error; - } -}; - -const deleteFiles = async ( - baseDir: string, - fileNames: string[], -): Promise => { - await Promise.all( - fileNames.map((fileName) => - rm(isAbsolute(fileName) ? fileName : join(baseDir, fileName), { - force: true, - }), - ), - ); -}; - -const scanDirectoryForMapPaths = async ( - dirPath: string, - visitedRealDirs: Set, - skipDirectoryNames: ReadonlySet, - options: BundlerPluginOptions, - progress: ScanProgress, -): Promise => { - let canonical: string; - try { - canonical = await realpath(dirPath); - } catch { - return []; - } - if (visitedRealDirs.has(canonical)) { - progress.revisitSkipped++; - if ( - isSourcemapsDebug(options) && - (progress.revisitSkipped <= 8 || progress.revisitSkipped % 500 === 0) - ) { - debugLog(options, "scan skip revisiting path", { - revisitSkipped: progress.revisitSkipped, - canonical, - }); - } - return []; - } - visitedRealDirs.add(canonical); - progress.dirsEntered++; - if ( - isSourcemapsDebug(options) && - (progress.dirsEntered <= 16 || progress.dirsEntered % 250 === 0) - ) { - debugLog(options, "scan entered directory", { - dirsEntered: progress.dirsEntered, - filesSeen: progress.filesSeen, - canonical, - }); - } - - let dirents: import("node:fs").Dirent[]; - try { - dirents = await readdir(dirPath, { withFileTypes: true }); - } catch { - return []; - } - - const mapPaths: string[] = []; - for (let c = 0; c < dirents.length; c += MAP_SCAN_ENTRY_CONCURRENCY) { - const chunk = dirents.slice(c, c + MAP_SCAN_ENTRY_CONCURRENCY); - const nested = await Promise.all( - chunk.map(async (entry) => { - const name = entry.name; - if (skipDirectoryNames.has(name)) { - progress.skippedByName++; - if ( - isSourcemapsDebug(options) && - (progress.skippedByName <= 12 || - progress.skippedByName % 500 === 0) - ) { - debugLog(options, "scan skip directory by name", { - skippedByName: progress.skippedByName, - name, - parent: dirPath, - }); - } - return [] as string[]; - } - const fullPath = join(dirPath, name); - if (entry.isDirectory()) { - return scanDirectoryForMapPaths( - fullPath, - visitedRealDirs, - skipDirectoryNames, - options, - progress, - ); - } - if (entry.isFile() && name.endsWith(".map")) { - progress.filesSeen++; - if ( - isSourcemapsDebug(options) && - (progress.filesSeen <= 20 || progress.filesSeen % 2000 === 0) - ) { - debugLog(options, "scan map file", { - filesSeen: progress.filesSeen, - path: fullPath, - }); - } - return [fullPath]; - } - if (!entry.isFile() && !entry.isDirectory()) { - let st: Stats; - try { - st = await stat(fullPath); - } catch { - return []; - } - if (st.isDirectory()) { - return scanDirectoryForMapPaths( - fullPath, - visitedRealDirs, - skipDirectoryNames, - options, - progress, - ); - } - if (st.isFile() && name.endsWith(".map")) { - progress.filesSeen++; - return [fullPath]; - } - } - return []; - }), - ); - for (const part of nested) { - mapPaths.push(...part); - } - } - return mapPaths; -}; - -const collectFromOutputDirectory = async ( - outputDir: string, - options: BundlerPluginOptions, -): Promise => { - const skipDirectoryNames = new Set( - options.sourcemapScanSkipDirectoryNames ?? [], - ); - const roots = - options.sourcemapScanRoots && options.sourcemapScanRoots.length > 0 - ? options.sourcemapScanRoots.map((r) => join(outputDir, r)) - : [outputDir]; - debugLog(options, "collectFromOutputDirectory start", { - outputDir, - roots, - skipDirectoryNames: [...skipDirectoryNames], - }); - const progress: ScanProgress = { - dirsEntered: 0, - revisitSkipped: 0, - filesSeen: 0, - skippedByName: 0, - }; - const scanT0 = Date.now(); - const visited = new Set(); - const sourcemapFiles: string[] = []; - for (const root of roots) { - const found = await scanDirectoryForMapPaths( - root, - visited, - skipDirectoryNames, - options, - progress, - ); - sourcemapFiles.push(...found); - } - debugLog(options, "collectFromOutputDirectory scan done", { - ms: Date.now() - scanT0, - dirsEntered: progress.dirsEntered, - revisitSkipped: progress.revisitSkipped, - filesSeen: progress.filesSeen, - skippedByName: progress.skippedByName, - mapPaths: sourcemapFiles.length, - }); - const readT0 = Date.now(); - const result: UploadFile[] = []; - for (let i = 0; i < sourcemapFiles.length; i += MAP_READ_CONCURRENCY) { - const slice = sourcemapFiles.slice(i, i + MAP_READ_CONCURRENCY); - const part = await Promise.all( - slice.map(async (filePath) => { - const content = await readFile(filePath, "utf8"); - return { - fileName: relative(outputDir, filePath), - content, - } satisfies UploadFile; - }), - ); - result.push(...part); - } - debugLog(options, "collectFromOutputDirectory read maps done", { - ms: Date.now() - readT0, - mapFilesRead: result.length, - }); - return result; -}; - -const getRecord = (value: unknown): Record | undefined => - value && typeof value === "object" - ? (value as Record) - : undefined; - -const getString = (value: unknown, key: string): string | undefined => { - const record = getRecord(value); - const field = record?.[key]; - return typeof field === "string" ? field : undefined; -}; - -const resolveNativeOutputDir = (nativeContext: unknown): string | undefined => { - const native = getRecord(nativeContext); - const framework = native?.framework; - - if (framework === "bun") { - const buildConfig = getRecord(getRecord(native?.build)?.config); - const outdir = getString(buildConfig, "outdir"); - if (outdir) { - return outdir; - } - - const outfile = getString(buildConfig, "outfile"); - return outfile ? dirname(outfile) : undefined; - } - - if (framework === "farm") { - const farmContext = getRecord(native?.context); - const farmConfig = getRecord(farmContext?.config); - const farmOutput = getRecord(farmConfig?.output); - return getString(farmOutput, "path"); - } - - return undefined; -}; - -const uploadAndMaybeDelete = async ( - options: BundlerPluginOptions, - buildId: string, - files: UploadFile[], - baseDirForDeletion?: string, -): Promise => { - if (files.length === 0) { - return; - } - - debugLog(options, "batching uploads", { - mapFileCount: files.length, - maxUploadBodyBytes: - options.maxUploadBodyBytes ?? DEFAULT_MAX_UPLOAD_BODY_BYTES, - }); - const batchT0 = Date.now(); - const batches = createUploadBatches( - buildId, - files, - options.maxUploadBodyBytes ?? DEFAULT_MAX_UPLOAD_BODY_BYTES, - ); - debugLog(options, "batching done", { - ms: Date.now() - batchT0, - batchCount: batches.length, - }); - let batchIndex = 0; - for (const payload of batches) { - batchIndex++; - debugLog(options, "upload batch", { - index: batchIndex, - of: batches.length, - files: payload.files.length, - }); - await postSourcemaps(options, payload); - await options.onUploadSuccess?.(payload); - } - - if (options.deleteAfterUpload && baseDirForDeletion) { - await deleteFiles( - baseDirForDeletion, - files.map((item) => item.fileName), - ); - } -}; - const isOutputAsset = (entry: OutputBundle[string]): entry is OutputAsset => entry.type === "asset"; @@ -598,18 +121,20 @@ const collectFromBundle = (bundle: OutputBundle): UploadFile[] => ]), ); -const rollupWriteBundle = (options: BundlerPluginOptions, buildId: string) => - async function ( - this: unknown, +const rollupWriteBundle = + (options: BundlerPluginOptions, buildId: string) => + async ( outputOptions: NormalizedOutputOptions, bundle: OutputBundle, - ): Promise { + ): Promise => { try { const sourcemaps = collectFromBundle(bundle); debugLog(options, "rollup writeBundle map outputs", { mapCount: sourcemaps.length, }); - if (sourcemaps.length === 0) return; + if (sourcemaps.length === 0) { + return; + } const outputDir = outputOptions.dir ?? @@ -620,13 +145,12 @@ const rollupWriteBundle = (options: BundlerPluginOptions, buildId: string) => } }; -const rollupOutputOptions = (injection: string) => - function (this: unknown, outputOptions: OutputOptions): OutputOptions { - return { - ...outputOptions, - banner: createBanner(outputOptions.banner, injection), - }; - }; +const rollupOutputOptions = + (injection: string) => + (outputOptions: OutputOptions): OutputOptions => ({ + ...outputOptions, + banner: createBanner(outputOptions.banner, injection), + }); const collectFromWebpackAssets = ( assets: Record unknown }>, @@ -643,9 +167,8 @@ const applyWebpackLikeHooks = ( globalKey: string, buildId: string, ): void => { - const BannerPlugin = compiler.webpack.BannerPlugin; - new BannerPlugin({ - banner: createGlobalInjection(globalKey, buildId), + new compiler.webpack.BannerPlugin({ + banner: createBuildMetadataInjection(globalKey, buildId), raw: true, entryOnly: false, }).apply(compiler); @@ -672,11 +195,11 @@ const applyWebpackLikeHooks = ( debugLog(options, "webpack processAssets missing output.path", {}); return; } - await uploadAndMaybeDelete(options, buildId, sourcemaps, outputPath); + await uploadAndMaybeDelete(options, buildId, sourcemaps, outputPath); if (options.deleteAfterUpload && compilation.deleteAsset) { - for (const item of sourcemaps) { - compilation.deleteAsset(item.fileName); + for (const sourcemap of sourcemaps) { + compilation.deleteAsset(sourcemap.fileName); } } } catch (error) { @@ -687,6 +210,42 @@ const applyWebpackLikeHooks = ( }); }; +const getRecord = (value: unknown): Record | undefined => + value && typeof value === "object" + ? (value as Record) + : undefined; + +const getString = (value: unknown, key: string): string | undefined => { + const record = getRecord(value); + const field = record?.[key]; + return typeof field === "string" ? field : undefined; +}; + +const resolveNativeOutputDir = (nativeContext: unknown): string | undefined => { + const native = getRecord(nativeContext); + const framework = native?.framework; + + if (framework === "bun") { + const buildConfig = getRecord(getRecord(native?.build)?.config); + const outdir = getString(buildConfig, "outdir"); + if (outdir) { + return outdir; + } + + const outfile = getString(buildConfig, "outfile"); + return outfile ? dirname(outfile) : undefined; + } + + if (framework === "farm") { + const farmContext = getRecord(native?.context); + const farmConfig = getRecord(farmContext?.config); + const farmOutput = getRecord(farmConfig?.output); + return getString(farmOutput, "path"); + } + + return undefined; +}; + const unpluginInstance = createUnplugin( (options, meta) => { const framework = meta.framework as BundlerName | undefined; @@ -697,12 +256,9 @@ const unpluginInstance = createUnplugin( }; } - const buildId = - options.buildId ?? - getGitCommitHashSync() ?? - `random_${crypto.randomUUID()}`; - const globalKey = options.globalKey ?? "__SOURCEMAPS_BUILD__"; - const injection = createGlobalInjection(globalKey, buildId); + const buildId = resolveBuildId(options.buildId); + const globalKey = resolveGlobalKey(options.globalKey); + const injection = createBuildMetadataInjection(globalKey, buildId); return { name: pluginName, @@ -757,18 +313,26 @@ const unpluginInstance = createUnplugin( result.metafile?.outputs ?? {}, ).filter((name) => name.endsWith(".map")); const sourcemaps = await Promise.all( - outputEntries.map(async (fileName) => { - const content = await readFile(fileName, "utf8"); - return { fileName, content } satisfies UploadFile; - }), + outputEntries.map( + async (fileName) => + ({ + fileName, + content: await readFile(fileName, "utf8"), + }) satisfies UploadFile, + ), ); - const outdir = + const outputDir = build.initialOptions.outdir ?? (build.initialOptions.outfile ? dirname(build.initialOptions.outfile) : process.cwd()); - await uploadAndMaybeDelete(options, buildId, sourcemaps, outdir); + await uploadAndMaybeDelete( + options, + buildId, + sourcemaps, + outputDir, + ); } catch (error) { await handleUploadError(options, error); } @@ -804,40 +368,7 @@ const unpluginInstance = createUnplugin( }, ); -export async function uploadSourcemapsFromDirectory( - outputDir: string, - options: BundlerPluginOptions, -): Promise { - debugLog(options, "uploadSourcemapsFromDirectory start", { outputDir }); - const buildT0 = Date.now(); - const buildId = - options.buildId ?? - getGitCommitHashSync() ?? - `random_${crypto.randomUUID()}`; - debugLog(options, "uploadSourcemapsFromDirectory buildId", { - buildId, - resolvedMs: Date.now() - buildT0, - }); - try { - const sourcemaps = await collectFromOutputDirectory(outputDir, options); - if (sourcemaps.length === 0) { - debugLog(options, "uploadSourcemapsFromDirectory no map files", { - outputDir, - }); - return; - } - await uploadAndMaybeDelete(options, buildId, sourcemaps, outputDir); - debugLog(options, "uploadSourcemapsFromDirectory complete", { - outputDir, - mapFiles: sourcemaps.length, - }); - } catch (error) { - debugLog(options, "uploadSourcemapsFromDirectory error", { - message: error instanceof Error ? error.message : String(error), - }); - await handleUploadError(options, error); - } -} +export { uploadSourcemapsFromDirectory } from "@faststats/sourcemap-uploader-core"; export const sourcemapsPlugin = unpluginInstance; export const vite = sourcemapsPlugin.vite; diff --git a/packages/bundler-plugin/tests/plugin.test.ts b/packages/bundler-plugin/tests/plugin.test.ts index 681020d..28a7518 100644 --- a/packages/bundler-plugin/tests/plugin.test.ts +++ b/packages/bundler-plugin/tests/plugin.test.ts @@ -3,6 +3,7 @@ import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { getGitCommitHashSync } from "@faststats/sourcemap-uploader-core"; import { rspack } from "@rspack/core"; import { build as esbuildBuild } from "esbuild"; import type { NormalizedOutputOptions, OutputBundle } from "rollup"; @@ -20,7 +21,6 @@ import unloaderPlugin from "../src/bundler/unloader"; import vitePlugin from "../src/bundler/vite"; import webpackPlugin from "../src/bundler/webpack"; import sourcemapsPlugin from "../src/index"; -import { getGitCommitHashSync } from "../src/utils/git"; type UploadPayload = { type: "javascript"; diff --git a/packages/javascript-core/package.json b/packages/javascript-core/package.json new file mode 100644 index 0000000..1ad99ab --- /dev/null +++ b/packages/javascript-core/package.json @@ -0,0 +1,37 @@ +{ + "name": "@faststats/sourcemap-uploader-core", + "version": "0.1.0", + "publishConfig": { + "access": "public", + "provenance": true + }, + "repository": { + "type": "git", + "url": "https://github.com/faststats-dev/sourcemaps" + }, + "type": "module", + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "files": [ + "dist", + "src" + ], + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsdown", + "lint": "biome check .", + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^25.0.0", + "tsdown": "^0.21.0", + "typescript": "6.0.2" + } +} diff --git a/packages/javascript-core/src/build.ts b/packages/javascript-core/src/build.ts new file mode 100644 index 0000000..ceb565f --- /dev/null +++ b/packages/javascript-core/src/build.ts @@ -0,0 +1,14 @@ +import { DEFAULT_GLOBAL_KEY } from "./constants"; +import { getGitCommitHashSync } from "./git"; + +export const resolveBuildId = (buildId?: string): string => + buildId ?? getGitCommitHashSync() ?? `random_${crypto.randomUUID()}`; + +export const resolveGlobalKey = (globalKey?: string): string => + globalKey ?? DEFAULT_GLOBAL_KEY; + +export const createBuildMetadataInjection = ( + globalKey: string, + buildId: string, +): string => + `globalThis[${JSON.stringify(globalKey)}]={buildId:${JSON.stringify(buildId)}};`; diff --git a/packages/javascript-core/src/constants.ts b/packages/javascript-core/src/constants.ts new file mode 100644 index 0000000..14f709c --- /dev/null +++ b/packages/javascript-core/src/constants.ts @@ -0,0 +1,7 @@ +export const DEFAULT_ENDPOINT = "https://sourcemaps.faststats.dev/v0/upload"; +export const DEFAULT_GLOBAL_KEY = "__SOURCEMAPS_BUILD__"; +export const DEFAULT_MAX_UPLOAD_BODY_BYTES = 50 * 1024 * 1024; +export const DEBUG_ENV_KEY = "FASTSTATS_SOURCEMAPS_DEBUG"; +export const MAP_SCAN_ENTRY_CONCURRENCY = 64; +export const MAP_READ_CONCURRENCY = 48; +export const BATCH_SIZE_ESTIMATE_MARGIN_CAP = 2048; diff --git a/packages/javascript-core/src/debug.ts b/packages/javascript-core/src/debug.ts new file mode 100644 index 0000000..e3346c7 --- /dev/null +++ b/packages/javascript-core/src/debug.ts @@ -0,0 +1,22 @@ +import { DEBUG_ENV_KEY } from "./constants"; +import type { JavaScriptSourcemapOptions } from "./types"; + +export const isSourcemapsDebug = ( + options: JavaScriptSourcemapOptions, +): boolean => options.debug === true || process.env[DEBUG_ENV_KEY] === "1"; + +export const debugLog = ( + options: JavaScriptSourcemapOptions, + message: string, + details?: Record, +): void => { + if (!isSourcemapsDebug(options)) { + return; + } + + const suffix = + details && Object.keys(details).length > 0 + ? ` ${JSON.stringify(details)}` + : ""; + console.error(`[faststats:sourcemaps] ${message}${suffix}`); +}; diff --git a/packages/bundler-plugin/src/utils/git.ts b/packages/javascript-core/src/git.ts similarity index 73% rename from packages/bundler-plugin/src/utils/git.ts rename to packages/javascript-core/src/git.ts index aa99fd1..6162ece 100644 --- a/packages/bundler-plugin/src/utils/git.ts +++ b/packages/javascript-core/src/git.ts @@ -1,33 +1,31 @@ import { execSync } from "node:child_process"; const envVariables = [ - // Git Providers "GITHUB_SHA", - "CI_COMMIT_SHA", // GitLab + "CI_COMMIT_SHA", "BITBUCKET_COMMIT", - - // CI Providers "BUILDKITE_COMMIT", "CIRCLE_SHA1", - - // Cloud Providers "VERCEL_GIT_COMMIT_SHA", - "COMMIT_REF", // Netlify + "COMMIT_REF", "WORKERS_CI_COMMIT_SHA", "RAILWAY_GIT_COMMIT_SHA", "AWS_COMMIT_ID", "CF_PAGES_COMMIT_SHA", "RENDER_GIT_COMMIT", "KOYEB_GIT_SHA", - "SVL_DEPLOYMENT_COMMIT_SHA", // Sevalla - "SOURCE_COMMIT", // Coolify + "SVL_DEPLOYMENT_COMMIT_SHA", + "SOURCE_COMMIT", ]; export const getGitCommitHashSync = (): string | undefined => { for (const envVariable of envVariables) { const value = process.env[envVariable]?.trim(); - if (value) return value; + if (value) { + return value; + } } + try { return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); } catch { diff --git a/packages/javascript-core/src/index.ts b/packages/javascript-core/src/index.ts new file mode 100644 index 0000000..dcf516b --- /dev/null +++ b/packages/javascript-core/src/index.ts @@ -0,0 +1,26 @@ +export { + createBuildMetadataInjection, + resolveBuildId, + resolveGlobalKey, +} from "./build"; +export { + DEBUG_ENV_KEY, + DEFAULT_ENDPOINT, + DEFAULT_GLOBAL_KEY, + DEFAULT_MAX_UPLOAD_BODY_BYTES, +} from "./constants"; +export { debugLog, isSourcemapsDebug } from "./debug"; +export { getGitCommitHashSync } from "./git"; +export type { + JavaScriptSourcemapOptions, + UploadFile, + UploadPayload, +} from "./types"; +export { + collectFromOutputDirectory, + collectUploadCandidates, + handleUploadError, + postSourcemaps, + uploadAndMaybeDelete, + uploadSourcemapsFromDirectory, +} from "./upload"; diff --git a/packages/javascript-core/src/types.ts b/packages/javascript-core/src/types.ts new file mode 100644 index 0000000..58c3ed2 --- /dev/null +++ b/packages/javascript-core/src/types.ts @@ -0,0 +1,27 @@ +export type UploadFile = { + fileName: string; + content: string; +}; + +export type UploadPayload = { + type: "javascript"; + buildId: string; + uploadedAt: string; + files: UploadFile[]; +}; + +export type JavaScriptSourcemapOptions = { + endpoint?: string; + authToken?: string; + buildId?: string; + maxUploadBodyBytes?: number; + failOnError?: boolean; + deleteAfterUpload?: boolean; + globalKey?: string; + fetchImpl?: typeof fetch; + onUploadSuccess?: (payload: UploadPayload) => void | Promise; + onUploadError?: (error: unknown) => void | Promise; + sourcemapScanSkipDirectoryNames?: string[]; + sourcemapScanRoots?: string[]; + debug?: boolean; +}; diff --git a/packages/javascript-core/src/upload.ts b/packages/javascript-core/src/upload.ts new file mode 100644 index 0000000..15ccbb1 --- /dev/null +++ b/packages/javascript-core/src/upload.ts @@ -0,0 +1,483 @@ +import type { Stats } from "node:fs"; +import { readdir, readFile, realpath, rm, stat } from "node:fs/promises"; +import { isAbsolute, join, relative } from "node:path"; +import { resolveBuildId } from "./build"; +import { + BATCH_SIZE_ESTIMATE_MARGIN_CAP, + DEFAULT_ENDPOINT, + DEFAULT_MAX_UPLOAD_BODY_BYTES, + MAP_READ_CONCURRENCY, + MAP_SCAN_ENTRY_CONCURRENCY, +} from "./constants"; +import { debugLog, isSourcemapsDebug } from "./debug"; +import type { + JavaScriptSourcemapOptions, + UploadFile, + UploadPayload, +} from "./types"; + +type ScanProgress = { + dirsEntered: number; + revisitSkipped: number; + filesSeen: number; + skippedByName: number; +}; + +export const collectUploadCandidates = ( + entries: Array<[string, unknown]>, +): UploadFile[] => + entries + .filter(([fileName]) => fileName.endsWith(".map")) + .flatMap(([fileName, source]) => { + if (typeof source === "string") { + return [{ fileName, content: source }]; + } + + if ( + source && + typeof source === "object" && + "toString" in source && + typeof source.toString === "function" + ) { + return [{ fileName, content: source.toString() }]; + } + + return []; + }); + +const createUploadBatches = ( + buildId: string, + files: UploadFile[], + maxUploadBodyBytes: number, +): UploadPayload[] => { + if (!Number.isFinite(maxUploadBodyBytes) || maxUploadBodyBytes <= 0) { + throw new Error("maxUploadBodyBytes must be a positive number"); + } + + const batchMargin = Math.min( + BATCH_SIZE_ESTIMATE_MARGIN_CAP, + Math.max(0, Math.floor(maxUploadBodyBytes * 0.05)), + ); + const budget = Math.max(1, maxUploadBodyBytes - batchMargin); + const uploadedAt = new Date().toISOString(); + const filePieceBytes = files.map((file) => + Buffer.byteLength( + JSON.stringify({ fileName: file.fileName, content: file.content }), + "utf8", + ), + ); + + const probe = JSON.stringify({ + type: "javascript" as const, + buildId, + uploadedAt, + files: [], + }); + const filesMarker = '"files":['; + const markerIndex = probe.indexOf(filesMarker); + if (markerIndex === -1) { + throw new Error("createUploadBatches: could not parse empty payload shape"); + } + + const head = probe.slice(0, markerIndex + filesMarker.length); + const tail = probe.slice(markerIndex + filesMarker.length); + const headTailBytes = Buffer.byteLength(head + tail, "utf8"); + + const batches: UploadPayload[] = []; + let fileIndex = 0; + while (fileIndex < files.length) { + const batchFiles: UploadFile[] = []; + let batchBytes = headTailBytes; + + while (fileIndex < files.length) { + const file = files[fileIndex] as UploadFile; + const pieceBytes = filePieceBytes[fileIndex] as number; + const separatorBytes = batchFiles.length > 0 ? 1 : 0; + if (batchBytes + separatorBytes + pieceBytes <= budget) { + batchFiles.push(file); + batchBytes += separatorBytes + pieceBytes; + fileIndex++; + continue; + } + + if (batchFiles.length > 0) { + break; + } + + const soloPayload: UploadPayload = { + type: "javascript", + buildId, + uploadedAt, + files: [file], + }; + const soloBytes = Buffer.byteLength(JSON.stringify(soloPayload), "utf8"); + if (soloBytes > maxUploadBodyBytes) { + throw new Error( + `Sourcemap "${file.fileName}" exceeds maxUploadBodyBytes limit`, + ); + } + batches.push(soloPayload); + fileIndex++; + } + + if (batchFiles.length > 0) { + batches.push({ + type: "javascript", + buildId, + uploadedAt, + files: batchFiles, + }); + } + } + + return batches; +}; + +export const postSourcemaps = async ( + options: JavaScriptSourcemapOptions, + payload: UploadPayload, +): Promise => { + const body = JSON.stringify(payload); + debugLog(options, "upload POST start", { + filesInBatch: payload.files.length, + bodyBytes: Buffer.byteLength(body, "utf8"), + endpoint: options.endpoint ?? DEFAULT_ENDPOINT, + }); + + const fetchImpl = options.fetchImpl ?? fetch; + const startedAt = Date.now(); + const response = await fetchImpl(options.endpoint ?? DEFAULT_ENDPOINT, { + method: "POST", + headers: { + "content-type": "application/json", + ...(options.authToken + ? { authorization: `Bearer ${options.authToken}` } + : {}), + }, + body, + }); + + debugLog(options, "upload POST response", { + ms: Date.now() - startedAt, + status: response.status, + ok: response.ok, + }); + + if (!response.ok) { + throw new Error(`Sourcemap upload failed with status ${response.status}`); + } +}; + +export const handleUploadError = async ( + options: JavaScriptSourcemapOptions, + error: unknown, +): Promise => { + await options.onUploadError?.(error); + if (options.failOnError ?? true) { + throw error; + } +}; + +const deleteFiles = async ( + baseDir: string, + fileNames: string[], +): Promise => { + await Promise.all( + fileNames.map((fileName) => + rm(isAbsolute(fileName) ? fileName : join(baseDir, fileName), { + force: true, + }), + ), + ); +}; + +const scanDirectoryForMapPaths = async ( + dirPath: string, + visitedRealDirs: Set, + skipDirectoryNames: ReadonlySet, + options: JavaScriptSourcemapOptions, + progress: ScanProgress, +): Promise => { + let canonicalPath: string; + try { + canonicalPath = await realpath(dirPath); + } catch { + return []; + } + + if (visitedRealDirs.has(canonicalPath)) { + progress.revisitSkipped++; + if ( + isSourcemapsDebug(options) && + (progress.revisitSkipped <= 8 || progress.revisitSkipped % 500 === 0) + ) { + debugLog(options, "scan skip revisiting path", { + revisitSkipped: progress.revisitSkipped, + canonical: canonicalPath, + }); + } + return []; + } + + visitedRealDirs.add(canonicalPath); + progress.dirsEntered++; + if ( + isSourcemapsDebug(options) && + (progress.dirsEntered <= 16 || progress.dirsEntered % 250 === 0) + ) { + debugLog(options, "scan entered directory", { + dirsEntered: progress.dirsEntered, + filesSeen: progress.filesSeen, + canonical: canonicalPath, + }); + } + + let dirents: import("node:fs").Dirent[]; + try { + dirents = await readdir(dirPath, { withFileTypes: true }); + } catch { + return []; + } + + const mapPaths: string[] = []; + for ( + let index = 0; + index < dirents.length; + index += MAP_SCAN_ENTRY_CONCURRENCY + ) { + const chunk = dirents.slice(index, index + MAP_SCAN_ENTRY_CONCURRENCY); + const nested = await Promise.all( + chunk.map(async (entry) => { + const name = entry.name; + if (skipDirectoryNames.has(name)) { + progress.skippedByName++; + if ( + isSourcemapsDebug(options) && + (progress.skippedByName <= 12 || progress.skippedByName % 500 === 0) + ) { + debugLog(options, "scan skip directory by name", { + skippedByName: progress.skippedByName, + name, + parent: dirPath, + }); + } + return [] as string[]; + } + + const fullPath = join(dirPath, name); + if (entry.isDirectory()) { + return scanDirectoryForMapPaths( + fullPath, + visitedRealDirs, + skipDirectoryNames, + options, + progress, + ); + } + + if (entry.isFile() && name.endsWith(".map")) { + progress.filesSeen++; + if ( + isSourcemapsDebug(options) && + (progress.filesSeen <= 20 || progress.filesSeen % 2000 === 0) + ) { + debugLog(options, "scan map file", { + filesSeen: progress.filesSeen, + path: fullPath, + }); + } + return [fullPath]; + } + + if (!entry.isFile() && !entry.isDirectory()) { + let stats: Stats; + try { + stats = await stat(fullPath); + } catch { + return []; + } + + if (stats.isDirectory()) { + return scanDirectoryForMapPaths( + fullPath, + visitedRealDirs, + skipDirectoryNames, + options, + progress, + ); + } + + if (stats.isFile() && name.endsWith(".map")) { + progress.filesSeen++; + return [fullPath]; + } + } + + return []; + }), + ); + + for (const part of nested) { + mapPaths.push(...part); + } + } + + return mapPaths; +}; + +export const collectFromOutputDirectory = async ( + outputDir: string, + options: JavaScriptSourcemapOptions, +): Promise => { + const skipDirectoryNames = new Set( + options.sourcemapScanSkipDirectoryNames ?? [], + ); + const roots = + options.sourcemapScanRoots && options.sourcemapScanRoots.length > 0 + ? options.sourcemapScanRoots.map((root) => join(outputDir, root)) + : [outputDir]; + + debugLog(options, "collectFromOutputDirectory start", { + outputDir, + roots, + skipDirectoryNames: [...skipDirectoryNames], + }); + + const progress: ScanProgress = { + dirsEntered: 0, + revisitSkipped: 0, + filesSeen: 0, + skippedByName: 0, + }; + const scanStartedAt = Date.now(); + const visited = new Set(); + const sourcemapFiles: string[] = []; + for (const root of roots) { + const found = await scanDirectoryForMapPaths( + root, + visited, + skipDirectoryNames, + options, + progress, + ); + sourcemapFiles.push(...found); + } + + debugLog(options, "collectFromOutputDirectory scan done", { + ms: Date.now() - scanStartedAt, + dirsEntered: progress.dirsEntered, + revisitSkipped: progress.revisitSkipped, + filesSeen: progress.filesSeen, + skippedByName: progress.skippedByName, + mapPaths: sourcemapFiles.length, + }); + + const readStartedAt = Date.now(); + const result: UploadFile[] = []; + for ( + let index = 0; + index < sourcemapFiles.length; + index += MAP_READ_CONCURRENCY + ) { + const chunk = sourcemapFiles.slice(index, index + MAP_READ_CONCURRENCY); + const files = await Promise.all( + chunk.map(async (filePath) => { + const content = await readFile(filePath, "utf8"); + return { + fileName: relative(outputDir, filePath), + content, + } satisfies UploadFile; + }), + ); + result.push(...files); + } + + debugLog(options, "collectFromOutputDirectory read maps done", { + ms: Date.now() - readStartedAt, + mapFilesRead: result.length, + }); + + return result; +}; + +export const uploadAndMaybeDelete = async ( + options: JavaScriptSourcemapOptions, + buildId: string, + files: UploadFile[], + baseDirForDeletion?: string, +): Promise => { + if (files.length === 0) { + return; + } + + debugLog(options, "batching uploads", { + mapFileCount: files.length, + maxUploadBodyBytes: + options.maxUploadBodyBytes ?? DEFAULT_MAX_UPLOAD_BODY_BYTES, + }); + + const batchStartedAt = Date.now(); + const batches = createUploadBatches( + buildId, + files, + options.maxUploadBodyBytes ?? DEFAULT_MAX_UPLOAD_BODY_BYTES, + ); + debugLog(options, "batching done", { + ms: Date.now() - batchStartedAt, + batchCount: batches.length, + }); + + let batchIndex = 0; + for (const payload of batches) { + batchIndex++; + debugLog(options, "upload batch", { + index: batchIndex, + of: batches.length, + files: payload.files.length, + }); + await postSourcemaps(options, payload); + await options.onUploadSuccess?.(payload); + } + + if (options.deleteAfterUpload && baseDirForDeletion) { + await deleteFiles( + baseDirForDeletion, + files.map((file) => file.fileName), + ); + } +}; + +export const uploadSourcemapsFromDirectory = async ( + outputDir: string, + options: JavaScriptSourcemapOptions, +): Promise => { + debugLog(options, "uploadSourcemapsFromDirectory start", { outputDir }); + + const buildIdStartedAt = Date.now(); + const buildId = resolveBuildId(options.buildId); + debugLog(options, "uploadSourcemapsFromDirectory buildId", { + buildId, + resolvedMs: Date.now() - buildIdStartedAt, + }); + + try { + const sourcemaps = await collectFromOutputDirectory(outputDir, options); + if (sourcemaps.length === 0) { + debugLog(options, "uploadSourcemapsFromDirectory no map files", { + outputDir, + }); + return; + } + + await uploadAndMaybeDelete(options, buildId, sourcemaps, outputDir); + debugLog(options, "uploadSourcemapsFromDirectory complete", { + outputDir, + mapFiles: sourcemaps.length, + }); + } catch (error) { + debugLog(options, "uploadSourcemapsFromDirectory error", { + message: error instanceof Error ? error.message : String(error), + }); + await handleUploadError(options, error); + } +}; diff --git a/packages/javascript-core/tsconfig.json b/packages/javascript-core/tsconfig.json new file mode 100644 index 0000000..be9f4c7 --- /dev/null +++ b/packages/javascript-core/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "types": ["node"], + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true + }, + "include": ["**/*.ts"] +} diff --git a/packages/javascript-core/tsdown.config.ts b/packages/javascript-core/tsdown.config.ts new file mode 100644 index 0000000..2e73630 --- /dev/null +++ b/packages/javascript-core/tsdown.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + }, + format: ["esm"], + clean: true, + outDir: "dist", +}); diff --git a/packages/nextjs-config/package.json b/packages/nextjs-config/package.json index 2d4944c..95ef12b 100644 --- a/packages/nextjs-config/package.json +++ b/packages/nextjs-config/package.json @@ -13,12 +13,13 @@ "main": "./dist/index.mjs", "module": "./dist/index.mjs", "files": [ - "dist" + "dist", + "src" ], - "types": "./dist/index.d.mts", + "types": "./src/index.ts", "exports": { ".": { - "types": "./dist/index.d.mts", + "types": "./src/index.ts", "import": "./dist/index.mjs", "default": "./dist/index.mjs" } @@ -32,6 +33,9 @@ "@faststats/sourcemap-uploader-plugin": ">=0.4.0", "next": ">=14.0.0" }, + "dependencies": { + "@faststats/sourcemap-uploader-core": "workspace:*" + }, "devDependencies": { "@faststats/sourcemap-uploader-plugin": "workspace:*", "@types/node": "^25.0.0", diff --git a/packages/nextjs-config/src/with-faststats-sourcemaps.ts b/packages/nextjs-config/src/with-faststats-sourcemaps.ts index ad6401f..6f0d446 100644 --- a/packages/nextjs-config/src/with-faststats-sourcemaps.ts +++ b/packages/nextjs-config/src/with-faststats-sourcemaps.ts @@ -1,9 +1,12 @@ import { isAbsolute, join } from "node:path"; -import type { BundlerPluginOptions } from "@faststats/sourcemap-uploader-plugin"; -import { uploadSourcemapsFromDirectory } from "@faststats/sourcemap-uploader-plugin"; +import type { JavaScriptSourcemapOptions } from "@faststats/sourcemap-uploader-core"; +import { uploadSourcemapsFromDirectory } from "@faststats/sourcemap-uploader-core"; import createSourcemapsWebpackPlugin from "@faststats/sourcemap-uploader-plugin/webpack"; -export type WithFaststatsSourcemapsOptions = BundlerPluginOptions & { +type EnabledOption = boolean | ((framework: string | undefined) => boolean); + +export type WithFaststatsSourcemapsOptions = JavaScriptSourcemapOptions & { + enabled?: EnabledOption; useWebpackPlugin?: boolean | "auto"; useRunAfterProductionCompile?: boolean | "auto"; };