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 | |
| Downloads |
dotnet add package Shiny.AspNet.Npmusing 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}npm publish # in a package named @acme/whatevernpm install @acme/whatevernpm 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.
One registry for everything
Section titled “One registry for everything”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.
What works
Section titled “What works”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) |
Tokens and users
Section titled “Tokens and users”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 refuseo.RequireTokenForReads = true; // a private registry: installs need a token tooMissing 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;};Publish size limits
Section titled “Publish size limits”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)));Storage
Section titled “Storage”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.
Securing it
Section titled “Securing it”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.
Not included
Section titled “Not included”- Caching: nothing from upstream is cached;
Upstreampasses requests through. - Web login: npm’s
--auth-type=webflow 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.
AI Coding Assistant
Section titled “AI Coding Assistant”Step 1 — Add the marketplace:
claude plugin marketplace add shinyorg/skillsStep 2 — Install the plugin:
claude plugin install shiny@shinyOne 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.
Step 1 — Add the marketplace:
copilot plugin marketplace add https://github.com/shinyorg/skillsStep 2 — Install the plugin:
copilot plugin install shiny@shinyOne 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.


