abxbus 2.4.28 → 2.4.30

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 (40) hide show
  1. package/README.md +22 -1
  2. package/dist/cjs/EventBus.d.ts +5 -1
  3. package/dist/cjs/EventBus.js +20 -7
  4. package/dist/cjs/EventBus.js.map +2 -2
  5. package/dist/cjs/EventHistory.d.ts +5 -0
  6. package/dist/cjs/EventHistory.js +32 -5
  7. package/dist/cjs/EventHistory.js.map +2 -2
  8. package/dist/cjs/TachyonEventBridge.d.ts +25 -0
  9. package/dist/cjs/TachyonEventBridge.js +427 -0
  10. package/dist/cjs/TachyonEventBridge.js.map +7 -0
  11. package/dist/cjs/bridges.d.ts +1 -0
  12. package/dist/cjs/bridges.js +3 -1
  13. package/dist/cjs/bridges.js.map +2 -2
  14. package/dist/cjs/index.d.ts +2 -2
  15. package/dist/cjs/index.js.map +2 -2
  16. package/dist/cjs/types.d.ts +3 -0
  17. package/dist/cjs/types.js.map +2 -2
  18. package/dist/esm/EventBus.js +20 -7
  19. package/dist/esm/EventBus.js.map +2 -2
  20. package/dist/esm/EventHistory.js +32 -5
  21. package/dist/esm/EventHistory.js.map +2 -2
  22. package/dist/esm/TachyonEventBridge.js +406 -0
  23. package/dist/esm/TachyonEventBridge.js.map +7 -0
  24. package/dist/esm/bridges.js +3 -1
  25. package/dist/esm/bridges.js.map +2 -2
  26. package/dist/esm/index.js.map +2 -2
  27. package/dist/esm/types.js.map +2 -2
  28. package/dist/types/EventBus.d.ts +5 -1
  29. package/dist/types/EventHistory.d.ts +5 -0
  30. package/dist/types/TachyonEventBridge.d.ts +25 -0
  31. package/dist/types/bridges.d.ts +1 -0
  32. package/dist/types/index.d.ts +2 -2
  33. package/dist/types/types.d.ts +3 -0
  34. package/package.json +26 -20
  35. package/src/EventBus.ts +38 -10
  36. package/src/EventHistory.ts +47 -4
  37. package/src/TachyonEventBridge.ts +498 -0
  38. package/src/bridges.ts +1 -0
  39. package/src/index.ts +10 -2
  40. package/src/types.ts +2 -0
