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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| pluggy PluginManager.register | 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 | 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 | 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 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.
Related how-tos
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
-
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
-
The plugin guide records what the
name$tagformat cost: aliasing. An instance ofmemcachecalledcache$hotis not allowed and must bememcache$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 -
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