You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Dmitry Gozman (@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
Loading
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
Loading
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:
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
Loading
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.json downloads 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-arm64 resolves the win entry and linux-musl-x64 resolves linux-x64 through the RID graph, matching what Driver.cs does today.
A package resolved via runtime.json does not get its own targets imported, but its runtimes/<rid>/native file does show up in NativeCopyLocalItems with 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.ILCompiler uses to pull runtime.<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.
🚀 Feature Request
Dmitry Gozman (@dgozman) I'd like to propose moving the bundled Node.js out of
Microsoft.Playwrightand 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 smallProposed end state. The root package keeps its id and loses the Node binaries. Five payload-only packages carry Node under the
runtimes/<rid>/nativeconvention. Projects with a RID get the right one automatically viaruntime.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 soDriver.cs,playwright.ps1and 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 metaExample
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:
If it's missing, the build error would name the exact package for the detected platform, and mention
Microsoft.Playwright.Node.AllandPlaywrightPlatform=none(for people bringing their own Node viaPLAYWRIGHT_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 --> p3Phase 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:
Microsoft.Playwright.dll1.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:
DriverDownloaderplatform table, so the roll doesn't gain any manual steps.runtime.jsonpins the exact version, and the targets fail with a clear message if a CPM setup lets them drift.Node.linux-x64or set a RID, same idea asPlaywrightPlatform=linuxtoday.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.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.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.