How-to › Maintain libraries across languages

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.

Audience
Library maintainer
Level
intermediate
Topic
Design a plugin system
Languages
TypeScript and JavaScript
Verified

You run your payment plugin twice in one process: one copy for the live account and one for a test account. The plugin keeps its API client in module scope, so both copies share the first client it made. A charge you meant for the test account lands on the live one.

What you get

You will end up with one definition running as two instances, each with its own options, state, and connection, and a test that proves the isolation. This is for you if your plugin host must run two copies of one plugin.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
pluggy PluginManager.registerPython hooks, where each copy is its own object built with its own optionsIt refuses the same module twice, even under a second name, so copies must be class instancesCopies need a lifecycle, released resources, or reconfiguration by name
Rollup and Vite plugin factoriesBuild plugins, where each factory call returns a fresh plugin object with its optionsIsolation rests with the plugin author, with no way to address or reconfigure one copy at run timeUsers must reach or reconfigure one copy while the process keeps running
@voxgig/pluginA long-running host whose users configure, address, and reconfigure plugin copies at run timeA host, a lifecycle, and a name$tag syntax your users learn before a second copy worksA 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 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:

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:

node demo.mjs
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:

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', ...)")
node demo.mjs
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:

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] })
})
node --test instances.test.mjs
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 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:

node pitfall.mjs
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.

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.

Footnotes

  1. The convention has outlived the bundler it was written for. Vite’s plugin guide 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. ↩︎ Back to text

  2. The plugin guide 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. ↩︎ Back to text

  3. Node states the mechanism in its section on module 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.