Skip to content
199 changes: 199 additions & 0 deletions website/blog/2026-07-26-verdaccio-7-release.md

Large diffs are not rendered by default.

198 changes: 198 additions & 0 deletions website/dev/plugin-storage-async.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
---
id: plugin-storage-async
title: 'Migrate a storage plugin from callbacks to promises'
description: 'Move a callback and stream based storage plugin to the promise contract that Verdaccio 7 runs natively, method by method.'
---

Verdaccio 7 runs storage plugins on a **promise-based contract**. A plugin written for 6.x
with callbacks still works on 7.x, because Verdaccio detects it and wraps it, but the wrapper
is a compatibility shim and logs this warning on every start:

```
a legacy callback-based storage plugin was detected and wrapped for compatibility;
consider upgrading it to the promise-based storage API
```

This page walks through the migration. The two contracts are described in
[Storage Plugin](plugin-storage.md#api); here is how to get from one to the other.

:::info Only storage plugins change
Authentication, middleware, filter and the other plugin types were never callback based, and
nothing about them changes. Pure ESM plugins are also supported on 7.x for every plugin type,
and that is independent of this migration.
:::

## Before you start {#before-you-start}

- **Keep a callback build for 6.x if you still support it.** 6.x only understands callbacks.
Publish the promise version for 7.x, ideally from a separate major version of your plugin.
- The promise types come from `pluginUtils` in `@verdaccio/core`, replacing the callback
types of `@verdaccio/types`.
- You do not declare anything. Verdaccio tells the contracts apart by arity: a `get` that
takes an argument, or an `add` that takes two, is read as the callback contract. So after
migrating, `get()` **must take no parameters**.

## The plugin object {#plugin}

Every method on the plugin returns a promise and no longer takes a callback.

| Callback (6.x) | Promise (7.x) |
| ------------------------------------------ | ------------------------------------------------------------------- |
| `add(name, callback)` | `add(name): Promise<void>` |
| `remove(name, callback)` | `remove(name): Promise<void>` |
| `get(callback)` | `get(): Promise<string[]>` |
| `search(onPackage, onEnd, validateName)` | `search(query): Promise<SearchItem[]>` |
| `getSecret(): Promise<string>` | unchanged |
| `setSecret(secret): Promise<any>` | unchanged |
| `getPackageStorage(name): IPackageStorage` | `getPackageStorage(name): StorageHandler` |
| _not in the contract_ | `init(): Promise<void>`, called once at startup |
| token methods | `saveToken`, `deleteToken` and `readTokens`, all returning promises |

Callback errors become rejected promises: where you called `callback(err)`, throw or reject.

```diff
- add(name, callback) {
- this.db.insert(name, (err) => callback(err));
- }
+ async add(name) {
+ await this.db.insert(name);
+ }
```

### Search {#search}

The callback `search` streamed every package through `onPackage(item, done)` and finished
with `onEnd`. The promise version **receives the query** and **returns an array**.

```diff
- search(onPackage, onEnd, validateName) {
- for (const name of this.names()) {
- onPackage(this.readManifest(name), () => {});
- }
- onEnd();
- }
+ async search(query) {
+ const text = query.text?.toLowerCase();
+ return this.names()
+ .filter((name) => !text || name.toLowerCase().includes(text))
+ .map((name) => ({
+ package: this.readManifest(name),
+ score: { final: 1, detail: { quality: 1, popularity: 1, maintenance: 1 } },
+ }));
+ }
```

Two details that bite:

- **You filter by `query.text` yourself.** The callback contract had no way to receive it. The
compatibility wrapper filters the collected results by name for you; a native plugin has to.
- **Each item has the shape `{ package, score }`**, with the manifest under `package`. The
callback contract emitted the manifest itself.

## The package handler {#handler}

`getPackageStorage(name)` returns the object that does the I/O for one package. Its methods
follow the same rule.

| Callback (6.x) | Promise (7.x) |
| ---------------------------------------------------------------------- | ------------------------------------------------------ |
| `readPackage(name, callback)` | `readPackage(name): Promise<Manifest>` |
| `createPackage(name, manifest, callback)` | `createPackage(name, manifest): Promise<void>` |
| `savePackage(name, manifest, callback)` | `savePackage(name, manifest): Promise<void>` |
| `deletePackage(fileName, callback)` | `deletePackage(fileName): Promise<void>` |
| `removePackage(callback)` | `removePackage(name): Promise<void>` |
| `updatePackage(name, updateHandler, onWrite, transformPackage, onEnd)` | `updatePackage(name, handleUpdate): Promise<Manifest>` |
| `writeTarball(name): IUploadTarball` | `writeTarball(name, { signal }): Promise<Writable>` |
| `readTarball(name): IReadTarball` | `readTarball(name, { signal }): Promise<Readable>` |
| _not in the contract_ | `hasPackage(name): Promise<boolean>` |
| _not in the contract_ | `hasTarball(name): Promise<boolean>` |

### Existence checks are now explicit {#exists}

The callback contract had no way to ask whether something exists, so Verdaccio probed it:
it called `readPackage` and treated a failure as "not there", and opened `readTarball` and
looked for an `open` or a `404`. The promise contract asks directly. Implement both and
return `false` for a missing item instead of throwing.

```typescript
async hasPackage(name: string): Promise<boolean> {
return this.db.exists(name);
}

async hasTarball(fileName: string): Promise<boolean> {
return this.bucket.exists(fileName);
}
```

### updatePackage {#update-package}

This is the method that changes shape the most. The callback version took four functions
and a final callback; the promise version takes **one function** and returns the manifest.

```diff
- updatePackage(name, updateHandler, onWrite, transformPackage, onEnd) {
- this.lockAndRead(name, (err, manifest) => {
- if (err) return onEnd(err);
- updateHandler(manifest, (err) => {
- if (err) return this.unlock(name, () => onEnd(err));
- onWrite(name, transformPackage(manifest), (err) => this.unlock(name, () => onEnd(err)));
- });
- });
- }
+ async updatePackage(name, handleUpdate) {
+ const manifest = await this.lockAndRead(name);
+ try {
+ return await handleUpdate(manifest);
+ } finally {
+ await this.unlock(name);
+ }
+ }
```

What your plugin owns is the **lock**: read the manifest under a lock, call `handleUpdate`
with it, release the lock whatever happens, and return what the handler returned. Verdaccio
persists the updated manifest itself, so there is no `onWrite` and no transform step to run.

### Tarballs {#tarballs}

The callback contract returned special stream classes, `UploadTarball` and `ReadTarball`,
and finished an upload with `.done()`. The promise contract uses **plain Node.js streams**
and an `AbortSignal`.

| Callback (6.x) | Promise (7.x) |
| ---------------------------------- | ----------------------------------------------------- |
| `UploadTarball`, ended by `done()` | a `Writable`; Verdaccio ends it and waits for `close` |
| `ReadTarball`, `abort()` | a `Readable`; stop when `signal` aborts |
| `emit('success')` after `done()` | `emit('close')` once the bytes are durable |

The rules for events, `open`, `close` and a single `error`, are the same ones the callback
plugins had to follow, and they are spelled out in
[how the store drives your plugin](plugin-storage.md#lifecycle). The new part is the signal:
when the client disconnects, abort the transfer and consume the rejection of any promise your
backend still has in flight.

```typescript
async writeTarball(fileName: string, { signal }: { signal: AbortSignal }): Promise<Writable> {
const stream = this.bucket.createWriteStream(fileName);
signal.addEventListener('abort', () => stream.destroy(new Error('upload aborted')), {
once: true,
});
return stream;
}
```

## Check it {#check}

1. Start Verdaccio 7 with the plugin. The "legacy callback-based storage plugin" warning must
be gone.
2. Publish a package, install it, and run `npm search` against the registry: search is the
method most likely to have been wrong, because it is the one whose shape changed.
3. Unpublish it and make sure the tarball is removed from your backend.
4. Publish two versions of the same package at once. Whatever lock your plugin keeps in
`updatePackage` is what protects the manifest.

## What is not covered {#not-covered}

- The `getSecret` and `setSecret` methods were already promise based and are untouched.
- Plugins for Verdaccio 6.x keep using callbacks. A single package cannot satisfy both
contracts, because Verdaccio chooses by the arity of `get` and `add`.
4 changes: 4 additions & 0 deletions website/dev/plugin-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ going through the adapter, and the adapter is a compatibility shim that will not
forever. A callback plugin written for 6.x keeps working on 7.x, but it is the slower path
and it will be the first thing to break.

Moving a callback plugin to promises is mostly mechanical, method by method: see
[migrate a storage plugin from callbacks to promises](plugin-storage-async.md). Only storage
plugins changed this way; every other plugin type was always promise-based.

<Tabs groupId="storage-contract">
<TabItem value="promise" label="Promises (7.x)" default>

Expand Down
11 changes: 11 additions & 0 deletions website/docs/cli-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,14 @@ Worth doing whichever approach you chose, because it does not depend on anyone's
A publish now goes to that registry regardless of local configuration. For the stronger
version of this — making the package unpublishable anywhere else — see
[protecting packages](protect-your-dependencies.md).

## Package manager compatibility {#package-manager-compatibility}

Verdaccio validates package manager compatibility in a dedicated end-to-end test suite. The list of tested npm, Yarn, pnpm and Bun versions is maintained manually by contributors so each compatibility update is explicit and reviewable.

| Package manager | Setup guide | Tested versions |
| --------------- | --------------------- | ----------------------------------------------------------------------------------------------- |
| pnpm | [pnpm](setup-pnpm.md) | Maintained in [verdaccio/e2e-tests](https://github.com/verdaccio/e2e-tests/blob/main/README.md) |
| npm | [npm](setup-npm.md) | Maintained in [verdaccio/e2e-tests](https://github.com/verdaccio/e2e-tests/blob/main/README.md) |
| Yarn | [yarn](setup-yarn.md) | Maintained in [verdaccio/e2e-tests](https://github.com/verdaccio/e2e-tests/blob/main/README.md) |
| Bun | [bun](setup-bun.md) | Maintained in [verdaccio/e2e-tests](https://github.com/verdaccio/e2e-tests/blob/main/README.md) |
6 changes: 4 additions & 2 deletions website/docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ $> verdaccio
info -=- local storage path /Users/user/.local/share/verdaccio/storage/.verdaccio-db.json
info --- using htpasswd file: /Users/user/.config/verdaccio/htpasswd
info --- http address http://localhost:4873/
info --- version: 6.10.3
info --- version: 7.0.0
info --- server started
```

Expand Down Expand Up @@ -110,5 +110,7 @@ docker run -it --rm --name verdaccio -p 4873:4873 verdaccio/verdaccio
```bash
$ helm repo add verdaccio https://charts.verdaccio.org
$ helm repo update
$ helm install registry --set image.tag=6 verdaccio/verdaccio
$ helm install registry --set image.tag=7 verdaccio/verdaccio
```

For a local Kubernetes setup, see the Verdaccio 7 Helm example in [docker-examples/v7/kubernetes/helm](https://github.com/verdaccio/verdaccio/tree/master/docker-examples/v7/kubernetes/helm).
7 changes: 7 additions & 0 deletions website/docs/kubernetes.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,18 @@ In this example we use `npm` as release name:
helm install npm verdaccio/verdaccio
```

### Run locally with Helm {#run-locally-with-helm}

If you want to test Verdaccio with Helm on a local Kubernetes cluster, use the Verdaccio 7 Helm example in the repository:

[docker-examples/v7/kubernetes/helm](https://github.com/verdaccio/verdaccio/tree/master/docker-examples/v7/kubernetes/helm)

### Deploy a specific version {#deploy-a-specific-version}

`image.tag` selects the Verdaccio image, and pinning it is what you want in production:

```bash
helm install npm --set image.tag=7 verdaccio/verdaccio # latest 7.x
helm install npm --set image.tag=6 verdaccio/verdaccio # latest 6.x
helm install npm --set image.tag=6.10.3 verdaccio/verdaccio # exactly this release
helm install npm --set image.tag=7.x-next verdaccio/verdaccio # nightly of the next major
Expand Down
9 changes: 7 additions & 2 deletions website/docs/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ import NotifyFlow from '@site/src/components/diagrams/NotifyFlow';

Notify was built primarily to use with Slack's Incoming
webhooks, but will also deliver a simple payload to
any endpoint. This is currently only active for the `npm publish`
command.
any endpoint. Notifications are available for package publish events.

:::info

Since Verdaccio 6.8.0, notifications can also be emitted for unpublish events.

:::

<NotifyFlow />

Expand Down
6 changes: 6 additions & 0 deletions website/docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ description: 'The five kinds of plugin Verdaccio accepts, how to install and con

Verdaccio is a pluggable application. It can be extended in many ways, either new authentication methods, adding endpoints or using a custom storage.

:::info

Since Verdaccio 7.x, plugins can be loaded through the async plugin loading path with CommonJS and ESM interop. This allows plugin authors to publish pure ESM plugins, while existing CommonJS plugins remain supported.

:::

There are 5 types of plugins:

- [Authentication](/dev/plugin-auth)
Expand Down
41 changes: 38 additions & 3 deletions website/docs/setup-npm.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,31 @@ or by specific scope eg: `@my-scope/auth`:
npm config set @my-scope:registry http://localhost:4873
```

You can also define both the private registry and the scoped registry directly in your project `.npmrc`:

```ini title=".npmrc"
registry=https://registry.npmjs.org/
@my-company:registry=http://localhost:4873/
```

With this configuration, unscoped packages continue resolving from the public registry, while `@my-company/*` packages resolve from Verdaccio.

### Minimum release age with a private scope {#minimum-release-age}

npm can delay installing newly published package versions with `min-release-age`. This is useful for reducing supply-chain risk for third-party dependencies. If your private scope publishes internal packages that must be available immediately, exclude that scope from the age gate.

```ini title=".npmrc"
registry=https://registry.npmjs.org/
@my-company:registry=http://localhost:4873/

min-release-age=7
min-release-age-exclude[]=@my-company/*
```

`min-release-age` is measured in days. Packages matching `min-release-age-exclude[]` bypass the age gate, but still use the registry configured for their scope.

See also [npm config: `min-release-age`](https://docs.npmjs.com/cli/using-npm/config#min-release-age).

## Using registry only on specific command {#command}

If you want one single use append `--registry http://localhost:4873/` to the required command.
Expand All @@ -63,19 +88,25 @@ If you only want to publish your package to Verdaccio but keep installing from o

## Creating user {#creating-user}

:::warning `npm adduser` is gone in npm 12
`npm adduser` was **removed in npm 12**. Use `npm login` against an existing Verdaccio
user, and create users through your authentication backend — or stay on npm 11 or older if
you depend on creating them from the CLI.
:::

Since `npm@9` the two commands do separate things, which is the behaviour on
every supported version:

- `login` authenticates an existing user and does **not** create one:

```bash
npm login --registry http://localhost:4873
npm login --registry http://localhost:4873 --auth-type=legacy
```

- `adduser` creates a user and does **not** log them in:

```bash
npm adduser --registry http://localhost:4873
npm adduser --registry http://localhost:4873 --auth-type=legacy
```

Both rely on web login by default; adding `--auth-type=legacy` gets the previous
Expand Down Expand Up @@ -201,7 +232,11 @@ vulnerable one, warns that the fix was blocked, and exits non-zero.

### `npm login` with npm@9 or higher

If you are running into issues login with `npm@9.x` or higher you could try use the legacy mode (see above).
If you are running into issues logging in with `npm@9.x` or higher, try legacy mode:

```bash
npm login --registry http://localhost:4873 --auth-type=legacy
```

For progress on the native support on future you can track the following [issue#3413](https://github.com/verdaccio/verdaccio/issues/3413).

Expand Down
6 changes: 6 additions & 0 deletions website/docs/setup-pnpm.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ You do not have to remember which is which: `pnpm config set <key> --location=pr
each key to the right file. Setting `registry` writes `.npmrc`; setting `minimumReleaseAge`
writes `pnpm-workspace.yaml`, creating it if needed.

### Commands pnpm 11 no longer has {#pnpm-11-command-changes}

Since pnpm 11, pnpm no longer falls back to the npm CLI for commands it does not implement. Commands such as `pnpm search`, `pnpm star`, `pnpm stars`, `pnpm unstar`, `pnpm token`, `pnpm whoami`, `pnpm owner` and `pnpm profile` return a "not implemented" error. Use the npm CLI directly for those.

Other registry commands were reimplemented natively in pnpm 11, including `pnpm login`, `pnpm adduser`, `pnpm logout`, `pnpm publish`, `pnpm view`, `pnpm deprecate`, `pnpm unpublish` and `pnpm dist-tag`.

### The lockfile records the registry {#lockfile}

`pnpm-lock.yaml` stores the resolved URL of every package. A lockfile produced against
Expand Down
Loading
Loading