@@ -0,0 +1,498 @@
1
+ /**
2
+ * Tachyon SPSC IPC bridge for forwarding events between runtimes.
3
+ *
4
+ * Tachyon is a same-machine shared-memory ring buffer with single-producer/
5
+ * single-consumer semantics. Each bridge instance plays exactly one role per
6
+ * session (sender XOR listener) — the role is committed on the first call to
7
+ * `emit()` (sender) or `on()` (listener).
8
+ *
9
+ * Usage:
10
+ * const bridge = new TachyonEventBridge('/tmp/abxbus.sock')
11
+ *
12
+ * // listener side (creates the SHM arena, must exist before sender connects)
13
+ * bridge.on('SomeEvent', handler)
14
+ *
15
+ * // sender side (separate process or instance)
16
+ * const sender = new TachyonEventBridge('/tmp/abxbus.sock')
17
+ * await sender.emit(event)
18
+ */
19
+ import { existsSync, symlinkSync, unlinkSync } from 'node:fs'
20
+ import { createRequire } from 'node:module'
21
+ import { dirname, join } from 'node:path'
22
+ import { Worker } from 'node:worker_threads'
23
+
24
+ import { BaseEvent } from './BaseEvent.js'
25
+ import { EventBus } from './EventBus.js'
26
+ import { assertOptionalDependencyAvailable, isNodeRuntime } from './optional_deps.js'
27
+ import type { EventClass, EventHandlerCallable, EventPattern, UntypedEventHandlerFunction } from './types.js'
28
+
29
+ const randomSuffix = (): string => Math.random().toString(36).slice(2, 10)
30
+ const DEFAULT_TACHYON_CAPACITY = 1 << 20
31
+ const TACHYON_CONNECT_TIMEOUT_MS = 5000
32
+ const TACHYON_LISTEN_TIMEOUT_MS = 5000
33
+ // Tachyon recv() blocks on a futex that worker.terminate() cannot preempt; the
34
+ // producer signals graceful shutdown by emitting one final message with this
35
+ // reserved type id, which lets the consumer break out of its recv loop.
36
+ const TACHYON_SHUTDOWN_TYPE_ID = 0xdead
37
+ const TACHYON_DATA_TYPE_ID = 1
38
+ const requireForTachyon = createRequire(import.meta.url)
39
+
40
+ const ensureTachyonNativeLayout = (): void => {
41
+ try {
42
+ const package_json = requireForTachyon.resolve('@tachyon-ipc/core/package.json')
43
+ const core_dir = dirname(package_json)
44
+ const expected_build_dir = join(dirname(core_dir), 'build')
45
+ const actual_build_dir = join(core_dir, 'build')
46
+ if (!existsSync(expected_build_dir) && existsSync(actual_build_dir)) {
47
+ symlinkSync(actual_build_dir, expected_build_dir, 'dir')
48
+ }
49
+ } catch {
50
+ // Optional dependency availability and import errors are reported by the caller.
51
+ }
52
+ }
53
+
54
+ const TACHYON_NATIVE_LAYOUT_FIX = `
55
+ const ensureTachyonNativeLayout = () => {
56
+ try {
57
+ const fs = require('node:fs')
58
+ const path = require('node:path')
59
+ const packageJson = require.resolve('@tachyon-ipc/core/package.json')
60
+ const coreDir = path.dirname(packageJson)
61
+ const expectedBuildDir = path.join(path.dirname(coreDir), 'build')
62
+ const actualBuildDir = path.join(coreDir, 'build')
63
+ if (!fs.existsSync(expectedBuildDir) && fs.existsSync(actualBuildDir)) {
64
+ fs.symlinkSync(actualBuildDir, expectedBuildDir, 'dir')
65
+ }
66
+ } catch {}
67
+ }
68
+ ensureTachyonNativeLayout()
69
+ `
70
+
71
+ const TACHYON_LISTENER_WORKER_CODE = `
72
+ const { parentPort, workerData } = require('node:worker_threads')
73
+ ${TACHYON_NATIVE_LAYOUT_FIX}
74
+
75
+ const SHUTDOWN_TYPE_ID = ${TACHYON_SHUTDOWN_TYPE_ID}
76
+
77
+ const probeListenerAlive = (path) => new Promise((resolve) => {
78
+ const net = require('node:net')
79
+ const sock = net.createConnection(path)
80
+ const settle = (alive) => {
81
+ try { sock.destroy() } catch {}
82
+ resolve(alive)
83
+ }
84
+ sock.setTimeout(50, () => settle(false))
85
+ sock.once('connect', () => settle(true))
86
+ sock.once('error', () => settle(false))
87
+ })
88
+
89
+ const main = async () => {
90
+ const { Bus } = await import('@tachyon-ipc/core')
91
+ const fs = require('node:fs')
92
+ const { path, capacity } = workerData
93
+ let bus
94
+ try {
95
+ bus = Bus.listen(path, capacity)
96
+ } catch (firstErr) {
97
+ // The bind failed because the path is in use. If a live listener owns it, propagate
98
+ // the error so the user can resolve the conflict; if it's a stale socket from a
99
+ // previous crash, unlink it and retry exactly once. Refuse to unlink anything that
100
+ // isn't a unix socket — we have no business deleting an unrelated regular file.
101
+ const alive = await probeListenerAlive(path)
102
+ if (alive || !fs.existsSync(path)) throw firstErr
103
+ let st
104
+ try { st = fs.statSync(path) } catch { throw firstErr }
105
+ if (!st.isSocket()) throw firstErr
106
+ try { fs.unlinkSync(path) } catch {}
107
+ bus = Bus.listen(path, capacity)
108
+ }
109
+ parentPort.postMessage({ type: 'ready' })
110
+ while (true) {
111
+ let msg
112
+ try {
113
+ msg = bus.recv()
114
+ } catch (err) {
115
+ parentPort.postMessage({ type: 'error', message: err && err.message ? err.message : String(err) })
116
+ break
117
+ }
118
+ if (msg.typeId === SHUTDOWN_TYPE_ID) break
119
+ parentPort.postMessage({ type: 'message', data: msg.data, typeId: msg.typeId })
120
+ }
121
+ try { bus.close && bus.close() } catch {}
122
+ }
123
+
124
+ main().catch((err) => {
125
+ if (parentPort) {
126
+ parentPort.postMessage({ type: 'error', message: err && err.message ? err.message : String(err) })
127
+ }
128
+ }).finally(() => process.exit(0))
129
+ `
130
+
131
+ const TACHYON_SENDER_WORKER_CODE = `
132
+ const { parentPort, workerData } = require('node:worker_threads')
133
+ ${TACHYON_NATIVE_LAYOUT_FIX}
134
+
135
+ const SHUTDOWN_TYPE_ID = ${TACHYON_SHUTDOWN_TYPE_ID}
136
+ const DATA_TYPE_ID = ${TACHYON_DATA_TYPE_ID}
137
+
138
+ const main = async () => {
139
+ const { Bus } = await import('@tachyon-ipc/core')
140
+ const { path, connect_timeout_ms } = workerData
141
+ let bus = null
142
+ let last_err = null
143
+ const deadline = Date.now() + connect_timeout_ms
144
+ while (Date.now() < deadline) {
145
+ try {
146
+ bus = Bus.connect(path)
147
+ break
148
+ } catch (err) {
149
+ last_err = err
150
+ await new Promise((resolve) => setTimeout(resolve, 10))
151
+ }
152
+ }
153
+ if (!bus) {
154
+ parentPort.postMessage({ type: 'error', message: 'TachyonEventBridge sender failed to connect: ' + (last_err && last_err.message ? last_err.message : String(last_err)) })
155
+ return
156
+ }
157
+ parentPort.postMessage({ type: 'ready' })
158
+ parentPort.on('message', (msg) => {
159
+ if (!msg) return
160
+ if (msg.type === 'send') {
161
+ try {
162
+ bus.send(Buffer.from(msg.payload), DATA_TYPE_ID)
163
+ parentPort.postMessage({ type: 'sent', id: msg.id })
164
+ } catch (err) {
165
+ parentPort.postMessage({ type: 'send_error', id: msg.id, message: err && err.message ? err.message : String(err) })
166
+ }
167
+ return
168
+ }
169
+ if (msg.type === 'close') {
170
+ try { bus.send(Buffer.alloc(0), SHUTDOWN_TYPE_ID) } catch {}
171
+ try { bus.close && bus.close() } catch {}
172
+ process.exit(0)
173
+ }
174
+ })
175
+ }
176
+
177
+ main().catch((err) => {
178
+ if (parentPort) {
179
+ parentPort.postMessage({ type: 'error', message: err && err.message ? err.message : String(err) })
180
+ }
181
+ })
182
+ `
183
+
184
+ type SendResolver = { resolve: () => void; reject: (err: Error) => void }
185
+
186
+ export class TachyonEventBridge {
187
+ readonly path: string
188
+ readonly capacity: number
189
+ readonly name: string
190
+
191
+ private readonly inbound_bus: EventBus
192
+ private listener_worker: Worker | null
193
+ // Sticky: `listener_worker` may be cleared mid-session (graceful exit, retry path),
194
+ // but the socket on disk is still ours to unlink in close().
195
+ private acted_as_listener: boolean
196
+ private listener_startup_error: Error | null
197
+ private sender_worker: Worker | null
198
+ private sender_ready_promise: Promise<void> | null
199
+ private send_seq: number
200
+ private pending_sends: Map<number, SendResolver>
201
+ private closed: boolean
202
+
203
+ constructor(path: string, capacity: number = DEFAULT_TACHYON_CAPACITY, name?: string) {
204
+ if (!path) throw new Error('TachyonEventBridge path must not be empty')
205
+ if (capacity <= 0 || (capacity & (capacity - 1)) !== 0) {
206
+ throw new Error(`TachyonEventBridge capacity must be a positive power of two, got: ${capacity}`)
207
+ }
208
+ assertOptionalDependencyAvailable('TachyonEventBridge', '@tachyon-ipc/core')
209
+ ensureTachyonNativeLayout()
210
+
211
+ this.path = path
212
+ this.capacity = capacity
213
+ this.name = name ?? `TachyonEventBridge_${randomSuffix()}`
214
+ this.inbound_bus = new EventBus(this.name, { max_history_size: 0 })
215
+ this.listener_worker = null
216
+ this.acted_as_listener = false
217
+ this.listener_startup_error = null
218
+ this.sender_worker = null
219
+ this.sender_ready_promise = null
220
+ this.send_seq = 0
221
+ this.pending_sends = new Map()
222
+ this.closed = false
223
+
224
+ this.dispatch = this.dispatch.bind(this)
225
+ this.emit = this.emit.bind(this)
226
+ this.on = this.on.bind(this)
227
+ }
228
+
229
+ on<T extends BaseEvent>(event_pattern: EventClass<T>, handler: EventHandlerCallable<T>): void
230
+ on<T extends BaseEvent>(event_pattern: string | '*', handler: UntypedEventHandlerFunction<T>): void
231
+ on(event_pattern: EventPattern | '*', handler: EventHandlerCallable | UntypedEventHandlerFunction): void {
232
+ this.ensureListenerStarted()
233
+ if (typeof event_pattern === 'string') {
234
+ this.inbound_bus.on(event_pattern, handler as UntypedEventHandlerFunction<BaseEvent>)
235
+ return
236
+ }
237
+ this.inbound_bus.on(event_pattern as EventClass<BaseEvent>, handler as EventHandlerCallable<BaseEvent>)
238
+ }
239
+
240
+ async emit<T extends BaseEvent>(event: T): Promise<void> {
241
+ // Fail fast before spawning a sender worker / connecting to the listener so a
242
+ // post-close emit() doesn't leak an extra worker for an instance that is going away.
243
+ if (this.closed) throw new Error('TachyonEventBridge is closed')
244
+ await this.ensureSenderConnected()
245
+ if (this.closed || !this.sender_worker) {
246
+ throw new Error('TachyonEventBridge is closed')
247
+ }
248
+ const payload = Buffer.from(JSON.stringify(event.toJSON()))
249
+ const id = ++this.send_seq
250
+ await new Promise<void>((resolve, reject) => {
251
+ this.pending_sends.set(id, { resolve, reject })
252
+ this.sender_worker!.postMessage({ type: 'send', id, payload })
253
+ })
254
+ }
255
+
256
+ async dispatch<T extends BaseEvent>(event: T): Promise<void> {
257
+ return this.emit(event)
258
+ }
259
+
260
+ async start(): Promise<void> {
261
+ // Role is committed lazily on first on() / emit(). For listener-side bridges,
262
+ // await the underlying socket bind so callers that need fail-fast readiness
263
+ // (peers about to connect, tests writing a ready_path file) can rely on it.
264
+ const worker = this.listener_worker
265
+ if (!worker) return
266
+ const deadline = Date.now() + TACHYON_LISTEN_TIMEOUT_MS
267
+ let path_first_seen_at: number | null = null
268
+ while (Date.now() < deadline) {
269
+ if (this.listener_startup_error) throw this.listener_startup_error
270
+ // acted_as_listener is set when the worker posts 'ready' — the only signal that
271
+ // *we* (not some other process) actually own the socket file at `path`.
272
+ if (this.acted_as_listener) return
273
+ if (existsSync(this.path)) {
274
+ if (path_first_seen_at === null) {
275
+ path_first_seen_at = Date.now()
276
+ } else if (Date.now() - path_first_seen_at >= 200) {
277
+ // The path exists, but it might belong to another process. After a brief
278
+ // grace window with no startup error and no ready signal, assume our worker
279
+ // bound it (it would otherwise have raised EADDRINUSE almost immediately).
280
+ return
281
+ }
282
+ }
283
+ await new Promise((resolve) => setTimeout(resolve, 5))
284
+ }
285
+ if (this.listener_startup_error) throw this.listener_startup_error
286
+ // Tear down the worker that never bound so a later on() can spawn a fresh one.
287
+ // Use .catch() so a terminate() rejection doesn't bubble up as an unhandled
288
+ // promise rejection (which crashes Node by default in newer versions).
289
+ worker.terminate().catch(() => {
290
+ /* ignore */
291
+ })
292
+ if (this.listener_worker === worker) this.listener_worker = null
293
+ throw new Error(`TachyonEventBridge listener did not bind socket ${this.path} within ${TACHYON_LISTEN_TIMEOUT_MS}ms`)
294
+ }
295
+
296
+ async close(): Promise<void> {
297
+ this.closed = true
298
+ if (this.sender_worker) {
299
+ const sender_exited = new Promise<void>((resolve) => {
300
+ this.sender_worker!.once('exit', () => resolve())
301
+ })
302
+ try {
303
+ this.sender_worker.postMessage({ type: 'close' })
304
+ } catch {
305
+ // worker may already be gone
306
+ }
307
+ // The sender flushes a SHUTDOWN_TYPE_ID sentinel and self-exits in response
308
+ // to `close`; await that natural exit before terminating to avoid orphaning.
309
+ await Promise.race([sender_exited, new Promise((resolve) => setTimeout(resolve, 1000))])
310
+ try {
311
+ await this.sender_worker.terminate()
312
+ } catch {
313
+ // ignore
314
+ }
315
+ this.sender_worker = null
316
+ }
317
+ if (this.listener_worker) {
318
+ const listener = this.listener_worker
319
+ this.listener_worker = null
320
+ // The listener exits naturally once it consumes the shutdown sentinel.
321
+ const listener_exited = new Promise<void>((resolve) => {
322
+ listener.once('exit', () => resolve())
323
+ })
324
+ await Promise.race([listener_exited, new Promise((resolve) => setTimeout(resolve, 1000))])
325
+ // worker.terminate() cannot preempt a futex-blocked Tachyon recv() (see the
326
+ // SHUTDOWN_TYPE_ID comment above), so a listener-only instance that never
327
+ // received a sentinel would hang forever if we awaited it. Fire-and-forget the
328
+ // terminate and bound how long we'll wait for it to come back; if it doesn't,
329
+ // unref the worker so the parent Node process can still exit (the worker is
330
+ // daemon-mode and dies with the process either way).
331
+ const terminate_promise = listener.terminate().catch(() => undefined)
332
+ const terminate_settled = await Promise.race([
333
+ terminate_promise.then(() => true),
334
+ new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 500)),
335
+ ])
336
+ if (!terminate_settled) {
337
+ try {
338
+ listener.unref()
339
+ } catch {
340
+ // ignore
341
+ }
342
+ }
343
+ }
344
+ for (const pending of this.pending_sends.values()) {
345
+ pending.reject(new Error('TachyonEventBridge closed'))
346
+ }
347
+ this.pending_sends.clear()
348
+ // Only the side that bound the socket (the listener) owns the path on disk.
349
+ // Sender-only instances must leave it alone so other senders/listeners can keep using it.
350
+ if (this.acted_as_listener && existsSync(this.path)) {
351
+ try {
352
+ unlinkSync(this.path)
353
+ } catch {
354
+ // ignore
355
+ }
356
+ }
357
+ this.inbound_bus.destroy()
358
+ }
359
+
360
+ private ensureListenerStarted(): void {
361
+ if (this.closed) throw new Error('TachyonEventBridge is closed')
362
+ if (this.listener_worker) return
363
+ if (!isNodeRuntime()) {
364
+ throw new Error('TachyonEventBridge is only supported in Node.js runtimes')
365
+ }
366
+ // The worker probes the path before unlinking — only stale sockets (no live
367
+ // listener) get cleared. This avoids clobbering a listener owned by another
368
+ // process when two bridges race on the same path.
369
+ const worker = new Worker(TACHYON_LISTENER_WORKER_CODE, {
370
+ eval: true,
371
+ workerData: { path: this.path, capacity: this.capacity },
372
+ })
373
+ this.listener_startup_error = null
374
+ worker.on('message', (msg: { type: string; data?: Uint8Array; typeId?: number; message?: string }) => {
375
+ if (msg.type === 'ready') {
376
+ // Bus.listen returning means *this* worker completed the bind+handshake; only
377
+ // now can we safely claim socket ownership for close()'s unlink path.
378
+ this.acted_as_listener = true
379
+ } else if (msg.type === 'message' && msg.data) {
380
+ try {
381
+ const text = Buffer.from(msg.data).toString('utf-8')
382
+ const payload = JSON.parse(text)
383
+ const event = BaseEvent.fromJSON(payload).eventReset()
384
+ this.inbound_bus.emit(event)
385
+ } catch {
386
+ // ignore malformed payloads
387
+ }
388
+ } else if (msg.type === 'error') {
389
+ // Surface the failure so a concurrent start() can fail fast instead of
390
+ // hanging until the bind-wait deadline.
391
+ this.listener_startup_error = new Error(msg.message ?? 'TachyonEventBridge listener error')
392
+ console.error('[abxbus] TachyonEventBridge listener error:', msg.message)
393
+ }
394
+ })
395
+ worker.on('error', (err: unknown) => {
396
+ this.listener_startup_error = err instanceof Error ? err : new Error(String(err))
397
+ console.error('[abxbus] TachyonEventBridge listener worker crashed:', err)
398
+ })
399
+ // Drop the cached reference if the worker dies (crash, init failure, or natural exit
400
+ // after consuming a shutdown sentinel) so a subsequent on() call can spin up a fresh one.
401
+ worker.on('exit', () => {
402
+ if (this.listener_worker === worker) this.listener_worker = null
403
+ // Only flag a startup error if we never confirmed a bind AND the bridge isn't
404
+ // shutting down; a normal shutdown exit (close(), or recv loop after consuming a
405
+ // sentinel) shouldn't poison future start() calls.
406
+ if (!this.acted_as_listener && !this.listener_startup_error && !this.closed) {
407
+ this.listener_startup_error = new Error('TachyonEventBridge listener worker exited before binding')
408
+ }
409
+ })
410
+ this.listener_worker = worker
411
+ // on() returns immediately so the Node event loop isn't frozen on startup; peers
412
+ // that try Bus.connect before this worker finishes binding are covered by the
413
+ // sender-side connect retry loop (see TACHYON_CONNECT_TIMEOUT_MS) and by the
414
+ // worker's 'ready' message which flips acted_as_listener once bind completes.
415
+ }
416
+
417
+ private async ensureSenderConnected(): Promise<void> {
418
+ if (this.sender_ready_promise) {
419
+ await this.sender_ready_promise
420
+ return
421
+ }
422
+ if (!isNodeRuntime()) {
423
+ throw new Error('TachyonEventBridge is only supported in Node.js runtimes')
424
+ }
425
+ const promise = new Promise<void>((resolve, reject) => {
426
+ const worker = new Worker(TACHYON_SENDER_WORKER_CODE, {
427
+ eval: true,
428
+ workerData: { path: this.path, connect_timeout_ms: TACHYON_CONNECT_TIMEOUT_MS },
429
+ })
430
+ let resolved = false
431
+ worker.on('message', (msg: { type: string; id?: number; message?: string }) => {
432
+ if (msg.type === 'ready') {
433
+ resolved = true
434
+ resolve()
435
+ return
436
+ }
437
+ if (msg.type === 'sent' && typeof msg.id === 'number') {
438
+ const pending = this.pending_sends.get(msg.id)
439
+ if (pending) {
440
+ this.pending_sends.delete(msg.id)
441
+ pending.resolve()
442
+ }
443
+ return
444
+ }
445
+ if (msg.type === 'send_error' && typeof msg.id === 'number') {
446
+ const pending = this.pending_sends.get(msg.id)
447
+ if (pending) {
448
+ this.pending_sends.delete(msg.id)
449
+ pending.reject(new Error(msg.message ?? 'TachyonEventBridge send failed'))
450
+ }
451
+ return
452
+ }
453
+ if (msg.type === 'error') {
454
+ const err = new Error(msg.message ?? 'TachyonEventBridge sender error')
455
+ if (!resolved) {
456
+ reject(err)
457
+ return
458
+ }
459
+ console.error('[abxbus] TachyonEventBridge sender error:', err)
460
+ }
461
+ })
462
+ worker.on('error', (err) => {
463
+ if (!resolved) reject(err instanceof Error ? err : new Error(String(err)))
464
+ else console.error('[abxbus] TachyonEventBridge sender worker crashed:', err)
465
+ })
466
+ worker.on('exit', (code) => {
467
+ // The sender worker exits with code 0 only when responding to our `close`
468
+ // message; any other exit means the worker died unexpectedly and pending
469
+ // sends would otherwise hang forever.
470
+ if (code === 0 && this.closed) return
471
+ const err = new Error(`TachyonEventBridge sender worker exited with code ${code}`)
472
+ for (const pending of this.pending_sends.values()) pending.reject(err)
473
+ this.pending_sends.clear()
474
+ if (this.sender_worker === worker) {
475
+ this.sender_worker = null
476
+ this.sender_ready_promise = null
477
+ }
478
+ })
479
+ this.sender_worker = worker
480
+ })
481
+ this.sender_ready_promise = promise
482
+ try {
483
+ await promise
484
+ } catch (err) {
485
+ // Allow a later emit() to retry once the listener becomes available.
486
+ this.sender_ready_promise = null
487
+ if (this.sender_worker) {
488
+ try {
489
+ await this.sender_worker.terminate()
490
+ } catch {
491
+ /* ignore */
492
+ }
493
+ this.sender_worker = null
494
+ }
495
+ throw err
496
+ }
497
+ }
498
+ }
package/src/bridges.ts CHANGED
@@ -7,3 +7,4 @@ export { SQLiteEventBridge } from './SQLiteEventBridge.js'
7
7
  export { NATSEventBridge } from './NATSEventBridge.js'
