microsoft / microsoft/playwright-dotnet
[Feature]: Split the bundled Node.js into per-RID packages (Microsoft.Playwright.Node.<rid>)
Nobody has claimed this yet.
- Dominant language
- C#
- Stars
- 3k
- Forks
- 304
- Avg merge
- 20h 47m
- Merged PRs (30d)
- 6
Description
🚀 Feature Request
@dgozman I'd like to propose moving the bundled Node.js out of Microsoft.Playwright and into five small per-platform packages, Microsoft.Playwright.Node.<rid>, with the main package keeping its id and NuGet picking the right one. I've done the NuGet homework below so this is hopefully cheap to evaluate, and I'm happy to do the work, building on the driver assembly you did in #3322.
The purpose is narrow: stop every consumer downloading Node for five platforms when they use one, and get the package off its collision course with the nuget.org 250 MB limit. This is not about the install/browser story, that's a separate conversation.
Today every restore pulls all five Node builds, then the targets copy one to bin:
flowchart TB
proj["Your project<br/>e.g. NUnit tests, no RuntimeIdentifier<br/>PackageReference Microsoft.Playwright"]
subgraph pkg["Microsoft.Playwright 1.62.0.nupkg · 211 MB compressed"]
direction TB
lib["lib/netstandard2.0/Microsoft.Playwright.dll · 0.5 MB"]
drv[".playwright/package (JS driver) · 3.1 MB"]
tgt["build/ + buildTransitive/ targets, playwright.ps1"]
n1[".playwright/node/darwin-arm64/node · 40.1 MB ✓ used on this machine"]
n2[".playwright/node/darwin-x64/node · 41.1 MB"]
n3[".playwright/node/linux-x64/node · 45.4 MB"]
n4[".playwright/node/linux-arm64/node · 45.3 MB"]
n5[".playwright/node/win32_x64/node.exe · 35.7 MB"]
lib ~~~ drv ~~~ tgt ~~~ n1 ~~~ n2 ~~~ n3 ~~~ n4 ~~~ n5
end
bin["bin/Debug/net8.0/<br/>Microsoft.Playwright.dll · playwright.ps1<br/>.playwright/package/…<br/>.playwright/node/darwin-arm64/node ✓ one platform only"]
cache["~/.nuget/packages<br/>other four Node builds: downloaded, never copied<br/>~166 MB wasted per project"]
proj -- "dotnet restore<br/>downloads all 211 MB" --> pkg
pkg -- "dotnet build<br/>targets copy one Node by RuntimeIdentifier,<br/>PlaywrightPlatform or host OS" --> bin
pkg -.-> cache
classDef used fill:#dcefdc,stroke:#2e6b3e,color:#14213d
classDef waste fill:#f8d7d5,stroke:#b3261e,color:#14213d
classDef small fill:#d9ecef,stroke:#1d6a7a,color:#14213d
class n1,bin used
class n2,n3,n4,n5,cache waste
class lib,drv,tgt small
Proposed end state. The root package keeps its id and loses the Node binaries. Five payload-only packages carry Node under the runtimes/<rid>/native convention. Projects with a RID get the right one automatically via runtime.json, projects without a RID add one explicit reference, and the root's targets relink the file into the same .playwright/node/<platform>/ path as today so Driver.cs, playwright.ps1 and people's Dockerfiles don't change:
flowchart TB
projA["Project with RID<br/>RuntimeIdentifier = linux-x64<br/>PackageReference Microsoft.Playwright<br/>nothing to change"]
projB["Project without RID (most test projects)<br/>PackageReference Microsoft.Playwright<br/>+ Microsoft.Playwright.Node.osx-arm64<br/>one explicit reference"]
subgraph root["Microsoft.Playwright · ~4 MB, same package id as today"]
direction TB
rpkg["lib/netstandard2.0/Microsoft.Playwright.dll<br/>.playwright/package (JS driver)<br/>buildTransitive targets"]
rjson["runtime.json<br/>win → Node.win-x64 [1.62.0]<br/>linux-x64 → Node.linux-x64 [1.62.0]<br/>linux-arm64, osx-x64, osx-arm64 …<br/>keyed on OS-level RIDs so win-arm64 and<br/>linux-musl-x64 inherit via the RID graph"]
rtgt["Root build targets (always imported)<br/>1. find the Node file among resolved package assets<br/>2. relink to .playwright/node/{platform}/<br/>3. check Node package version == root version<br/>4. error naming the package to add if none"]
rpkg --- rjson
rpkg --- rtgt
end
subgraph nodes["Microsoft.Playwright.Node.* · payload only, no targets, one file each"]
direction TB
nw["Node.win-x64<br/>runtimes/win-x64/native/node.exe · 36 MB"]
nlx["Node.linux-x64<br/>runtimes/linux-x64/native/node · 45 MB"]
nla["Node.linux-arm64 · 45 MB"]
noa["Node.osx-arm64<br/>runtimes/osx-arm64/native/node · 40 MB"]
nox["Node.osx-x64 · 41 MB"]
nall["Node.All<br/>meta-package, all five (today's behaviour)"]
nw ~~~ nla ~~~ nox
nlx ~~~ noa ~~~ nall
end
bin["bin/Debug/net8.0/<br/>Microsoft.Playwright.dll · playwright.ps1<br/>.playwright/package/…<br/>.playwright/node/{platform}/node ✓ exactly one<br/>same layout as today, Driver.cs unchanged<br/>~45 MB downloaded in total, was 211 MB"]
projA --> rpkg
projB --> rpkg
rjson == "Route A: restore resolves<br/>runtime.json for the project's RID" ==> nlx
projB -. "Route B: explicit<br/>PackageReference" .-> noa
nlx -. "relinked by root targets" .-> bin
noa -. "relinked by root targets" .-> bin
rtgt -- "dotnet build" --> bin
classDef routeA fill:#fbeecb,stroke:#b8860b,color:#14213d
classDef routeB fill:#d9ecef,stroke:#1d6a7a,color:#14213d
classDef good fill:#dcefdc,stroke:#2e6b3e,color:#14213d
classDef meta fill:#f4f1ea,stroke:#888,stroke-dasharray:4 3,color:#14213d
class rjson,nlx,projA routeA
class noa,projB routeB
class bin good
class nall meta
Example
For a project that sets a RuntimeIdentifier (self-contained, AOT, dotnet publish -r), nothing changes, restore just gets smaller.
For a test project with no RID, which I think is most people, it's one line:
<PackageReference Include="Microsoft.Playwright.NUnit" Version="1.6x.0" />
<PackageReference Include="Microsoft.Playwright.Node.linux-x64" Version="1.6x.0" />
If it's missing, the build error would name the exact package for the detected platform, and mention Microsoft.Playwright.Node.All and PlaywrightPlatform=none (for people bringing their own Node via PLAYWRIGHT_NODEJS_PATH) as the alternatives.
I'd do it in three phases so nothing breaks by surprise:
flowchart LR
p1["Phase 1 · additive<br/>publish the five Node packages<br/>root still bundles Node<br/>targets prefer a package-supplied Node<br/>nobody's build changes"]
p2["Phase 2 · warn<br/>RID-less projects with no Node package<br/>get a build warning naming the package<br/>release notes announce the cut"]
p3["Phase 3 · cut<br/>drop Node from the root, add runtime.json<br/>warning becomes an error with the fix in it<br/>root goes from 211 MB to ~4 MB"]
p1 --> p2 --> p3
Phase 1 is really just packing: .drivers/node/<platform>/ already has exactly the per-platform layout after #3322, so each Node package is one glob over that folder, and the publish job pushes five more nupkgs before the root. That publish step is the only bit I can't do from outside.
Motivation
The numbers from the 1.62.0 nupkg on nuget.org:
| Compressed | |
|---|---|
| Node ×5 | 207 MB |
| JS driver | 3.1 MB |
Microsoft.Playwright.dll |
0.5 MB |
| Whole package | 211 MB |
1.54 was 195 MB and 1.62 is 211 MB after the move to Node 24, and nuget.org rejects packages over 250 MB, so at some point a Node bump is going to push this over and something will have to change anyway. I'd much rather there was a plan in place before that happens 🙂
It's also the second most upvoted open issue (#1850) and a big part of the most upvoted one (#2286), but I know those threads have got a bit noisy, hence starting fresh here.
Some things I'd expect to come up, and where I've landed on them:
| Question | Where I've landed |
|---|---|
| Isn't this a lot of extra packages to look after? | Five, all cut from the same DriverDownloader platform table, so the roll doesn't gain any manual steps. |
| What if the root and Node packages get out of step? | runtime.json pins the exact version, and the targets fail with a clear message if a CPM setup lets them drift. |
| Previous attempts at install-on-build and the dotnet tool caused CI and container pain | Nothing changes by default here, the ps1 stays, and the new path is opt-in until phase 3. |
| Does this make Node part of the public surface? | No more than today. Node stays an implementation detail unless someone chooses to trim it. |
| Is the CI saving actually worth it? | For a single project it's a few seconds, but it adds up across a big solution, and separately the package can't keep growing past the nuget.org limit. |
| Cross-platform builds, e.g. Windows host building a Linux container | Reference Node.linux-x64 or set a RID, same idea as PlaywrightPlatform=linux today. |
What I've verified with local packages on the .NET 10 SDK (10.0.400):
runtime.jsondownloads only the matching Node package, and nothing at all for a RID-less restore, which is why phase 3 needs the explicit reference rather than being magic.win-arm64resolves thewinentry andlinux-musl-x64resolveslinux-x64through the RID graph, matching whatDriver.csdoes today.- A package resolved via
runtime.jsondoes not get its own targets imported, but itsruntimes/<rid>/nativefile does show up inNativeCopyLocalItemswith the package id attached, so the relinking has to live in the root's targets and the Node packages can be dumb payloads. - This is the same mechanism
Microsoft.DotNet.ILCompileruses to pullruntime.<rid>.Microsoft.DotNet.ILCompiler, and it's the .NET equivalent of the per-platform wheels Playwright already ships for Python.
Is this a direction you'd take a PR for? If phase 1 is welcome I'll put it up against #3322's layout, and I'm happy to help iron out anything that falls out of it.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with .drivers/node//, the existing build/buildTransitive targets, and the Driver.cs layout from #3322; review how the package publish job creates NuGet artifacts. Validate the phase-one outcome with local packages: five payload-only Node packages are published and the root targets can prefer a package-supplied Node without changing the existing .playwright/node// path.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- build-system, release
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100