prool 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/_lib/exports/index.d.ts +4 -0
  4. package/_lib/exports/index.d.ts.map +1 -0
  5. package/_lib/exports/index.js +4 -0
  6. package/_lib/exports/index.js.map +1 -0
  7. package/_lib/exports/instances.d.ts +2 -0
  8. package/_lib/exports/instances.d.ts.map +1 -0
  9. package/_lib/exports/instances.js +2 -0
  10. package/_lib/exports/instances.js.map +1 -0
  11. package/_lib/instance.d.ts +111 -0
  12. package/_lib/instance.d.ts.map +1 -0
  13. package/_lib/instance.js +141 -0
  14. package/_lib/instance.js.map +1 -0
  15. package/_lib/instances/anvil.d.ts +459 -0
  16. package/_lib/instances/anvil.d.ts.map +1 -0
  17. package/_lib/instances/anvil.js +74 -0
  18. package/_lib/instances/anvil.js.map +1 -0
  19. package/_lib/pool.d.ts +38 -0
  20. package/_lib/pool.d.ts.map +1 -0
  21. package/_lib/pool.js +134 -0
  22. package/_lib/pool.js.map +1 -0
  23. package/_lib/proxy.d.ts +21 -0
  24. package/_lib/proxy.d.ts.map +1 -0
  25. package/_lib/proxy.js +93 -0
  26. package/_lib/proxy.js.map +1 -0
  27. package/_lib/utils.d.ts +35 -0
  28. package/_lib/utils.d.ts.map +1 -0
  29. package/_lib/utils.js +71 -0
  30. package/_lib/utils.js.map +1 -0
  31. package/exports/index.test.ts +12 -0
  32. package/exports/index.ts +20 -0
  33. package/exports/instances.test.ts +10 -0
  34. package/exports/instances.ts +1 -0
  35. package/instance.test.ts +311 -0
  36. package/instance.ts +277 -0
  37. package/instances/anvil.test.ts +130 -0
  38. package/instances/anvil.ts +322 -0
  39. package/package.json +39 -8
  40. package/pool.test.ts +209 -0
  41. package/pool.ts +183 -0
  42. package/proxy.test.ts +294 -0
  43. package/proxy.ts +135 -0
  44. package/tsconfig.build.tsbuildinfo +1 -0
  45. package/utils.test.ts +44 -0
  46. package/utils.ts +92 -0
