# How to run two instances of one plugin definition side by side

> Declare one plugin definition under two refs, each with its own options, state, and connection, and reconfigure one without touching the other.

Source: https://voxgig.com/howto/run-two-instances-of-one-plugin-definition

- Audience: library-maintainer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-10-20
- Published: 2026-10-20

## Short answer

With `@voxgig/plugin`, define the plugin once and declare it twice by ref, `stripe` and `stripe$test`, each with its own options object. Keep every connection and cache on the instance, created in `activate`, never in module scope, so each instance holds only its own. Reach one instance with `host.exports('stripe$test/charge')`, call both through a shared point, and reconfigure one with `host.options('stripe$test', patch)`.

---
## You will need

Node 22 or later, `@voxgig/plugin`, and a plugin whose resources can move into its activation.
Verified 2026-10-20 against Node 22.22.0 and `@voxgig/plugin` 0.2.0. The calls on this page are the
ones the package's type definitions declare, and the
[plugin guide](https://github.com/voxgig/plugin/blob/main/DOCS.md) explains them. The payment
gateway in the sample is an in-process stand-in that records charges per account, so nothing
leaves the machine.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [pluggy PluginManager.register](https://pluggy.readthedocs.io/en/stable/api_reference.html) | Python hooks, where each copy is its own object built with its own options | It refuses the same module twice, even under a second name, so copies must be class instances | Copies need a lifecycle, released resources, or reconfiguration by name |
| [Rollup and Vite plugin factories](https://rollupjs.org/plugin-development/) | Build plugins, where each factory call returns a fresh plugin object with its options | Isolation rests with the plugin author, with no way to address or reconfigure one copy at run time | Users must reach or reconfigure one copy while the process keeps running |
| [@voxgig/plugin](https://github.com/voxgig/plugin) | A long-running host whose users configure, address, and reconfigure plugin copies at run time | A host, a lifecycle, and a `name$tag` syntax your users learn before a second copy works | A build tool that runs once and restarts to change its configuration |

Voxgig maintains @voxgig/plugin. This page compares it with pluggy and with Rollup and Vite plugin
factories.

A Rollup or [Vite](https://vite.dev/guide/api-plugin) factory gives each call its own closure,
which isolates what the factory creates but not what its module created at import.[^1] pluggy
registers objects, so two copies are two instances of a class, configured by their constructors,
with no host-side options to change later. @voxgig/plugin adds addressable refs and per-instance
reconfiguration, at the price of a host and a vocabulary.

## Keep every resource on the instance

The definition runs once per instance, but its module loads once per process. Anything created at
module scope is shared by every instance, so the client belongs in `activate`, on `inst.state`:

```js title="payments.mjs"
export const stripe = {
  name: 'stripe',

  define(inst) {
    inst.state.charged = []
    // The application reaches one instance by its ref: host.exports('stripe$test/charge').
    inst.export('charge', (cents) => {
      if (!inst.state.client) throw new Error(`${inst.ref} is not live`)
      inst.state.client.charge(cents)
      inst.state.charged.push(cents)
    })
    // A point every live instance answers, each from its own state.
    inst.bind('settle', (report) => {
      const { account, id } = inst.state.client
      report.push({ ref: inst.ref, account, connection: id, charged: [...inst.state.charged] })
    })
  },

  activate(inst) {
    // Options are read here, on every activation, so host.options() takes effect on the next one.
    const client = connect(inst.options)
    inst.state.client = client
    inst.release(() => {
      client.close()
      inst.state.client = null
    })
  },
}
```

`activate` reads `inst.options` each time it runs, and the function passed to `inst.release`
closes the client when the instance deactivates. `define` exports `charge` and binds the host's
`settle` point, and every closure in it reaches state through `inst`, never through the module.

Declare the definition under two refs, make both ready, and charge each once:

```bash
node demo.mjs
```

```text output
declared  {"stripe":"declared","stripe$test":"declared"}
ready     {"stripe":"live","stripe$test":"live"}
settle, one charge each
  stripe       acct_live     connection 1  charged [1250]
  stripe$test  acct_test     connection 2  charged [99]
```

`declare` runs nothing, so both refs sit at `declared` until `ready` loads and activates
them.[^2] The settle point reached both instances, and each reported its own account, connection,
and charges.

## Reconfigure one instance by its ref

`host.options(ref, patch)` merges the patch into that instance's options. A live instance whose
definition has no `reconfigure` callback is deactivated and activated again, which releases its
connection and opens a fresh one:

```js title="demo.mjs"
host.options('stripe$test', { account: 'acct_test_eu', key: 'sk_test_77d0' })
host.exports('stripe$test/charge')(42)
show("settle, after host.options('stripe$test', ...)")
```

```bash
node demo.mjs
```

```text output
settle, after host.options('stripe$test', ...)
  stripe       acct_live     connection 1  charged [1250]
  stripe$test  acct_test_eu  connection 3  charged [99,42]
provider ledger {"acct_live":[1250],"acct_test":[99],"acct_test_eu":[42]}
```

`stripe` kept connection 1. `stripe$test` moved to connection 3 on `acct_test_eu` and kept its
charge history, because deactivation releases resources and leaves `inst.state` alone. A
definition that can swap its client in place implements `reconfigure(inst, options, previous)`,
and the host calls that instead of the restart.

## Check it worked

The suite asserts the isolation directly. The settle test charges each instance once, then checks
what each reported and what the provider recorded:

```js title="instances.test.mjs"
test('the settle point reaches both instances, and each reports only its own charges', () => {
  const host = twoInstances()
  host.exports('stripe/charge')(1250)
  host.exports('stripe$test/charge')(99)
  const report = Object.fromEntries(settle(host).map((r) => [r.ref, r]))
  assert.deepEqual(report.stripe.charged, [1250])
  assert.deepEqual(report['stripe$test'].charged, [99])
  assert.deepEqual(ledgerOf(), { acct_live: [1250], acct_test: [99] })
})
```

```bash
node --test instances.test.mjs
```

```text output
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 104.614713
```

Read `# pass 7`. Three of the seven pin the failures in the next section, so an upgrade that
changes how one of them behaves fails here first.

## When it goes wrong

A test charge lands on the live account. The client was created in module scope, as in
`shared-client.mjs`, so the first instance to activate chose the account for both.[^3] Create the
client in `activate` and keep it on `inst.state`.

Code that asks for `stripe/charge` charges the live account from a test. The bare alias resolves
to the untagged instance whenever that instance exports the key, as the
[plugin guide](https://github.com/voxgig/plugin/blob/main/DOCS.md) documents. Ask for
`stripe$test/charge` by name, or tag both instances so the bare alias fails as ambiguous.

Reconfiguring one instance changes the options of the other. Both were declared with the same
options object, which the host keeps and refills in place, so the second instance takes the first
one's account at its next activation. Pass each `declare` its own object,
`{ options: { ...defaults } }`.

All three, run against a real host:

```bash
node pitfall.mjs
```

```text output
1. a client in module scope, shared by both instances
   stripe$test charged 99, provider ledger: {"acct_live":[99]}
2. the bare alias stripe/charge, with an untagged instance declared
   a test charged 99 through stripe/charge, provider ledger: {"acct_live":[99]}
   the same alias with both instances tagged:
   plugin/plugin_export_ambiguous: alias stripe/charge matches 2 instances: stripe$live, stripe$test [refs=["stripe$live","stripe$test"]]
3. one options object passed to both declare calls
   after options('stripe$a', ...), stripe$b's account is acct_test_eu
```

## When not to do this

Do not keep a live and a test instance in one process when a mistake costs money. @voxgig/plugin
keeps each instance's state apart, but two of the three failures in the preceding section come
from its own defaults. Its bare alias picks the untagged instance, and its host refills a shared
options object in place. Separate processes or deployments make the boundary physical.

Do not adopt @voxgig/plugin to run two copies of a build plugin. A Rollup or Vite factory called
twice already gives each copy its own closure, and a build that restarts to change its
configuration has no use for reconfiguration by ref.

Do not leave the production instance untagged out of habit. The bare alias resolves to it, so the
safer default is a tag on every copy and an alias that refuses to guess.

## Related how-tos

- [Test a plugin through every lifecycle stage](/howto/test-a-plugin-through-every-lifecycle-stage)

- [Discover installed plugins without explicit registration](/howto/discover-installed-plugins-without-explicit-registration)

## Last verified

Verified 2026-10-20 against Node 22.22.0 and `@voxgig/plugin` 0.2.0. Every output block is what the
command preceding it printed. The payment gateway is an in-process stand-in. Rollup, Vite, and
pluggy were read from their documentation, and pluggy's refusal to register one module twice was
also checked against pluggy 1.7.0, outside the sample.

[^1]: The convention has outlived the bundler it was written for. Vite's
[plugin guide](https://vite.dev/guide/api-plugin) calls the factory function common convention for
Vite, Rolldown, and Rollup plugins alike, and notes that Vite 8 and later run on Rolldown. A
factory written to Rollup's interface may therefore spend its working life inside a bundler that
is not Rollup.

[^2]: The [plugin guide](https://github.com/voxgig/plugin/blob/main/DOCS.md) records what the
`name$tag` format cost: aliasing. An instance of `memcache` called `cache$hot` is not allowed and
must be `memcache$hot`, which the guide calls a cosmetic loss against the invariant everything
else rests on. The invariant is that the name is always the definition, so a registry can group
instances by the text before the `$` without being told.

[^3]: Node states the mechanism in its section on
[module caching](https://nodejs.org/api/modules.html#caching): a module is cached after the first
time it is loaded, and loading it again does not run its code again. The same section ends with
advice for code that must run more than once, which is to export a function and call that
function. Rollup's and Vite's plugin factories take that advice, though only for what the factory
itself creates.