Skip to content
Shiny.NET

npm Registry

Shiny.AspNet.Npm maps a private npm registry onto the ASP.NET Core app you already have. It speaks the registry API that npm, pnpm and Yarn use, so clients need nothing but a registry URL and a token. It needs no database and no Node.js, and it is trim and Native AOT clean.

GitHub GitHub stars for shinyorg/aspnet
Downloads NuGet downloads for Shiny.AspNet.Npm
Frameworks
ASP.NET
Terminal window
dotnet add package Shiny.AspNet.Npm
using Shiny.AspNet.Npm;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapNpmRegistry("/npm", o =>
{
o.Store = new DiskNpmPackageStore(Path.Combine(dataDir, "npm"));
o.Tokens[builder.Configuration["Npm:Token"]!] = "ci"; // token → user name
});
app.Run();
# .npmrc: only your scope comes from here, everything else from npmjs.org
@acme:registry=http://build-box:5000/npm/
//build-box:5000/npm/:_authToken=${NPM_TOKEN}
Terminal window
npm publish # in a package named @acme/whatever
npm install @acme/whatever

npm sends a scoped name as one segment with its slash encoded (@acme%2fwidgets) for documents, and unencoded for tarballs (@acme/widgets/-/widgets-1.0.0.tgz). Kestrel leaves %2F encoded in the path, and the registry decodes it itself, so both forms work.

Set Upstream and the registry answers for public packages too. Anything not published here is fetched from upstream. A package published here always wins over one of the same name upstream, so a public package cannot shadow a private one.

o.Upstream = new Uri("https://registry.npmjs.org");
registry=http://build-box:5000/npm/
//build-box:5000/npm/:_authToken=${NPM_TOKEN}

npm rewrites registry.npmjs.org tarball links to the configured registry (its replace-registry-host setting), so tarball downloads for public packages come through here as well, and are passed on. npm audit requests are passed on too. Nothing from upstream is cached.

These are the npm 11 CLI commands the registry implements; the protocol tests replay the requests each one sends:

Command
npm install, npm ci Full and abbreviated (application/vnd.npm.install-v1+json) metadata, ETag revalidation, tarballs
npm publish [--tag next] Checks the tarball against the client’s integrity and shasum; republishing a version is refused (403)
npm view, npm search, npm ping, npm whoami
npm dist-tag add/rm/ls latest cannot be removed
npm deprecate Install shows the warning; an empty message removes it
npm unpublish pkg@1.2.3, npm unpublish pkg --force If the version tagged latest is unpublished, latest moves to the highest remaining release
npm owner add/rm/ls Only a maintainer can change a package, as on the public registry
npm login Only when LoginAsync is set (see below)

npm publish and every other change need Authorization: Bearer, which is the _authToken in .npmrc. A token maps to a user name, and the first publisher of a package becomes its maintainer. After that, only maintainers can publish to it or change it, until npm owner add adds someone.

o.Tokens["npm_..."] = "allan";
o.ValidateTokenAsync = async ctx => await tokens.UserForAsync(ctx.Token); // user name, or null to refuse
o.RequireTokenForReads = true; // a private registry: installs need a token too

Missing and invalid tokens get 401, which npm reports as E401. A registry that accepts publishes but has no token configured fails to map. Set ReadOnly = true for a registry nobody publishes to.

npm login (and npm adduser) works only if you supply LoginAsync. It receives the user name and password and returns a token, or null to refuse:

o.LoginAsync = async ctx =>
await users.CheckAsync(ctx.UserName, ctx.Password) ? await tokens.IssueAsync(ctx.UserName) : null;

The token you return must also be accepted by Tokens or ValidateTokenAsync. Without LoginAsync, hand tokens out some other way.

o.OnBeforePublishAsync = ctx =>
{
if (!ctx.PackageName.StartsWith("@acme/", StringComparison.Ordinal))
ctx.Reject(detail: "Only @acme packages.");
return ValueTask.CompletedTask;
};

npm sends the tarball base64-encoded inside JSON, which is a third larger than the tarball. MaxTarballSize defaults to 100 MB, and the registry’s write routes carry a request size limit (IRequestSizeLimitMetadata) of four thirds of it plus 4 MB for the manifest, so Kestrel’s default MaxRequestBodySize of 30 MB does not refuse a publish first. Raise or lower MaxTarballSize and the route limit follows. The body is JSON, so FormOptions plays no part.

To set a different limit on the write routes, add your own metadata after mapping; the last one wins:

app.MapNpmRegistry("/npm", o => { ... })
.ForWrites(w => w.WithMetadata(new RequestSizeLimitAttribute(200 * 1024 * 1024)));

DiskNpmPackageStore keeps each package in its own directory, {root}/{name}/ or {root}/@scope/{name}/. Each holds a package.json document beside the tarballs. Documents are written to a temporary file and moved into place. Implement INpmPackageStore to use blob storage or a database. The registry serializes writes to each package and checks revisions itself, so a store only has to read and write what it is given.

MapNpmRegistry returns an IEndpointConventionBuilder for the registry’s route group, so ASP.NET’s own extensions apply to every route at once. ForReads and ForWrites (or the Reads and Writes properties) apply a policy to one half:

app.MapNpmRegistry("/npm", o => { ... })
.ForWrites(w => w.RequireRateLimiting("publish"))
.RequireCors("tools");

Reads are GET/HEAD. Writes are PUT/POST/DELETE, and npm login (a PUT) is among them. Every tarball link is absolute and built from the request’s scheme, host and path base, including any outer route group prefix. Behind a proxy that rewrites the host, use forwarded headers or set PublicBaseUrl. Errors use npm’s own {"error": "..."} shape, which the CLI prints, rather than problem details. The registry is excluded from OpenAPI descriptions by default.

  • Caching: nothing from upstream is cached; Upstream passes requests through.
  • Web login: npm’s --auth-type=web flow is not supported. npm falls back to the legacy prompt.
  • Two-factor authentication (OTP).
  • Organizations and teams: access is per token, plus the maintainer list.
  • Provenance and signatures, and download counts.

Tested with raw protocol tests (shaped on the npm 11 CLI’s traffic) over HTTP/1.1 and HTTP/2 on Kestrel. pnpm and Yarn use the same API but have not been run against it.

claude plugin marketplace add shinyorg/skills
claude plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

copilot plugin marketplace add https://github.com/shinyorg/skills
copilot plugin install shiny@shiny

One plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.

View shiny-aspnet Skill