package/pool.ts ADDED
@@ -0,0 +1,183 @@
1
+ import getPort from 'get-port'
2
+
3
+ import type { Instance } from './instance.js'
4
+
5
+ type Instance_ = Omit<Instance, 'create'>
6
+
7
+ export type Pool<key = number> = Pick<
8
+ Map<key, Instance_>,
9
+ 'entries' | 'keys' | 'forEach' | 'get' | 'has' | 'size' | 'values'
10
+ > & {
11
+ _internal: {
12
+ instance: Instance_
13
+ }
14
+ destroy(key: key): Promise<void>
15
+ destroyAll(): Promise<void>
16
+ start(key: key, options?: { port?: number }): Promise<Instance_>
17
+ stop(key: key): Promise<void>
18
+ stopAll(): Promise<void>
19
+ }
20
+
21
+ export type DefinePoolParameters = {
22
+ /** Instance for the pool. */
23
+ instance: Instance
24
+ /** The maximum number of instances that can be started. */
25
+ limit?: number | number
26
+ }
27
+
28
+ export type DefinePoolReturnType<key = number> = Pool<key>
29
+
30
+ /**
31
+ * Defines an instance pool. Instances can be started, cached, and stopped against an identifier.
32
+ *
33
+ * @example
34
+ * ```
35
+ * const pool = definePool({
36
+ * instance: anvil(),
37
+ * })
38
+ *
39
+ * const instance_1 = await pool.start(1)
40
+ * const instance_2 = await pool.start(2)
41
+ * const instance_3 = await pool.start(3)
42
+ * ```
43
+ */
44
+ export function definePool<key = number>(
45
+ parameters: DefinePoolParameters,
46
+ ): DefinePoolReturnType<key> {
47
+ const { instance, limit } = parameters
48
+
49
+ type Instance_ = Omit<Instance, 'create'>
50
+ const instances = new Map<key, Instance_>()
51
+
52
+ // Define promise instances for mutators to avoid race conditions, and return
53
+ // identical instances of the promises (instead of duplicating them).
54
+ // We utilize `Promise.withResolvers` instead of `new Promise((resolve, reject) => ...)`
55
+ // to avoid async Promise executor functions (https://biomejs.dev/linter/rules/no-async-promise-executor/).
56
+ const promises = {
57
+ destroy: new Map<key, Promise<void>>(),
58
+ destroyAll: undefined as Promise<void> | undefined,
59
+ start: new Map<key, Promise<Instance_>>(),
60
+ stop: new Map<key, Promise<void>>(),
61
+ stopAll: undefined as Promise<void> | undefined,
62
+ }
63
+
64
+ return {
65
+ _internal: {
66
+ instance,
67
+ },
68
+ async destroy(key) {
69
+ const destroyPromise = promises.destroy.get(key)
70
+ if (destroyPromise) return destroyPromise
71
+
72
+ const resolver = Promise.withResolvers<void>()
73
+
74
+ try {
75
+ promises.destroy.set(key, resolver.promise)
76
+
77
+ await this.stop(key)
78
+ instances.delete(key)
79
+
80
+ resolver.resolve()
81
+ } catch (error) {
82
+ resolver.reject(error)
83
+ }
84
+
85
+ return resolver.promise
86
+ },
87
+ async destroyAll() {
88
+ if (promises.destroyAll) return promises.destroyAll
89
+
90
+ const resolver = Promise.withResolvers<void>()
91
+
92
+ try {
93
+ promises.destroyAll = resolver.promise
94
+
95
+ await Promise.all([...instances.keys()].map((key) => this.destroy(key)))
96
+
97
+ promises.destroyAll = undefined
98
+
99
+ resolver.resolve()
100
+ } catch (error) {
101
+ resolver.reject(error)
102
+ }
103
+ },
104
+ async start(key, options = {}) {
105
+ const startPromise = promises.start.get(key)
106
+ if (startPromise) return startPromise
107
+
108
+ const resolver = Promise.withResolvers<Instance_>()
109
+
110
+ try {
111
+ promises.start.set(key, resolver.promise)
112
+
113
+ if (limit && instances.size >= limit)
114
+ throw new Error(`Instance limit of ${limit} reached.`)
115
+
116
+ const { port = await getPort() } = options
117
+
118
+ const instance_ = instances.get(key) || instance.create({ port })
119
+ await instance_.start()
120
+
121
+ instances.set(key, instance_)
122
+ resolver.resolve(instance_)
123
+ } catch (error) {
124
+ resolver.reject(error)
125
+ } finally {
126
+ promises.start.delete(key)
127
+ }
128
+
129
+ return resolver.promise
130
+ },
131
+ async stop(key) {
132
+ const stopPromise = promises.stop.get(key)
133
+ if (stopPromise) return stopPromise
134
+
135
+ const resolver = Promise.withResolvers<void>()
136
+
137
+ try {
138
+ promises.stop.set(key, resolver.promise)
139
+
140
+ const instance_ = instances.get(key)
141
+ if (!instance_) {
142
+ resolver.resolve()
143
+ return
144
+ }
145
+
146
+ await instance_.stop()
147
+
148
+ resolver.resolve()
149
+ } catch (error) {
150
+ resolver.reject(error)
151
+ } finally {
152
+ promises.stop.delete(key)
153
+ }
154
+ },
155
+ async stopAll() {
156
+ if (promises.stopAll) return promises.stopAll
157
+
158
+ const resolver = Promise.withResolvers<void>()
159
+
160
+ try {
161
+ promises.stopAll = resolver.promise
162
+
163
+ await Promise.all([...instances.keys()].map((key) => this.stop(key)))
164
+
165
+ promises.stopAll = undefined
166
+
167
+ resolver.resolve()
168
+ } catch (error) {
169
+ resolver.reject(error)
170
+ }
171
+ },
172
+
173
+ get size() {
174
+ return instances.size
175
+ },
176
+ entries: instances.entries.bind(instances),
177
+ keys: instances.keys.bind(instances),
178
+ forEach: instances.forEach.bind(instances),
179
+ get: instances.get.bind(instances).bind(instances),
180
+ has: instances.has.bind(instances),
181
+ values: instances.values.bind(instances),
182
+ }
183
+ }
package/proxy.test.ts ADDED
@@ -0,0 +1,294 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { type MessageEvent, WebSocket } from 'ws'
3
+ import { anvil } from './instances/anvil.js'
4
+ import { defineProxyPool } from './proxy.js'
5
+
6
+ describe.each([{ instance: anvil() }])(
7
+ 'instance: $instance.name',
8
+ ({ instance }) => {
9
+ test('default', async () => {
10
+ const pool = defineProxyPool({
11
+ instance,
12
+ })
13
+ expect(pool).toBeDefined()
14
+
15
+ await pool.start()
16
+ expect(pool.address()).toBeDefined()
17
+
18
+ // Stop via instance method.
19
+ await pool.stop()
20
+ expect(pool.address()).toBeNull()
21
+
22
+ const stop = await pool.start()
23
+ expect(pool.address()).toBeDefined()
24
+
25
+ // Stop via return value.
26
+ await stop()
27
+ expect(pool.address()).toBeNull()
28
+ })
29
+
30
+ test('args: port', async () => {
31
+ const pool = defineProxyPool({
32
+ instance,
33
+ port: 3000,
34
+ })
35
+ expect(pool).toBeDefined()
36
+
37
+ const stop = await pool.start()
38
+ expect(pool.address()?.port).toBe(3000)
39
+ await stop()
40
+ })
41
+
42
+ test('args: host', async () => {
43
+ const pool = defineProxyPool({
44
+ instance,
45
+ host: 'localhost',
46
+ port: 3000,
47
+ })
48
+ expect(pool).toBeDefined()
49
+
50
+ const stop = await pool.start()
51
+ expect(pool.address()?.address).toBe('::1')
52
+ expect(pool.address()?.port).toBe(3000)
53
+ await stop()
54
+ })
55
+
56
+ test('request: /healthcheck', async () => {
57
+ const server = defineProxyPool({
58
+ instance,
59
+ })
60
+
61
+ const stop = await server.start()
62
+ const { port } = server.address()!
63
+ const response = await fetch(`http://localhost:${port}/healthcheck`)
64
+ expect(response.status).toBe(200)
65
+
66
+ await stop()
67
+ })
68
+
69
+ test('request: /start + /stop', async () => {
70
+ const server = defineProxyPool({
71
+ instance,
72
+ })
73
+
74
+ const stop = await server.start()
75
+ const { port } = server.address()!
76
+ const response = await fetch(`http://localhost:${port}/1/start`)
77
+ expect(response.status).toBe(200)
78
+
79
+ const json = (await response.json()) as any
80
+ expect(json.host).toBeDefined()
81
+ expect(json.port).toBeDefined()
82
+
83
+ const response_err = await fetch(`http://localhost:${port}/1/start`)
84
+ expect(response_err.status).toBe(400)
85
+ expect(await response_err.json()).toEqual({
86
+ message: `Instance "${instance.name}" is not in an idle or stopped state. Status: started`,
87
+ })
88
+
89
+ const response_stop = await fetch(`http://localhost:${port}/1/stop`)
90
+ expect(response_stop.status).toBe(200)
91
+
92
+ const response_2 = await fetch(`http://localhost:${port}/1/start`)
93
+ expect(response_2.status).toBe(200)
94
+
95
+ await stop()
96
+ })
97
+
98
+ test('ws', async () => {
99
+ const server = defineProxyPool({
100
+ instance,
101
+ })
102
+
103
+ const stop = await server.start()
104
+ const { port } = server.address()!
105
+ const ws = new WebSocket(`ws://localhost:${port}/1`)
106
+ await new Promise((resolve) => ws.addEventListener('open', resolve))
107
+ ws.send('test')
108
+ await new Promise((resolve) => ws.addEventListener('message', resolve))
109
+
110
+ await stop()
111
+ })
112
+ },
113
+ )
114
+
115
+ describe("instance: 'anvil'", () => {
116
+ test('request: /{id}', async () => {
117
+ const server = defineProxyPool({
118
+ instance: anvil(),
119
+ })
120
+
121
+ const stop = await server.start()
122
+ const { port } = server.address()!
123
+ const response = await fetch(`http://localhost:${port}/1`)
124
+ // Standard Anvil HTTP response for invalid request.
125
+ expect(response.status).toBe(400)
126
+ expect(await response.text()).toBe(
127
+ "Connection header did not include 'upgrade'",
128
+ )
129
+
130
+ // Check block numbers
131
+ expect(
132
+ await fetch(`http://localhost:${port}/1`, {
133
+ body: JSON.stringify({
134
+ method: 'eth_blockNumber',
135
+ id: 0,
136
+ jsonrpc: '2.0',
137
+ }),
138
+ headers: {
139
+ 'Content-Type': 'application/json',
140
+ },
141
+ method: 'POST',
142
+ }).then((x) => x.json()),
143
+ ).toMatchInlineSnapshot(`
144
+ {
145
+ "id": 0,
146
+ "jsonrpc": "2.0",
147
+ "result": "0x0",
148
+ }
149
+ `)
150
+ expect(
151
+ await fetch(`http://localhost:${port}/2`, {
152
+ body: JSON.stringify({
153
+ method: 'eth_blockNumber',
154
+ id: 0,
155
+ jsonrpc: '2.0',
156
+ }),
157
+ headers: {
158
+ 'Content-Type': 'application/json',
159
+ },
160
+ method: 'POST',
161
+ }).then((x) => x.json()),
162
+ ).toMatchInlineSnapshot(`
163
+ {
164
+ "id": 0,
165
+ "jsonrpc": "2.0",
166
+ "result": "0x0",
167
+ }
168
+ `)
169
+
170
+ // Mine block number
171
+ await fetch(`http://localhost:${port}/1`, {
172
+ body: JSON.stringify({
173
+ method: 'anvil_mine',
174
+ params: ['0x69', '0x0'],
175
+ id: 0,
176
+ jsonrpc: '2.0',
177
+ }),
178
+ headers: {
179
+ 'Content-Type': 'application/json',
180
+ },
181
+ method: 'POST',
182
+ })
183
+
184
+ // Check block numbers
185
+ expect(
186
+ await fetch(`http://localhost:${port}/1`, {
187
+ body: JSON.stringify({
188
+ method: 'eth_blockNumber',
189
+ id: 0,
190
+ jsonrpc: '2.0',
191
+ }),
192
+ headers: {
193
+ 'Content-Type': 'application/json',
194
+ },
195
+ method: 'POST',
196
+ }).then((x) => x.json()),
197
+ ).toMatchInlineSnapshot(`
198
+ {
199
+ "id": 0,
200
+ "jsonrpc": "2.0",
201
+ "result": "0x69",
202
+ }
203
+ `)
204
+ expect(
205
+ await fetch(`http://localhost:${port}/2`, {
206
+ body: JSON.stringify({
207
+ method: 'eth_blockNumber',
208
+ id: 0,
209
+ jsonrpc: '2.0',
210
+ }),
211
+ headers: {
212
+ 'Content-Type': 'application/json',
213
+ },
214
+ method: 'POST',
215
+ }).then((x) => x.json()),
216
+ ).toMatchInlineSnapshot(`
217
+ {
218
+ "id": 0,
219
+ "jsonrpc": "2.0",
220
+ "result": "0x0",
221
+ }
222
+ `)
223
+
224
+ await stop()
225
+ })
226
+
227
+ test('request: /messages', async () => {
228
+ const server = defineProxyPool({
229
+ instance: anvil(),
230
+ })
231
+
232
+ const stop = await server.start()
233
+ const { port } = server.address()!
234
+
235
+ await fetch(`http://localhost:${port}/1`)
236
+
237
+ expect(
238
+ (
239
+ (await fetch(`http://localhost:${port}/1/messages`, {
240
+ body: JSON.stringify({
241
+ method: 'eth_blockNumber',
242
+ id: 0,
243
+ jsonrpc: '2.0',
244
+ }),
245
+ headers: {
246
+ 'Content-Type': 'application/json',
247
+ },
248
+ method: 'POST',
249
+ }).then((x) => x.json())) as any
250
+ ).length > 0,
251
+ ).toBeTruthy()
252
+
253
+ await stop()
254
+ })
255
+
256
+ test('ws', async () => {
257
+ const server = defineProxyPool({
258
+ instance: anvil(),
259
+ })
260
+
261
+ const stop = await server.start()
262
+ const { port } = server.address()!
263
+ const ws = new WebSocket(`ws://localhost:${port}/1`)
264
+ await new Promise((resolve) => ws.addEventListener('open', resolve))
265
+ ws.send(
266
+ JSON.stringify({
267
+ method: 'eth_blockNumber',
268
+ id: 0,
269
+ jsonrpc: '2.0',
270
+ }),
271
+ )
272
+ const { data } = await new Promise<MessageEvent>((resolve) =>
273
+ ws.addEventListener('message', resolve),
274
+ )
275
+ expect(data).toMatchInlineSnapshot(
276
+ `"{"jsonrpc":"2.0","id":0,"result":"0x0"}"`,
277
+ )
278
+
279
+ await stop()
280
+ })
281
+ })
282
+
283
+ test('404', async () => {
284
+ const server = defineProxyPool({
285
+ instance: anvil(),
286
+ })
287
+
288
+ const stop = await server.start()
289
+ const { port } = server.address()!
290
+ const response = await fetch(`http://localhost:${port}/wat`)
291
+ expect(response.status).toBe(404)
292
+
293
+ await stop()
294
+ })
package/proxy.ts ADDED
@@ -0,0 +1,135 @@
1
+ import {
2
+ type IncomingMessage,
3
+ type Server,
4
+ type ServerResponse,
5
+ createServer,
6
+ } from 'node:http'
7
+ import type { AddressInfo } from 'node:net'
8
+ import { createProxyServer } from 'http-proxy'
9
+
10
+ import { type DefinePoolParameters, definePool } from './pool.js'
11
+ import { extractPath } from './utils.js'
12
+
13
+ export type DefineProxyPoolParameters = DefinePoolParameters &
14
+ (
15
+ | {
16
+ /** Host to run the server on. */
17
+ host?: string | undefined
18
+ /** Port to run the server on. */
19
+ port: number
20
+ }
21
+ | {
22
+ host?: undefined
23
+ port?: undefined
24
+ }
25
+ )
26
+
27
+ export type DefineProxyPoolReturnType = Omit<
28
+ Server<typeof IncomingMessage, typeof ServerResponse>,
29
+ 'address'
30
+ > & {
31
+ address(): AddressInfo | null
32
+ start(): Promise<() => Promise<void>>
33
+ stop(): Promise<void>
34
+ }
35
+
36
+ export function defineProxyPool(
37
+ parameters: DefineProxyPoolParameters,
38
+ ): DefineProxyPoolReturnType {
39
+ const { host = '::', instance, limit, port } = parameters
40
+
41
+ const pool = definePool({ instance, limit })
42
+ const proxy = createProxyServer({
43
+ ignorePath: true,
44
+ ws: true,
45
+ })
46
+
47
+ const server = createServer(async (request, response) => {
48
+ try {
49
+ const url = request.url
50
+ if (!url) {
51
+ response.end()
52
+ return
53
+ }
54
+
55
+ const { id, path } = extractPath(url)
56
+
57
+ if (typeof id === 'number' && path === '/') {
58
+ const { host, port } = pool.get(id) || (await pool.start(id))
59
+ return proxy.web(request, response, {
60
+ target: `http://${host}:${port}`,
61
+ })
62
+ }
63
+ if (typeof id === 'number' && path === '/start') {
64
+ const { host, port } = await pool.start(id)
65
+ response
66
+ .writeHead(200, { 'Content-Type': 'application/json' })
67
+ .end(JSON.stringify({ host, port }))
68
+ return
69
+ }
70
+ if (typeof id === 'number' && path === '/stop') {
71
+ await pool.stop(id)
72
+ response.writeHead(200, { 'Content-Type': 'application/json' }).end()
73
+ return
74
+ }
75
+ if (typeof id === 'number' && path === '/messages') {
76
+ const messages = pool.get(id)?.messages.get() || []
77
+ response
78
+ .writeHead(200, { 'Content-Type': 'application/json' })
79
+ .end(JSON.stringify(messages))
80
+ return
81
+ }
82
+
83
+ if (path === '/healthcheck') {
84
+ response.writeHead(200, { 'Content-Type': 'application/json' }).end()
85
+ return
86
+ }
87
+
88
+ response.writeHead(404, { 'Content-Type': 'application/json' }).end()
89
+ return
90
+ } catch (error) {
91
+ response
92
+ .writeHead(400, { 'Content-Type': 'application/json' })
93
+ .end(JSON.stringify({ message: (error as Error).message }))
94
+ return
95
+ }
96
+ })
97
+
98
+ server.on('upgrade', async (request, socket, head) => {
99
+ const url = request.url
100
+ if (!url) {
101
+ socket.destroy(new Error('Unsupported request'))
102
+ return
103
+ }
104
+
105
+ const { id, path } = extractPath(url)
106
+
107
+ if (typeof id === 'number' && path === '/') {
108
+ const { host, port } = pool.get(id) || (await pool.start(id))
109
+ proxy.ws(request, socket, head, {
110
+ target: `ws://${host}:${port}`,
111
+ })
112
+ return
113
+ }
114
+
115
+ socket.destroy(new Error('Unsupported request'))
116
+ return
117
+ })
118
+
119
+ return Object.assign(server as any, {
120
+ start() {
121
+ return new Promise<() => Promise<void>>((resolve) => {
122
+ if (port) server.listen(port, host, () => resolve(this.stop))
123
+ else server.listen(() => resolve(this.stop))
124
+ })
125
+ },
126
+ async stop() {
127
+ await Promise.allSettled([
128
+ new Promise<void>((resolve, reject) =>
129
+ server.close((error) => (error ? reject(error) : resolve())),
130
+ ),
131
+ pool.destroyAll(),
132
+ ])
133
+ },
134
+ })
135
+ }