abxbus 2.4.29 → 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.
- package/README.md +22 -1
- package/dist/cjs/EventBus.d.ts +5 -1
- package/dist/cjs/EventBus.js +20 -7
- package/dist/cjs/EventBus.js.map +2 -2
- package/dist/cjs/EventHistory.d.ts +5 -0
- package/dist/cjs/EventHistory.js +32 -5
- package/dist/cjs/EventHistory.js.map +2 -2
- package/dist/cjs/TachyonEventBridge.d.ts +25 -0
- package/dist/cjs/TachyonEventBridge.js +427 -0
- package/dist/cjs/TachyonEventBridge.js.map +7 -0
- package/dist/cjs/base_event.d.ts +2 -2
- package/dist/cjs/bridges.d.ts +1 -0
- package/dist/cjs/bridges.js +3 -1
- package/dist/cjs/bridges.js.map +2 -2
- package/dist/cjs/event_handler.d.ts +0 -1
- package/dist/cjs/index.d.ts +2 -2
- package/dist/cjs/index.js.map +2 -2
- package/dist/cjs/types.d.ts +3 -0
- package/dist/cjs/types.js.map +2 -2
- package/dist/esm/EventBus.js +20 -7
- package/dist/esm/EventBus.js.map +2 -2
- package/dist/esm/EventHistory.js +32 -5
- package/dist/esm/EventHistory.js.map +2 -2
- package/dist/esm/TachyonEventBridge.js +406 -0
- package/dist/esm/TachyonEventBridge.js.map +7 -0
- package/dist/esm/bridges.js +3 -1
- package/dist/esm/bridges.js.map +2 -2
- package/dist/esm/index.js.map +2 -2
- package/dist/esm/types.js.map +2 -2
- package/dist/types/EventBus.d.ts +5 -1
- package/dist/types/EventHistory.d.ts +5 -0
- package/dist/types/TachyonEventBridge.d.ts +25 -0
- package/dist/types/base_event.d.ts +2 -2
- package/dist/types/bridges.d.ts +1 -0
- package/dist/types/event_handler.d.ts +0 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/types.d.ts +3 -0
- package/package.json +26 -20
- package/src/EventBus.ts +38 -10
- package/src/EventHistory.ts +47 -4
- package/src/TachyonEventBridge.ts +498 -0
- package/src/bridges.ts +1 -0
- package/src/index.ts +10 -2
- package/src/types.ts +2 -0
- package/dist/cjs/bridge_ipc.d.ts +0 -45
- package/dist/cjs/middleware_otel_tracing.d.ts +0 -49
- package/dist/types/bridge_ipc.d.ts +0 -45
- package/dist/types/middleware_otel_tracing.d.ts +0 -49
|
@@ -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 {
|
|
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 '*'
|
package/dist/cjs/bridge_ipc.d.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { BaseEvent } from './base_event.js';
|
|
2
|
-
import { EventBus } from './event_bus.js';
|
|
3
|
-
import type { EventClass, EventHandlerCallable, UntypedEventHandlerFunction } from './types.js';
|
|
4
|
-
type EndpointScheme = 'unix' | 'http' | 'https';
|
|
5
|
-
type ParsedEndpoint = {
|
|
6
|
-
raw: string;
|
|
7
|
-
scheme: EndpointScheme;
|
|
8
|
-
host?: string;
|
|
9
|
-
port?: number;
|
|
10
|
-
path?: string;
|
|
11
|
-
};
|
|
12
|
-
export type HTTPEventBridgeOptions = {
|
|
13
|
-
send_to?: string | null;
|
|
14
|
-
listen_on?: string | null;
|
|
15
|
-
name?: string;
|
|
16
|
-
};
|
|
17
|
-
export declare class EventBridge {
|
|
18
|
-
readonly send_to: ParsedEndpoint | null;
|
|
19
|
-
readonly listen_on: ParsedEndpoint | null;
|
|
20
|
-
readonly name: string;
|
|
21
|
-
protected readonly inbound_bus: EventBus;
|
|
22
|
-
private start_promise;
|
|
23
|
-
private node_server;
|
|
24
|
-
constructor(send_to?: string | null, listen_on?: string | null, name?: string);
|
|
25
|
-
on<T extends BaseEvent>(event_pattern: EventClass<T>, handler: EventHandlerCallable<T>): void;
|
|
26
|
-
on<T extends BaseEvent>(event_pattern: string | '*', handler: UntypedEventHandlerFunction<T>): void;
|
|
27
|
-
emit<T extends BaseEvent>(event: T): Promise<void>;
|
|
28
|
-
dispatch<T extends BaseEvent>(event: T): Promise<void>;
|
|
29
|
-
start(): Promise<void>;
|
|
30
|
-
close(): Promise<void>;
|
|
31
|
-
private ensureListenerStarted;
|
|
32
|
-
private handleIncomingPayload;
|
|
33
|
-
private sendHttp;
|
|
34
|
-
private sendUnix;
|
|
35
|
-
private startHttpListener;
|
|
36
|
-
private startUnixListener;
|
|
37
|
-
}
|
|
38
|
-
export declare class HTTPEventBridge extends EventBridge {
|
|
39
|
-
constructor(send_to?: string | null, listen_on?: string | null, name?: string);
|
|
40
|
-
constructor(options?: HTTPEventBridgeOptions);
|
|
41
|
-
}
|
|
42
|
-
export declare class SocketEventBridge extends EventBridge {
|
|
43
|
-
constructor(path?: string | null, name?: string);
|
|
44
|
-
}
|
|
45
|
-
export {};
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { trace, type Span, type SpanAttributes, type SpanContext, type TimeInput, type Tracer } from '@opentelemetry/api';
|
|
2
|
-
import type { BaseEvent } from './base_event.js';
|
|
3
|
-
import type { EventBus } from './event_bus.js';
|
|
4
|
-
import type { EventResult } from './event_result.js';
|
|
5
|
-
import type { EventBusMiddleware } from './middlewares.js';
|
|
6
|
-
import type { EventStatus } from './types.js';
|
|
7
|
-
type OpenTelemetryTraceApi = Pick<typeof trace, 'getTracer' | 'setSpan'> & Partial<Pick<typeof trace, 'setSpanContext'>>;
|
|
8
|
-
export type OtelTracingSpanFactoryInput = {
|
|
9
|
-
name: string;
|
|
10
|
-
span_context: SpanContext;
|
|
11
|
-
parent_span_context?: SpanContext;
|
|
12
|
-
attributes: SpanAttributes;
|
|
13
|
-
start_time?: TimeInput;
|
|
14
|
-
};
|
|
15
|
-
export type OtelTracingSpanFactory = (input: OtelTracingSpanFactoryInput) => Span;
|
|
16
|
-
export type OtelTracingSpanProvider = object;
|
|
17
|
-
export type OtelTracingMiddlewareOptions = {
|
|
18
|
-
tracer?: Tracer;
|
|
19
|
-
trace_api?: OpenTelemetryTraceApi;
|
|
20
|
-
span_provider?: OtelTracingSpanProvider;
|
|
21
|
-
span_factory?: OtelTracingSpanFactory;
|
|
22
|
-
otlp_endpoint?: string;
|
|
23
|
-
service_name?: string;
|
|
24
|
-
instrumentation_name?: string;
|
|
25
|
-
root_span_attributes?: SpanAttributes | ((eventbus: EventBus, event: BaseEvent) => SpanAttributes);
|
|
26
|
-
};
|
|
27
|
-
export declare class OtelTracingMiddleware implements EventBusMiddleware {
|
|
28
|
-
private readonly tracer;
|
|
29
|
-
private readonly trace_api;
|
|
30
|
-
private readonly span_factory?;
|
|
31
|
-
private readonly span_provider?;
|
|
32
|
-
private readonly root_span_attributes;
|
|
33
|
-
private readonly event_spans;
|
|
34
|
-
private readonly event_contexts;
|
|
35
|
-
private readonly handler_spans;
|
|
36
|
-
private readonly handler_contexts;
|
|
37
|
-
constructor(options?: OtelTracingMiddlewareOptions);
|
|
38
|
-
onEventChange(eventbus: EventBus, event: BaseEvent, status: EventStatus): void;
|
|
39
|
-
onEventResultChange(eventbus: EventBus, event: BaseEvent, event_result: EventResult, status: EventStatus): void;
|
|
40
|
-
private startEventSpan;
|
|
41
|
-
private completeEventSpan;
|
|
42
|
-
private startHandlerSpan;
|
|
43
|
-
private completeHandlerSpan;
|
|
44
|
-
private parentContextForEvent;
|
|
45
|
-
private completeEventSpanWithFactory;
|
|
46
|
-
private exportEventTreeWithFactory;
|
|
47
|
-
private exportHandlerSpanWithFactory;
|
|
48
|
-
}
|
|
49
|
-
export {};
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { BaseEvent } from './base_event.js';
|
|
2
|
-
import { EventBus } from './event_bus.js';
|
|
3
|
-
import type { EventClass, EventHandlerCallable, UntypedEventHandlerFunction } from './types.js';
|
|
4
|
-
type EndpointScheme = 'unix' | 'http' | 'https';
|
|
5
|
-
type ParsedEndpoint = {
|
|
6
|
-
raw: string;
|
|
7
|
-
scheme: EndpointScheme;
|
|
8
|
-
host?: string;
|
|
9
|
-
port?: number;
|
|
10
|
-
path?: string;
|
|
11
|
-
};
|
|
12
|
-
export type HTTPEventBridgeOptions = {
|
|
13
|
-
send_to?: string | null;
|
|
14
|
-
listen_on?: string | null;
|
|
15
|
-
name?: string;
|
|
16
|
-
};
|
|
17
|
-
export declare class EventBridge {
|
|
18
|
-
readonly send_to: ParsedEndpoint | null;
|
|
19
|
-
readonly listen_on: ParsedEndpoint | null;
|
|
20
|
-
readonly name: string;
|
|
21
|
-
protected readonly inbound_bus: EventBus;
|
|
22
|
-
private start_promise;
|
|
23
|
-
private node_server;
|
|
24
|
-
constructor(send_to?: string | null, listen_on?: string | null, name?: string);
|
|
25
|
-
on<T extends BaseEvent>(event_pattern: EventClass<T>, handler: EventHandlerCallable<T>): void;
|
|
26
|
-
on<T extends BaseEvent>(event_pattern: string | '*', handler: UntypedEventHandlerFunction<T>): void;
|
|
27
|
-
emit<T extends BaseEvent>(event: T): Promise<void>;
|
|
28
|
-
dispatch<T extends BaseEvent>(event: T): Promise<void>;
|
|
29
|
-
start(): Promise<void>;
|
|
30
|
-
close(): Promise<void>;
|
|
31
|
-
private ensureListenerStarted;
|
|
32
|
-
private handleIncomingPayload;
|
|
33
|
-
private sendHttp;
|
|
34
|
-
private sendUnix;
|
|
35
|
-
private startHttpListener;
|
|
36
|
-
private startUnixListener;
|
|
37
|
-
}
|
|
38
|
-
export declare class HTTPEventBridge extends EventBridge {
|
|
39
|
-
constructor(send_to?: string | null, listen_on?: string | null, name?: string);
|
|
40
|
-
constructor(options?: HTTPEventBridgeOptions);
|
|
41
|
-
}
|
|
42
|
-
export declare class SocketEventBridge extends EventBridge {
|
|
43
|
-
constructor(path?: string | null, name?: string);
|
|
44
|
-
}
|
|
45
|
-
export {};
|