HTTP testing instances for Ethereum
Prool is a library that provides programmatic HTTP testing instances for Ethereum. It is designed to be used in testing environments (e.g. Vitest) where you need to interact with an Ethereum server instance (e.g. Execution Node, 4337 Bundler, Indexer, etc) over HTTP or WebSocket.
Prool contains a set of pre-configured instances that can be used to simulate Ethereum server environments, being:
You can also create your own custom instances by using the Instance.define function.
npm i proolpnpm add proolbun i proolcurl -L https://foundry.paradigm.xyz | bash # Install Foundryimport { Instance, Server } from 'prool'
const server = Server.create({
instance: Instance.anvil(),
})
await server.start()
// Instances accessible at:
// "http://localhost:8545/1"
// "http://localhost:8545/2"
// "http://localhost:8545/3"
// "http://localhost:8545/n"curl -L https://tempo.xyz/install | bash # Install Tempoimport { Instance, Server } from 'prool'
const server = Server.create({
instance: Instance.tempo(),
})
await server.start()
// Instances accessible at:
// "http://localhost:8545/1"
// "http://localhost:8545/2"
// "http://localhost:8545/3"
// "http://localhost:8545/n"@pimlico/alto:npm i @pimlico/alto
import { Instance, Server } from 'prool'
const executionServer = Server.create({
instance: Instance.anvil(),
port: 8545
})
await executionServer.start()
// Instances accessible at:
// "http://localhost:8545/1"
// "http://localhost:8545/2"
// "http://localhost:8545/3"
// "http://localhost:8545/n"
const bundlerServer = Server.create({
instance: (key) => Instance.alto({
entrypoints: ['0x0000000071727De22E5E9d8BAf0edAc6f37da032'],
rpcUrl: `http://localhost:8545/${key}`,
executorPrivateKeys: ['0x...'],
})
})
await bundlerServer.start()
// Instances accessible at:
// "http://localhost:3000/1" (→ http://localhost:8545/1)
// "http://localhost:3000/2" (→ http://localhost:8545/2)
// "http://localhost:3000/3" (→ http://localhost:8545/3)
// "http://localhost:3000/n" (→ http://localhost:8545/n)Creates a server for a keyed instance proxy or an exclusive lease pool.
import { Instance, Server } from 'prool'
const executionServer = Server.create({
instance: Instance.anvil(),
})
await executionServer.start()
// Instances accessible at:
// "http://localhost:8545/1"
// "http://localhost:8545/2"
// "http://localhost:8545/3"
// "http://localhost:8545/n"
// "http://localhost:8545/n/start"
// "http://localhost:8545/n/stop"
// "http://localhost:8545/n/restart"
// "http://localhost:8545/healthcheck"Endpoints:
/:key: Proxy to instance atkey./:key/start: Start instance atkey./:key/stop: Stop instance atkey./:key/restart: Restart instance atkey./healthcheck: Healthcheck endpoint.
A lease pool exposes POST /acquire and POST /release/:token instead:
import { Instance, Pool, Server } from 'prool'
const pool = Pool.create({
instance: Instance.anvil(),
limit: 2,
async reset(instance) {
// Reset application state before reuse.
},
})
const server = Server.create({ pool })
await server.start()| Name | Description | Type |
|---|---|---|
instance |
Instance for the server. | Instance | (key: number) => Instance |
limit |
Number of instances that can be instantiated in the pool | number |
pool |
Exclusive lease pool. | LeasePool |
host |
Host for the server. | string |
port |
Port for the server. | number |
| returns | Server | Server.Server |
Creates an instance definition, that can be used with Server.create or Pool.define.
import { Instance } from 'prool'
const foo = Instance.define((parameters: FooParameters) => {
return {
name: 'foo',
host: 'localhost',
port: 3000,
async start() {
// ...
},
async stop() {
// ...
},
}
})| Name | Description | Type |
|---|---|---|
fn |
Instance definition. | DefineInstanceFn |
| returns | Instance. | Instance |
Creates a bounded pool of exclusively leased instances. Acquisitions wait in order when every instance is busy.
If reset fails, the instance is destroyed and the release rejects before the slot becomes available again.
import { Instance, Pool } from 'prool'
const pool = Pool.create({
instance: Instance.anvil(),
})
const lease = await pool.acquire()
try {
await fetch(lease.instance.url)
} finally {
await lease.release()
}
await pool.close()| Name | Description | Type |
|---|---|---|
instance |
Instance to lease. | Instance |
limit |
Maximum concurrent leases. Defaults to half the available logical CPUs. | number |
reset |
Resets an instance before its next lease. | (instance: Instance) => Promise<void> | void |
| returns | Exclusive lease pool. | LeasePool |
Defines a pool of instances. Instances can be started, cached, and stopped against an identifier.
import { Instance, Pool } from 'prool'
const pool = Pool.define({
instance: Instance.anvil(),
})
const instance_1 = await pool.start(1)
const instance_2 = await pool.start(2)
const instance_3 = await pool.start(3)| Name | Description | Type |
|---|---|---|
instance |
Instance for the pool. | Instance |
limit |
Number of instances that can be instantiated in the pool | number |
| returns | Pool. | Pool |
Server.setup starts one lazy keyed proxy for a Vitest project. Each worker can
use Server.get to address, reset, or restart its own instance. Pool.setup
eagerly starts one direct instance per worker instead.
Both helpers also accept named instances instead of instance. Each name
gets its own isolated instance per worker. The single-instance API is unchanged.
Concurrent tests within one worker still share that worker's instances.
Both helpers clean up all named resources during teardown or failed setup. Eager pools wait for pending starts before cleanup. Teardown attempts every named resource even when another teardown fails, and reports cleanup errors.
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globalSetup: './test/setup.global.ts',
setupFiles: './test/setup.ts',
},
})import { Instance } from 'prool'
import { Server } from 'prool/vitest'
import type { TestProject } from 'vitest/node'
declare module 'vitest' {
export interface ProvidedContext {
anvil: Server.Context
}
}
export default Server.setup({
instance: Instance.anvil(),
setup(server, project: TestProject) {
project.provide('anvil', server)
},
})import { Server } from 'prool/vitest'
import { inject } from 'vitest'
const anvil = Server.get(inject('anvil'))
await anvil.reset({ signal: AbortSignal.timeout(30_000) })
export const rpcUrl = anvil.urlreset destroys only that worker's instance, and its next request starts a
fresh one. restart retains the pooled instance and endpoint.
For multiple instances, pass named instances to start one lazy proxy per name.
Each proxy binds to an automatically assigned port, and each pool allows up to
maxWorkers instances. Definitions can also be factories that receive the
worker's pool ID.
// test/setup.global.ts
import { Instance } from 'prool'
import { Server } from 'prool/vitest'
import type { TestProject } from 'vitest/node'
declare module 'vitest' {
export interface ProvidedContext {
chains: { l1: Server.Context; l2: Server.Context }
}
}
export default Server.setup({
instances: {
l1: Instance.anvil(),
l2: Instance.anvil(),
},
setup(chains, project: TestProject) {
project.provide('chains', chains)
},
})// test/setup.ts
import { Server } from 'prool/vitest'
import { inject } from 'vitest'
export const { l1, l2 } = Server.get(inject('chains'))
await Promise.all([l1.reset(), l2.reset()])l1.url and l2.url address separate instances for the current worker.
Resetting or restarting one does not affect the other names or workers.
import { Instance } from 'prool'
import { Pool } from 'prool/vitest'
import type { TestProject } from 'vitest/node'
declare module 'vitest' {
export interface ProvidedContext {
rpcUrls: readonly string[]
}
}
export default Pool.setup({
instance: Instance.anvil(),
setup(instances, project: TestProject) {
project.provide(
'rpcUrls',
instances.map((instance) => instance.url),
)
},
})import { Pool } from 'prool/vitest'
import { inject } from 'vitest'
export const rpcUrl = Pool.get(inject('rpcUrls'))For multiple instances, pass named instances. The setup callback receives an
array of named records, ordered by worker ID. Provide serializable values such
as URLs; Pool.get selects the current worker's record.
// test/setup.global.ts
import { Instance } from 'prool'
import { Pool } from 'prool/vitest'
import type { TestProject } from 'vitest/node'
declare module 'vitest' {
export interface ProvidedContext {
chainUrls: readonly { l1: string; l2: string }[]
}
}
export default Pool.setup({
instances: {
l1: Instance.anvil(),
l2: Instance.anvil(),
},
setup(instances, project: TestProject) {
project.provide(
'chainUrls',
instances.map(({ l1, l2 }) => ({ l1: l1.url, l2: l2.url })),
)
},
})// test/setup.ts
import { Pool } from 'prool/vitest'
import { inject } from 'vitest'
export const { l1, l2 } = Pool.get(inject('chainUrls'))MIT License