8
8
  export { RedisEventBridge } from './RedisEventBridge.js'
9
9
  export { PostgresEventBridge } from './PostgresEventBridge.js'
10
+ export { TachyonEventBridge } from './TachyonEventBridge.js'
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { BaseEvent, BaseEventSchema } from './BaseEvent.js'
2
2
  export { EventHistory } from './EventHistory.js'
3
- export type { EventHistoryFindOptions, EventHistoryTrimOptions } from './EventHistory.js'
3
+ export type { EventHistoryFilterOptions, EventHistoryFindOptions, EventHistoryTrimOptions } from './EventHistory.js'
4
4
  export { EventResult } from './EventResult.js'
5
5
  export { EventBus } from './EventBus.js'
6
6
  export type { EventBusJSON, EventBusOptions } from './EventBus.js'
@@ -24,7 +24,15 @@ export type {
24
24
  EventHandlerCompletionMode,
25
25
  EventBusInterfaceForLockManager,
26
26
  } from './LockManager.js'
27
- export type { EventClass, EventHandlerCallable as EventHandler, EventPattern, EventStatus, FindOptions, FindWindow } from './types.js'
27
+ export type {
28
+ EventClass,
29
+ EventHandlerCallable as EventHandler,
30
+ EventPattern,
31
+ EventStatus,
32
+ FilterOptions,
33
+ FindOptions,
34
+ FindWindow,
35
+ } from './types.js'
28
36
  export { retry, clearSemaphoreRegistry, RetryTimeoutError, SemaphoreTimeoutError } from './retry.js'
29
37
  export type { RetryOptions } from './retry.js'
30
38
  export { events_suck } from './events_suck.js'
package/src/types.ts CHANGED
@@ -50,6 +50,8 @@ export type FindOptions<T extends BaseEvent = BaseEvent> = {
50
50
  } & EventFilterFields<T> &
51
51
  Record<string, unknown>
52
52
 
53
+ export type FilterOptions<T extends BaseEvent = BaseEvent> = FindOptions<T> & { limit?: number | null }
54
+
53
55
  export const normalizeEventPattern = (event_pattern: EventPattern | '*'): string | '*' => {
54
56
  if (event_pattern === '*') {
55
57
  return '*'