microsoft / microsoft/playwright-dotnet

[Feature]: Split the bundled Node.js into per-RID packages (Microsoft.Playwright.Node.<rid>)

Open
#3,353 0 comments 0 reactions 0 assignees View on GitHub

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.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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.