diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index f52b501..3cf27d5 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -20,7 +20,11 @@
"Bash(echo:*)",
"Bash(grep:*)",
"Bash(rg:*)",
- "Bash(.venv/bin/pytest tests/test_typed_event_results.py::test_builtin_type_casting -v -s --timeout=10)"
+ "WebFetch(domain:github.com)",
+ "Bash(timeout 60 .venv/bin/pytest:*)",
+ "Bash(timeout 180 .venv/bin/pytest tests/ -v)",
+ "Bash(timeout 180 .venv/bin/pytest:*)",
+ "Bash(git tag:*)"
],
"deny": []
}
diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml
new file mode 100644
index 0000000..30bcfcb
--- /dev/null
+++ b/.github/workflows/publish-npm.yml
@@ -0,0 +1,52 @@
+name: publish-npm
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: npm dist-tag to publish under
+ required: false
+ default: latest
+
+permissions:
+ contents: read
+ id-token: write
+
+jobs:
+ publish_to_npm:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: bubus-ts
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 10
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+ cache-dependency-path: bubus-ts/pnpm-lock.yaml
+ registry-url: https://registry.npmjs.org
+
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm run typecheck
+ - run: pnpm test
+ - run: pnpm run build
+
+ - name: Publish release tag
+ if: github.event_name == 'release'
+ run: pnpm publish --access public --no-git-checks
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+ - name: Publish manual tag
+ if: github.event_name == 'workflow_dispatch'
+ run: pnpm publish --access public --tag "${{ inputs.tag }}" --no-git-checks
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 6d5adec..8960285 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,6 +20,7 @@ CLAUDE.local.md
# Build files
dist/
+node_modules/
# Coverage files
.coverage
@@ -27,7 +28,7 @@ dist/
htmlcov/
coverage.xml
*.cover
-
+*.sqlite*
# Secrets and sensitive files
secrets.env
diff --git a/README.md b/README.md
index afd7ed8..66316e9 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,35 @@
-# `bubus`: π’ Production-ready event bus library for Python
+# `bubus`: π’ Production-ready multi-language event bus
-Bubus is a fully-featured, Pydantic-powered event bus library for async Python.
+
-It's designed for quickly building event-driven applications with Python in a way that "just works" with async support, proper support for nested events, and real concurrency control.
+[](https://deepwiki.com/pirate/bbus)   
-It provides a [pydantic](https://docs.pydantic.dev/latest/)-based API for implementing publish-subscribe patterns with type safety, async/sync handler support, and advanced features like event forwarding between buses.
+[](https://deepwiki.com/pirate/bbus/3-typescript-implementation) 
-βΎοΈ It's inspired by the simplicity of async and events in `JS`, we aim to bring a fully type-checked [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget)-style API to Python.
+Bubus is an in-memory event bus library for async Python and TS (node/browser).
+
+It's designed for quickly building resilient, predictable, complex event-driven apps.
+
+It "just works" with an intuitive, but powerful event JSON format + dispatch API that's consistent across both languages and scales consistently from one even up to millions:
+```python
+bus.on(SomeEvent, some_function)
+bus.emit(SomeEvent({some_data: 132}))
+```
+
+It's async native, has proper automatic nested event tracking, and powerful concurrency control options. The API is inspired by `EventEmitter` or [`emittery`](https://github.com/sindresorhus/emittery) in JS, but it takes it a step further:
+
+- nice Pydantic / Zod schemas for events that can be exchanged between both languages
+- automatic UUIDv7s and monotonic nanosecond timestamps for ordering events globally
+- built in locking options to force strict global FIFO procesing or fully parallel processing
+
+---
+
+βΎοΈ It's inspired by the simplicity of async and events in `JS` but with baked-in features that allow to eliminate most of the tedious repetitive complexity in event-driven codebases:
+
+- correct timeout enforcement across multiple levels of events, if a parent times out it correctly aborts all child event processing
+- ability to strongly type hint and enforce the return type of event handlers at compile-time
+- ability to queue events on the bus, or inline await them for immediate execution like a normal function call
+- handles ~5,000 events/sec/core in both languages, with ~2kb/event RAM consumed per event during active processing
@@ -15,7 +38,7 @@ It provides a [pydantic](https://docs.pydantic.dev/latest/)-based API for implem
Install bubus and get started with a simple event-driven application:
```bash
-pip install bubus
+pip install bubus # see ./bubus-ts/README.md for JS instructions
```
```python
@@ -29,7 +52,7 @@ class UserLoginEvent(BaseEvent[str]):
async def handle_login(event: UserLoginEvent) -> str:
auth_request = await event.event_bus.dispatch(AuthRequestEvent(...)) # nested events supported
- auth_response = await event.event_bus.expect(AuthResponseEvent, timeout=30.0)
+ auth_response = await event.event_bus.find(AuthResponseEvent, child_of=auth_request, future=30)
return f"User {event.username} logged in admin={event.is_admin} with API response: {await auth_response.event_result()}"
bus = EventBus()
@@ -104,9 +127,9 @@ class SomeService:
return 'this works too'
# All usage patterns behave the same:
-bus.on(SomeEvent, SomeClass().handlers_can_be_methods)
-bus.on(SomeEvent, SomeClass.handler_can_be_classmethods)
-bus.on(SomeEvent, SomeClass.handlers_can_be_staticmethods)
+bus.on(SomeEvent, SomeService().handlers_can_be_methods)
+bus.on(SomeEvent, SomeService.handler_can_be_classmethods)
+bus.on(SomeEvent, SomeService.handlers_can_be_staticmethods)
```
@@ -181,6 +204,7 @@ bus.on(GetConfigEvent, load_user_config)
bus.on(GetConfigEvent, load_system_config)
# Get a merger of all dict results
+# (conflicting keys raise ValueError unless raise_if_conflicts=False)
event = await bus.dispatch(GetConfigEvent())
config = await event.event_results_flat_dict(raise_if_conflicts=False)
# {'debug': False, 'port': 8080, 'timeout': 30}
@@ -270,47 +294,78 @@ if __name__ == '__main__':
-### β³ Expect an Event to be Dispatched
+### π Find Events in History or Wait for Future Events
-Wait for specific events to be seen on a bus with optional filtering:
+The `find()` method provides a unified way to search past event history and/or wait for future events. It's the recommended approach for most event lookup scenarios.
-```python
-# Block until a specific event is seen (with optional timeout)
-request_event = await bus.dispatch(RequestEvent(id=123, table='invoices', request_id=999234))
-response_event = await bus.expect(ResponseEvent, timeout=30)
-```
+The `past` and `future` parameters accept either `bool` or `float` values:
-A more complex real-world example showing off all the features:
+| Value | `past` meaning | `future` meaning |
+|-------|----------------|------------------|
+| `True` | Search all history | Wait forever |
+| `False` | Skip history search | Don't wait |
+| `5.0` | Search last 5 seconds | Wait up to 5 seconds |
```python
-async def on_generate_invoice_pdf(event: GenerateInvoiceEvent) -> pdf:
- request_event = await bus.dispatch(APIRequestEvent( # example: fire a backend request via some RPC client using bubus
- method='invoices.generatePdf',
- invoice_id=event.invoice_id,
- request_id=uuid4(),
- ))
- # ...rpc client should send the request, then call event_bus.dispatch(APIResponseEvent(...)) when it gets a response ...
+# Search all history, wait up to 5s for future
+event = await bus.find(ResponseEvent, past=True, future=5)
+
+# Search last 5s of history, wait forever
+event = await bus.find(ResponseEvent, past=5, future=True)
+
+# Search last 5s of history, wait up to 5s
+event = await bus.find(ResponseEvent, past=5, future=5)
+
+# Search all history only, don't wait (instant)
+event = await bus.find(ResponseEvent, past=True, future=False)
- # wait for the response event to be fired by the RPC client
- is_our_response = lambda response_event: response_event.request_id == request_event.request_id
- is_succesful = lambda response_event: response_event.invoice_id == event.invoice_id and response_event.invoice_url
- try:
- response_event: APIResponseEvent = await bus.expect(
- APIResponseEvent, # wait for events of this type (also accepts str name)
- include=lambda e: is_our_response(e) and is_succesful(e), # only include events that match a certain filter func
- exclude=lambda e: e.status != 'retrying', # optionally exclude certain events, overrides include
- timeout=30, # raises asyncio.TimeoutError if no match is seen within 30sec
- )
- except TimeoutError:
- await bus.dispatch(TimedOutError(msg='timed out while waiting for response from server', request_id=request_event.id))
+# Wait up to 5s for future only (like expect())
+event = await bus.find(ResponseEvent, past=False, future=5)
- return response_event.invoice_url
+# With custom filter
+event = await bus.find(ResponseEvent, where=lambda e: e.request_id == my_id, future=5)
+```
+
+#### Finding Child Events
+
+When you dispatch an event that triggers child events, use `child_of` to find specific descendants:
+
+```python
+# Dispatch a parent event that triggers child events
+nav_event = await bus.dispatch(NavigateToUrlEvent(url="https://example.com"))
-event_bus.on(GenerateInvoiceEvent, on_generate_invoice_pdf)
+# Find a child event (already fired while NavigateToUrlEvent was being handled)
+new_tab = await bus.find(TabCreatedEvent, child_of=nav_event, past=5)
+if new_tab:
+ print(f"New tab created: {new_tab.tab_id}")
```
+This solves race conditions where child events fire before you start waiting for them.
+
> [!IMPORTANT]
-> `expect()` resolves when the event is first *dispatched* to the `EventBus`, not when it completes. `await response_event` to get the completed event.
+> `find()` resolves when the event is first *dispatched* to the `EventBus`, not when it completes. Use `await event` to wait for handlers to finish.
+> If no match is found (or future timeout elapses), `find()` returns `None`.
+
+
+
+### π Event Debouncing
+
+Avoid re-running expensive work by reusing recent events. The `find()` method makes debouncing simple:
+
+```python
+# Simple debouncing: reuse event from last 10 seconds, or dispatch new
+event = (
+ bus.find(ScreenshotEvent, past=10, future=False) # Check last 10s of history (instant)
+ or await bus.dispatch(ScreenshotEvent())
+)
+
+# Advanced: check history, wait briefly for new event to appear, fallback to dispatch new event
+event = (
+ await bus.find(SyncEvent, past=True, future=False) # Check all history (instant)
+ or await bus.find(SyncEvent, past=False, future=5) # Wait up to 5s for in-flight
+ or await bus.dispatch(SyncEvent()) # Fallback: dispatch new
+)
+```
@@ -412,12 +467,75 @@ email_list = await event_bus.dispatch(FetchInboxEvent(account_id='124', ...)).ev
+### π§΅ ContextVar Propagation
+
+ContextVars set before `dispatch()` are automatically propagated to event handlers. This is essential for request-scoped context like request IDs, user sessions, or tracing spans:
+
+```python
+from contextvars import ContextVar
+
+# Define your context variables
+request_id: ContextVar[str] = ContextVar('request_id', default='')
+user_id: ContextVar[str] = ContextVar('user_id', default='')
+
+async def handler(event: MyEvent) -> str:
+ # Handler sees the context values that were set before dispatch()
+ print(f"Request: {request_id.get()}, User: {user_id.get()}")
+ return "done"
+
+bus.on(MyEvent, handler)
+
+# Set context before dispatch (e.g., in FastAPI middleware)
+request_id.set('req-12345')
+user_id.set('user-abc')
+
+# Handler will see request_id='req-12345' and user_id='user-abc'
+await bus.dispatch(MyEvent())
+```
+
+**Context propagates through nested handlers:**
+
+```python
+async def parent_handler(event: ParentEvent) -> str:
+ # Context is captured at dispatch time
+ print(f"Parent sees: {request_id.get()}") # 'req-12345'
+
+ # Child events inherit the same context
+ await bus.dispatch(ChildEvent())
+ return "parent_done"
+
+async def child_handler(event: ChildEvent) -> str:
+ # Child also sees the original dispatch context
+ print(f"Child sees: {request_id.get()}") # 'req-12345'
+ return "child_done"
+```
+
+**Context isolation between dispatches:**
+
+Each dispatch captures its own context snapshot. Concurrent dispatches with different context values are properly isolated:
+
+```python
+request_id.set('req-A')
+event_a = bus.dispatch(MyEvent()) # Handler A sees 'req-A'
+
+request_id.set('req-B')
+event_b = bus.dispatch(MyEvent()) # Handler B sees 'req-B'
+
+await event_a # Still sees 'req-A'
+await event_b # Still sees 'req-B'
+```
+
+> [!NOTE]
+> Context is captured at `dispatch()` time, not when the handler executes. This ensures handlers see the context from the call site, even if the event is processed later from a queue.
+
+
+
### π§Ή Memory Management
EventBus includes automatic memory management to prevent unbounded growth in long-running applications:
```python
-# Create a bus with memory limits (default: 50 events)
+# Create a bus with memory limits (default: 100 events)
bus = EventBus(max_history_size=100) # Keep max 100 events in history
# Or disable memory limits for unlimited history
@@ -477,11 +595,25 @@ await bus.dispatch(DataEvent())
Persist events automatically to a `jsonl` file for future replay and debugging:
```python
+from pathlib import Path
+
+from bubus import EventBus, SQLiteHistoryMirrorMiddleware
+from bubus.middlewares import LoggerEventBusMiddleware, WALEventBusMiddleware
+
# Enable WAL event log persistence (optional)
-bus = EventBus(name='MyBus', wal_path='./events.jsonl')
+bus = EventBus(
+ name='MyBus',
+ middlewares=[
+ SQLiteHistoryMirrorMiddleware('./events.sqlite'),
+ WALEventBusMiddleware('./events.jsonl'),
+ LoggerEventBusMiddleware('./events.log'),
+ ],
+)
+
+# LoggerEventBusMiddleware defaults to stdout-only logging if no file path is provided
# All completed events are automatically appended as JSON lines to the end
-bus.dispatch(SecondEventAbc(some_key="banana"))
+await bus.dispatch(SecondEventAbc(some_key="banana"))
```
`./events.jsonl`:
@@ -507,17 +639,43 @@ The main event bus class that manages event processing and handler execution.
```python
EventBus(
name: str | None = None,
- wal_path: Path | str | None = None,
parallel_handlers: bool = False,
- max_history_size: int | None = 50
+ max_history_size: int | None = 50,
+ middlewares: Sequence[EventBusMiddleware | type[EventBusMiddleware]] | None = None,
)
```
**Parameters:**
- `name`: Optional unique name for the bus (auto-generated if not provided)
-- `wal_path`: Path for write-ahead logging of events to a `jsonl` file (optional)
- `parallel_handlers`: If `True`, handlers run concurrently for each event, otherwise serially if `False` (the default)
+- `middlewares`: Optional list of `EventBusMiddleware` subclasses or instances that hook into handler execution for analytics, logging, retries, etc.
+
+Handler middlewares subclass `EventBusMiddleware` and override whichever lifecycle hooks they need:
+
+```python
+from bubus.middlewares import EventBusMiddleware
+
+class AnalyticsMiddleware(EventBusMiddleware):
+ async def process_handler_start(self, eventbus, event, event_result):
+ await analytics_bus.dispatch(HandlerStartedAnalyticsEvent(event_id=event_result.event_id))
+
+ async def process_handler_end(self, eventbus, event, event_result):
+ await analytics_bus.dispatch(HandlerCompletedAnalyticsEvent(event_id=event_result.event_id))
+
+ async def process_handler_exception(self, eventbus, event, event_result, error):
+ await analytics_bus.dispatch(HandlerCompletedAnalyticsEvent(event_id=event_result.event_id, error=error))
+```
+
+Middlewares can observe or mutate the `EventResult` at each step, dispatch additional events, or trigger other side effects (metrics, retries, auth checks, etc.).
+
+Pair that with the built-in `SQLiteHistoryMirrorMiddleware` to mirror every event and handler transition into append-only `events_log` and `event_results_log` tables, making it easy to inspect or audit the bus state:
+
+```python
+from bubus import EventBus, SQLiteHistoryMirrorMiddleware
+
+bus = EventBus(middlewares=[SQLiteHistoryMirrorMiddleware('./events.sqlite')])
+```
- `max_history_size`: Maximum number of events to keep in history (default: 50, None = unlimited)
#### `EventBus` Properties
@@ -554,9 +712,77 @@ result = await event # await the pending Event to get the completed Event
**Note:** When `max_history_size` is set, EventBus enforces a hard limit of 100 pending events (queue + processing) to prevent runaway memory usage. Dispatch will raise `RuntimeError` if this limit is exceeded.
-##### `expect(event_type: str | Type[BaseEvent], timeout: float | None=None, predicate: Callable[[BaseEvent], bool]=None) -> BaseEvent`
+##### `query(event_type: str | Type[BaseEvent], *, include: Callable[[BaseEvent], bool] | None=None, exclude: Callable[[BaseEvent], bool] | None=None, since: timedelta | float | int | None=None) -> BaseEvent | None`
+
+Return the most recently completed event in history that matches the type and optional predicates. Returns `None` if nothing qualifies.
+
+```python
+recent_sync = await bus.query(
+ SyncEvent,
+ since=timedelta(seconds=30),
+ include=lambda e: e.account_id == account_id,
+)
+
+if recent_sync is not None:
+ print('We already synced recently, skipping')
+```
+
+##### `find(event_type: str | Type[BaseEvent], *, where: Callable[[BaseEvent], bool]=None, child_of: BaseEvent | None=None, past: bool | float=True, future: bool | float=True) -> BaseEvent | None`
+
+Find an event matching criteria in history and/or future. This is the recommended unified method for event lookup.
+
+**Parameters:**
+
+- `event_type`: The event type string or model class to find
+- `where`: Predicate function for filtering (default: matches all)
+- `child_of`: Only match events that are descendants of this parent event
+- `past`: Controls history search behavior (default: `True`)
+ - `True`: search all history
+ - `False`: skip history search
+ - `float`: search events from last N seconds only
+- `future`: Controls future wait behavior (default: `True`)
+ - `True`: wait forever for matching event
+ - `False`: don't wait for future events
+ - `float`: wait up to N seconds for matching event
+
+```python
+# Search all history, wait up to 5s for future
+event = await bus.find(ResponseEvent, past=True, future=5)
+
+# Search last 5s of history, wait forever
+event = await bus.find(ResponseEvent, past=5, future=True)
+
+# Search last 5s of history, wait up to 5s
+event = await bus.find(ResponseEvent, past=5, future=5)
+
+# Search all history only, don't wait (instant)
+event = await bus.find(ResponseEvent, past=True, future=False)
-Wait for a specific event to occur.
+# Wait up to 5s for future only (ignore history)
+event = await bus.find(ResponseEvent, past=False, future=5)
+
+# Find child of a specific parent event
+child = await bus.find(ChildEvent, child_of=parent_event, future=5)
+
+# With custom filter
+event = await bus.find(ResponseEvent, where=lambda e: e.status == 'success', future=5)
+```
+
+##### `expect(event_type: str | Type[BaseEvent], *, include: Callable=None, exclude: Callable=None, timeout: float | None=None, past: bool | float=False, child_of: BaseEvent | None=None) -> BaseEvent | None`
+
+Wait for a specific event to occur. This is a backwards-compatible wrapper around `find()`.
+
+**Parameters:**
+
+- `event_type`: The event type string or model class to wait for
+- `include`: Filter function that must return `True` for the event to match
+- `exclude`: Filter function that must return `False` for the event to match
+- `timeout`: Maximum time to wait in seconds (None = wait forever). Maps to `future` parameter of `find()`.
+- `past`: Controls history search behavior (default: `False`)
+ - `True`: search all history first
+ - `False`: skip history search
+ - `float`: search events from last N seconds
+- `child_of`: Only match events that are descendants of this parent event
```python
# Wait for any UserEvent
@@ -565,8 +791,39 @@ event = await bus.expect('UserEvent', timeout=30)
# Wait with custom filter
event = await bus.expect(
'UserEvent',
- predicate=lambda e: e.user_id == 'specific_user'
+ include=lambda e: e.user_id == 'specific_user',
+ timeout=30,
)
+
+# Search history first, then wait
+event = await bus.expect('UserEvent', past=True, timeout=30)
+
+# Search last 10 seconds of history, then wait
+event = await bus.expect('UserEvent', past=10, timeout=30)
+
+# Find child event
+child = await bus.expect(ChildEvent, child_of=parent_event, timeout=5)
+
+if event is None:
+ print('No matching event arrived within 30 seconds')
+```
+
+##### `event_is_child_of(event: BaseEvent, ancestor: BaseEvent) -> bool`
+
+Check if event is a descendant of ancestor (child, grandchild, etc.).
+
+```python
+if bus.event_is_child_of(child_event, parent_event):
+ print("child_event is a descendant of parent_event")
+```
+
+##### `event_is_parent_of(event: BaseEvent, descendant: BaseEvent) -> bool`
+
+Check if event is an ancestor of descendant (parent, grandparent, etc.).
+
+```python
+if bus.event_is_parent_of(parent_event, child_event):
+ print("parent_event is an ancestor of child_event")
```
##### `wait_until_idle(timeout: float | None=None)`
@@ -606,7 +863,7 @@ class BaseEvent(BaseModel, Generic[T_EventResultType]):
# Framework-managed fields
event_type: str # Defaults to class name
event_id: str # Unique UUID7 identifier, auto-generated if not provided
- event_timeout: float = 60.0 # Maximum execution in seconds for each handler
+ event_timeout: float = 300.0 # Maximum execution in seconds for each handler
event_schema: str # Module.Class@version (auto-set based on class & LIBRARY_VERSION env var)
event_parent_id: str # Parent event ID (auto-set)
event_path: list[str] # List of bus names traversed (auto-set)
@@ -626,7 +883,7 @@ class BaseEvent(BaseModel, Generic[T_EventResultType]):
#### `BaseEvent` Properties
-- `event_status`: `Literal['pending', 'started', 'complete']` Event status
+- `event_status`: `Literal['pending', 'started', 'completed']` Event status
- `event_started_at`: `datetime` When first handler started processing
- `event_completed_at`: `datetime` When all handlers completed processing
- `event_children`: `list[BaseEvent]` Get any child events emitted during handling of this event
@@ -762,6 +1019,17 @@ long_lists = await event.event_results_flat_list(include=lambda r: isinstance(r.
all_items = await event.event_results_flat_list(raise_if_any=False, raise_if_none=False)
```
+##### `event_create_pending_results(handlers: dict[str, EventHandler], eventbus: EventBus | None = None, timeout: float | None = None) -> dict[str, EventResult]`
+
+Create (or reset) the `EventResult` placeholders for the provided handlers. The `EventBus` uses this internally before it begins executing handlers so that the event's state is immediately visible. Advanced users can call it when coordinating handler execution manually.
+
+```python
+applicable_handlers = bus._get_applicable_handlers(event) # internal helper shown for illustration
+pending_results = event.event_create_pending_results(applicable_handlers, eventbus=bus)
+
+assert all(result.status == 'pending' for result in pending_results.values())
+```
+
##### `event_bus` (property)
Shortcut to get the `EventBus` that is currently processing this event. Can be used to avoid having to pass an `EventBus` instance to your handlers.
@@ -785,7 +1053,7 @@ async def some_handler(event: MyEvent):
The placeholder object that represents the pending result from a single handler executing an event.
`Event.event_results` contains a `dict[PythonIdStr, EventResult]` in the shape of `{handler_id: EventResult()}`.
-You shouldn't need to ever directly use this class, it's an internal wrapper to track pending and completed results from each handler within `BaseEvent.event_results`.
+You generally won't interact with this class directlyβthe bus instantiates and updates it for youβbut its API is documented here for advanced integrations and custom dispatch loops.
#### `EventResult` Fields
@@ -799,12 +1067,12 @@ class EventResult(BaseModel):
status: str # 'pending', 'started', 'completed', 'error'
result: Any # Handler return value
- error: str | None # Error message if failed
+ error: BaseException | None # Captured exception if the handler failed
started_at: datetime # When handler started
completed_at: datetime # When handler completed
timeout: float # Handler timeout in seconds
- child_events: list[BaseEvent] # list of child events emitted during handler execution
+ event_children: list[BaseEvent] # child events emitted during handler execution
```
#### `EventResult` Methods
@@ -818,6 +1086,9 @@ handler_result = event.event_results['handler_id']
value = await handler_result # Returns result or raises an exception if handler hits an error
```
+- `execute(event, handler, *, eventbus, timeout, enter_handler_context, exit_handler_context, format_exception_for_log)`
+ Low-level helper that runs the handler, updates timing/status fields, captures errors, and notifies its completion signal. `EventBus.execute_handler()` delegates to this; you generally only need it when building a custom bus or integrating the event system into another dispatcher.
+
---
## π§΅ Advanced Concurrency Control
@@ -966,6 +1237,7 @@ uv run pytest tests/test_eventbus.py
- https://www.cosmicpython.com/book/chapter_08_events_and_message_bus.html#message_bus_diagram βοΈ
- https://developer.mozilla.org/en-US/docs/Web/API/EventTarget βοΈ
+- https://github.com/sindresorhus/emittery βοΈ (equivalent for JS), https://github.com/EventEmitter2/EventEmitter2, https://github.com/vitaly-t/sub-events
- https://github.com/pytest-dev/pluggy βοΈ
- https://github.com/teamhide/fastapi-event βοΈ
- https://github.com/ethereum/lahja βοΈ
diff --git a/bubus-ts/.prettierignore b/bubus-ts/.prettierignore
new file mode 100644
index 0000000..849ddff
--- /dev/null
+++ b/bubus-ts/.prettierignore
@@ -0,0 +1 @@
+dist/
diff --git a/bubus-ts/README.md b/bubus-ts/README.md
new file mode 100644
index 0000000..95cc54b
--- /dev/null
+++ b/bubus-ts/README.md
@@ -0,0 +1,558 @@
+# bubus-ts: Python vs JS Differences (and the tricky parts)
+
+This README only covers the differences between the Python implementation and this TypeScript port, plus the
+gotchas we uncovered while matching behavior. It intentionally does **not** re-document the full TS API surface.
+
+## Key Differences vs Python
+
+### 1) Awaiting events: `event.done()` instead of `await event`
+
+- Python: `await event` waits for handlers and can jump the queue when awaited inside a handler.
+- TS: use `await event.done()` for the same behavior.
+- Outside a handler, `done()` just waits for completion (it does not jump the queue).
+- Inside a handler, `done()` triggers immediate processing (queue jump) on **all buses** where the event is queued.
+
+### 2) Cross-bus queue jump (forwarding)
+
+- Python uses a global re-entrant lock to let awaited events process immediately on every bus where they appear.
+- TS optionally uses `AsyncLocalStorage` on Node.js (auto-detected) to capture dispatch context, but falls back gracefully in browsers.
+- `EventBus._all_instances` + the `LockManager` pause mechanism pauses each runloop and processes the same event immediately across buses.
+
+### 3) `event.bus` is a BusScopedEvent view
+
+- In Python, `event.event_bus` is dynamic (contextvars).
+- In TS, `event.bus` is provided by a **BusScopedEvent** (a Proxy over the original event).
+- That proxy injects a bus-bound `emit/dispatch` to ensure correct parent/child tracking.
+
+### 4) Monotonic timestamps
+
+- JS `Date.now()` is not strictly monotonic at millisecond granularity.
+- To keep FIFO tests stable, we generate strictly increasing timestamps via `BaseEvent.nextTimestamp()` (returns `{ date, isostring, ts }`).
+
+### 5) No middleware, no WAL, no SQLite mirrors
+
+- Those Python features were intentionally dropped for the JS version.
+
+### 6) Default timeouts come from the EventBus
+
+- `BaseEvent.event_timeout` defaults to `null`.
+- When dispatched, `EventBus` applies its default `event_timeout` (60s unless configured).
+- You can set `{ event_timeout: null }` on the bus to disable timeouts entirely.
+- Slow handler warnings fire after `event_handler_slow_timeout` (default: `30s`). Slow event warnings fire after `event_slow_timeout` (default: `300s`).
+
+## EventBus Options
+
+All options are passed to `new EventBus(name, options)`.
+
+- `max_history_size?: number | null` (default: `100`)
+ - Max number of events kept in history. Set to `null` for unlimited history.
+- `event_concurrency?: "global-serial" | "bus-serial" | "parallel" | "auto"` (default: `"bus-serial"`)
+ - Controls how many **events** can be processed at a time.
+ - `"global-serial"` enforces FIFO across all buses.
+ - `"bus-serial"` enforces FIFO per bus, allows cross-bus overlap.
+ - `"parallel"` allows events to process concurrently.
+ - `"auto"` uses the bus default (mostly useful for overrides).
+- `event_handler_concurrency?: "global-serial" | "bus-serial" | "parallel" | "auto"` (default: `"bus-serial"`)
+ - Controls how many **handlers** run at once for each event.
+ - Same semantics as `event_concurrency`, but applied to handler execution.
+- `event_timeout?: number | null` (default: `60`)
+ - Default handler timeout in seconds, applied when `event.event_timeout` is `null`.
+ - Set to `null` to disable timeouts globally for the bus.
+- `event_handler_slow_timeout?: number | null` (default: `30`)
+ - Warn after this many seconds for slow handlers.
+ - Only warns when the handler's timeout is `null` or greater than this value.
+ - Set to `null` to disable slow handler warnings.
+- `event_slow_timeout?: number | null` (default: `300`)
+ - Warn after this many seconds for slow event processing.
+ - Set to `null` to disable slow event warnings.
+
+## Concurrency Overrides and Precedence
+
+You can override concurrency per event and per handler:
+
+```ts
+const FastEvent = BaseEvent.extend('FastEvent', {
+ payload: z.string(),
+})
+
+// Per-event override (highest precedence)
+const event = FastEvent({
+ payload: 'x',
+ event_concurrency: 'parallel',
+ event_handler_concurrency: 'parallel',
+})
+
+// Per-handler override (lower precedence)
+bus.on(FastEvent, handler, { event_handler_concurrency: 'parallel' })
+```
+
+Precedence order (highest β lowest):
+
+1. Event instance overrides (`event_concurrency`, `event_handler_concurrency`)
+2. Handler options (`event_handler_concurrency`)
+3. Bus defaults (`event_concurrency`, `event_handler_concurrency`)
+
+`"auto"` resolves to the bus default.
+
+## Handler Options
+
+Handlers can be configured at registration time:
+
+```ts
+bus.on(SomeEvent, handler, {
+ event_handler_concurrency: 'parallel',
+ handler_timeout: 10, // per-handler timeout in seconds
+})
+```
+
+- `event_handler_concurrency` allows per-handler concurrency overrides.
+- `handler_timeout` sets a per-handler timeout in seconds (overrides the bus default when lower).
+
+## TypeScript Return Type Enforcement (Edge Cases)
+
+TypeScript can only enforce handler return types when the event type is inferable at compile time.
+
+- `bus.on(EventFactoryOrClass, handler)`:
+ - Return values are type-checked against the event's `event_result_schema` (if defined).
+ - `undefined` (or no return) is always allowed.
+- `bus.on('SomeEventName', handler)`:
+ - Return type checking is best-effort only (treated as unknown in typing).
+ - Use class/factory keys when you want compile-time return-shape enforcement.
+- `bus.on('*', handler)`:
+ - Return type checking is intentionally loose (best-effort only), because wildcard handlers may receive many event types, including forwarded events from other buses.
+ - In practice, wildcard handlers are expected to be side-effect/forwarding handlers and usually return `undefined`.
+
+Runtime behavior is still consistent across all key styles:
+
+- If an event has `event_result_schema` and a handler returns a non-`undefined` value, that value is validated at runtime.
+- If the handler returns `undefined`, schema validation is skipped and the result is accepted.
+
+## Throughput + Memory Behavior (Current)
+
+This section documents the current runtime profile and the important edge cases. It is intentionally conservative:
+we describe what is enforced today, not theoretical best-case behavior.
+
+### Throughput model
+
+- Baseline throughput in tests is gated at `<30s` for:
+ - `50k events within reasonable time`
+ - `50k events with ephemeral on/off handler registration across 2 buses`
+ - `500 ephemeral buses with 100 events each`
+- The major hot-path operations are linear in collection sizes:
+ - Per event, handler matching is `O(total handlers on bus)` (`exact` scan + `*` scan).
+ - `.off()` is `O(total handlers on bus)` for matching/removal.
+ - Queue-jump (`await event.done()` inside handlers) does cross-bus discovery by walking `event_path` and iterating `EventBus._all_instances`, so cost grows with buses and forwarding depth.
+- `waitUntilIdle()` is best used at batch boundaries, not per event:
+ - Idle checks call `isIdle()`, which scans `event_history` and handler results.
+ - There is a fast-path that skips idle scans when no idle waiters exist, which keeps normal dispatch/complete flows fast even with large history.
+- Concurrency settings are a direct throughput limiter:
+ - `global-serial` and `bus-serial` intentionally serialize work.
+ - `parallel` increases throughput but can increase transient memory if producers outpace consumers.
+
+### Memory model
+
+- Per bus, strong references are held for:
+ - `handlers`
+ - `pending_event_queue`
+ - `in_flight_event_ids`
+ - `event_history` (bounded by `max_history_size`, or unbounded if `null`)
+ - active `find()` waiters until match/timeout
+- Per event, retained state includes:
+ - `event_results` (per-handler result objects)
+ - descendant links in `event_results[].event_children`
+- History trimming behavior:
+ - Completed events are evicted first (oldest first).
+ - If still over limit, oldest remaining events are dropped even if pending, and a warning is logged.
+ - Eviction calls `event._gc()` to clear internal references (`event_results`, child arrays, bus/context pointers).
+- Memory is not strictly bounded by only `pending_queue_size + max_history_size`:
+ - A retained parent event can hold references to many children/grandchildren via `event_children`.
+ - So effective retained memory can exceed a simple `event_count * avg_event_size` bound in high fan-out trees.
+- `destroy()` is recommended for deterministic cleanup, but not required for GC safety:
+ - `_all_instances` is WeakRef-based, so unreferenced buses can be collected without calling `.destroy()`.
+ - There is a GC regression test for this (`unreferenced buses with event history are garbage collected without destroy()`).
+- `heapUsed` vs `rss`:
+ - `heapUsed` returning near baseline after GC is the primary leak signal in tests.
+ - `rss` can stay elevated due to V8 allocator high-water behavior and is not, by itself, a proof of leak.
+
+### Practical guidance for high-load deployments
+
+- Keep `max_history_size` finite in production.
+- Avoid very large wildcard handler sets on hot event types.
+- Avoid calling `waitUntilIdle()` for every single event in large streams; prefer periodic/batch waits.
+- Be aware that very deep/high-fan-out parent-child graphs increase retained memory until parent events are evicted.
+- Use `.destroy()` for explicit lifecycle control in request-scoped or short-lived bus patterns.
+
+## Semaphores (how concurrency is enforced)
+
+We use four semaphores:
+
+- `LockManager.global_event_semaphore`
+- `LockManager.global_handler_semaphore`
+- `bus.locks.bus_event_semaphore`
+- `bus.locks.bus_handler_semaphore`
+
+They are applied centrally when scheduling events and handlers, so concurrency is controlled without scattering
+mutex checks throughout the code.
+
+## Full lifecycle across concurrency modes
+
+Below is the complete execution flow for nested events, including forwarding across buses, and how it behaves
+under different `event_concurrency` / `event_handler_concurrency` configurations.
+
+### 1) Base execution flow (applies to all modes)
+
+**Dispatch (non-awaited):**
+
+1. `dispatch()` normalizes to `original_event`, sets `bus` if missing.
+2. Captures `_dispatch_context` (AsyncLocalStorage if available).
+3. Applies `event_timeout_default` if `event.event_timeout === null`.
+4. If this bus is already in `event_path` (or `bus.hasProcessedEvent()`), return a BusScopedEvent without queueing.
+5. Append bus name to `event_path`, record child relationship (if `event_parent_id` is set).
+6. Add to `event_history` (a `Map` keyed by event id).
+7. Increment `event_pending_bus_count`.
+8. Push to `pending_event_queue` and `startRunloop()`.
+
+**Runloop + processing:**
+
+1. `runloop()` drains `pending_event_queue`.
+2. Adds event id to `in_flight_event_ids`.
+3. Calls `scheduleEventProcessing()` (async).
+4. `scheduleEventProcessing()` selects the event semaphore and runs `processEvent()`.
+5. `processEvent()`:
+ - `event.markStarted()`
+ - `notifyFindListeners(event)`
+ - creates handler results (`event_results`)
+ - runs handlers (respecting handler semaphore)
+ - decrements `event_pending_bus_count` and calls `event.markCompleted(false)` (completes only if all buses and children are done)
+
+### 2) Event concurrency modes (`event_concurrency`)
+
+- **`global-serial`**: events are serialized across _all_ buses using `LockManager.global_event_semaphore`.
+- **`bus-serial`**: events are serialized per bus; different buses can overlap.
+- **`parallel`**: no event semaphore; events can run concurrently on the same bus.
+- **`auto`**: resolves to the bus default.
+
+**Mixed buses:** each bus enforces its own event mode. Forwarding to another bus does not inherit the source busβs mode.
+
+### 3) Handler concurrency modes (`event_handler_concurrency`)
+
+`event_handler_concurrency` controls how handlers run **for a single event**:
+
+- **`global-serial`**: only one handler at a time across all buses using `LockManager.global_handler_semaphore`.
+- **`bus-serial`**: handlers serialize per bus.
+- **`parallel`**: handlers run concurrently for the event.
+- **`auto`**: resolves to the bus default.
+
+**Interaction with event concurrency:**
+Even if events are parallel, handlers can still be serialized:
+`event_concurrency: "parallel"` + `event_handler_concurrency: "bus-serial"` means events start concurrently but handler execution on a bus is serialized.
+
+### 4) Forwarding across buses (non-awaited)
+
+When a handler on Bus A calls `bus_b.dispatch(event)` without awaiting:
+
+- Bus A continues running its handler.
+- Bus B queues and processes the event according to **Bus Bβs** concurrency settings.
+- No coupling unless both buses use the global semaphores.
+
+### 5) Queue-jump (`await event.done()` inside handlers)
+
+When `event.done()` is awaited inside a handler, **queue-jump** happens:
+
+1. `BaseEvent.done()` delegates to `bus.processEventImmediately()`, which detects whether we're inside a handler
+ (via `getActiveHandlerResult()` / `getParentEventResultAcrossAllBusses()`). If not inside a handler, it falls back to `waitForCompletion()`.
+2. `processEventImmediately()` **yields** the parent handler's concurrency semaphore (if held) so child handlers can acquire it.
+3. `processEventImmediately()` removes the event from the pending queue (if present).
+4. `runImmediatelyAcrossBuses()` processes the event immediately on all buses where it is queued.
+5. While immediate processing is active, each affected bus's runloop is paused to prevent unrelated events from running.
+6. Once immediate processing completes, `processEventImmediately()` **re-acquires** the parent handler's semaphore
+ (unless the parent timed out while the child was processing).
+7. Paused runloops resume.
+
+**Important:** queue-jump bypasses event semaphores but **respects** handler semaphores via yield-and-reacquire.
+This means queue-jumped handlers run serially on a `bus-serial` bus, not in parallel.
+
+### 6) Precedence recap
+
+Highest β lowest:
+
+1. Event instance fields (`event_concurrency`, `event_handler_concurrency`)
+2. Handler options (`event_handler_concurrency`)
+3. Bus defaults
+
+`"auto"` always resolves to the bus default.
+
+## Gotchas and Design Choices (What surprised us)
+
+### A) Handler attribution without AsyncLocalStorage
+
+We need to know **which handler emitted a child** to correctly assign:
+
+- `event_parent_id`
+- `event_emitted_by_handler_id`
+- and to attach child events under the correct handler in the tree.
+
+In TS we do this by injecting a **BusScopedEvent** into handlers, which captures the active handler id and
+propagates it via `event_emitted_by_handler_id`. This keeps parentage deterministic even with nested awaits.
+
+### B) Why runloop pausing exists
+
+When an event is awaited inside a handler, the event must **jump the queue**. If the runloop continues normally,
+it could process unrelated events ("overshoot"), breaking FIFO guarantees.
+
+The `LockManager` pause mechanism (`requestPause`/`waitUntilRunloopResumed`) pauses the runloop while we run the awaited
+event immediately. Once the queue-jump completes, the runloop resumes in FIFO order. This matches the Python behavior.
+
+### C) BusScopedEvent: why it exists and how it works
+
+Forwarding exposes a subtle bug: if you pass the **same event object** to another bus, a naive implementation
+can mutate `event.bus` mid-handler and break parent-child tracking.
+
+To prevent that:
+
+- Handlers always receive a **BusScopedEvent** (Proxy of the original event).
+- Its `bus` property is a proxy over the real `EventBus`.
+- That proxy intercepts `emit/dispatch` to set `event_parent_id` and attach children to the correct handler.
+- The original event object is still the canonical one stored in history.
+
+### D) Cross-bus immediate processing (forwarding + awaiting)
+
+When you `await event.done()` inside a handler:
+
+- the system finds all buses that have this event queued (using `EventBus._all_instances` + `event_path`)
+- pauses their runloops
+- processes the event immediately on each bus
+- then resumes the runloops
+
+This gives the same "awaited events jump the queue" semantics as Python, but without a global lock.
+
+### E) Why `event.bus` is required for `done()`
+
+`done()` is the signal to run an event immediately when called inside a handler. Without a bus, we can't
+perform the queue jump, so `done()` throws if no bus is attached.
+
+## Summary
+
+The core contract is preserved:
+
+- FIFO order
+- child event tracking
+- forwarding
+- await-inside-handler queue jump
+
+But the **implementation details are different** because JS needs browser compatibility and lacks Python's
+contextvars + asyncio primitives. The `LockManager` (runloop pause + semaphore coordination), `HandlerLock`
+(yield-and-reacquire), and `BusScopedEvent` proxy are the key differences that make the behavior match in practice.
+
+---
+
+## `retry()` Decorator
+
+`retry()` adds retry logic and optional semaphore-based concurrency limiting to any async function.
+
+### Why retry is a handler-level concept
+
+Retry and timeout belong on the **handler**, not on `emit()` or `done()`:
+
+- **Handlers fail, events don't.** An event has no error state β it's a message. Individual handlers
+ produce errors, timeouts, and exceptions that may need retrying. The handler knows *why* it failed
+ and whether retrying makes sense.
+
+- **Replayability.** When you replay an event log, each emit should produce exactly one event. If retry
+ lives on the handler, the log records one emit β one handler invocation β one result. The retry
+ attempts are invisible implementation details. If retry lives on `emit()`, the log contains multiple
+ separate events for the same logical operation, making replays non-deterministic.
+
+- **Separation of concerns.** Event-level concurrency (`event_concurrency`) and handler-level concurrency
+ (`event_handler_concurrency`) are bus-level scheduling concerns. Retry/timeout/semaphore limiting are
+ handler-level resilience concerns. They compose orthogonally β don't mix them.
+
+### Recommended pattern: `@retry()` on class methods
+
+```ts
+import { retry, EventBus, BaseEvent } from 'bubus'
+
+class ScreenshotService {
+ constructor(private bus: InstanceType) {
+ bus.on(ScreenshotRequestEvent, this.on_ScreenshotRequest.bind(this))
+ }
+
+ @retry({
+ max_attempts: 4,
+ retry_on_errors: [/timeout/i],
+ timeout: 5,
+ semaphore_scope: 'global',
+ semaphore_name: 'Screenshots',
+ semaphore_limit: 2,
+ })
+ async on_ScreenshotRequest(event: InstanceType): Promise {
+ // At most 2 concurrent screenshot operations globally.
+ // Each attempt times out after 5s. Up to 4 total attempts.
+ // Only retries on timeout-related errors.
+ return await takeScreenshot(event.data.url)
+ }
+}
+
+// Emit side stays clean β no retry/timeout concerns
+const event = bus.emit(ScreenshotRequestEvent({ url: 'https://example.com' }))
+await event.done()
+```
+
+This is the primary supported pattern. The `@retry()` decorator handles:
+- **Retry logic**: max attempts, backoff, error filtering
+- **Per-attempt timeout**: each attempt gets its own deadline
+- **Concurrency limiting**: semaphore-based, with global/class/instance scoping
+
+The emit site just dispatches events and awaits completion β it doesn't know or care about retries.
+
+### Also works: inline HOF for simple handlers
+
+```ts
+// For one-off handlers that don't need a class
+bus.on(MyEvent, retry({ max_attempts: 3, timeout: 10 })(async (event) => {
+ await riskyOperation(event.data)
+}))
+```
+
+### Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `max_attempts` | `number` | `1` | Total attempts including the initial call. `1` = no retry, `3` = up to 2 retries. |
+| `retry_after` | `number` | `0` | Seconds to wait between retries. |
+| `retry_backoff_factor` | `number` | `1.0` | Multiplier applied to `retry_after` after each attempt. `2.0` = exponential backoff. |
+| `retry_on_errors` | `(ErrorClass \| string \| RegExp)[]` | `undefined` | Only retry when the error matches a matcher. Accepts class constructors (`instanceof`), strings (matched against `error.name`), or RegExp (tested against `String(error)`). Can be mixed: `[TypeError, 'NetworkError', /timeout/i]`. `undefined` = retry on any error. |
+| `timeout` | `number \| null` | `undefined` | Per-attempt timeout in seconds. Throws `RetryTimeoutError` if exceeded. |
+| `semaphore_limit` | `number \| null` | `undefined` | Max concurrent executions sharing this semaphore. |
+| `semaphore_name` | `string \| null` | fn name | Semaphore identifier. Functions with the same name share the same slot pool. |
+| `semaphore_lax` | `boolean` | `true` | If `true`, proceed without concurrency limit when semaphore acquisition times out. |
+| `semaphore_scope` | `'global' \| 'class' \| 'instance'` | `'global'` | `'global'`: one semaphore for all calls. `'class'`: one per class (keyed by `constructor.name`). `'instance'`: one per object instance (keyed by WeakMap identity). `'class'`/`'instance'` require `this` to be an object; they fall back to `'global'` for standalone calls. |
+| `semaphore_timeout` | `number \| null` | `undefined` | Max seconds to wait for semaphore. Default: `timeout * max(1, limit - 1)`. |
+
+### Error types
+
+- **`RetryTimeoutError`** β thrown when a single attempt exceeds `timeout`. Has `.timeout_seconds` and `.attempt` fields. Retryable by default (treated like any other error in the retry loop).
+- **`SemaphoreTimeoutError`** β thrown (when `semaphore_lax=false`) if the semaphore cannot be acquired within the timeout. Has `.semaphore_name`, `.semaphore_limit`, `.timeout_seconds` fields.
+
+### Semaphore concurrency control
+
+The semaphore is acquired **once** before the first attempt and held across all retries. This prevents other
+callers from stealing the slot between retry attempts.
+
+```ts
+class ApiService {
+ @retry({
+ max_attempts: 2,
+ semaphore_limit: 3,
+ semaphore_name: 'api_calls',
+ })
+ async callExternalApi(): Promise {
+ // At most 3 concurrent calls across all instances of ApiService
+ return await fetch('https://api.example.com')
+ }
+}
+```
+
+Functions that share a `semaphore_name` share the same slot pool β this is how you limit concurrency across
+different functions that access the same resource.
+
+### Re-entrancy and deadlock prevention
+
+The decorator uses `AsyncLocalStorage` (on Node.js) to track which semaphores are held in the current async
+call stack. When a nested call encounters a semaphore it already holds, it **skips acquisition** and runs
+directly within the parent's slot. This prevents deadlocks in recursive or nested scenarios:
+
+```ts
+const inner = retry({ semaphore_limit: 1, semaphore_name: 'shared' })(async () => 'ok')
+
+const outer = retry({ semaphore_limit: 1, semaphore_name: 'shared' })(async () => {
+ // Without re-entrancy tracking, this would deadlock:
+ // outer holds the semaphore, inner tries to acquire the same one.
+ // With re-entrancy, inner detects 'shared' is already held and skips acquisition.
+ return await inner()
+})
+
+await outer() // works, no deadlock
+```
+
+This also works for recursive calls (a function calling itself) and deeply nested chains (A β B β C all sharing
+a semaphore).
+
+In browsers (no `AsyncLocalStorage`), re-entrancy tracking is unavailable and the decorator gracefully degrades
+to a no-op (no deadlock detection). Avoid recursive/nested calls through the same semaphore in browser
+environments, or use different `semaphore_name` values.
+
+### Interaction with bus concurrency options
+
+`retry()` and the bus's concurrency modes are **orthogonal** and compose together:
+
+- **`event_concurrency`** controls how many events the bus processes at once (via the runloop + event semaphore).
+- **`event_handler_concurrency`** controls how many handlers run concurrently for a single event (via the handler semaphore).
+- **`retry()` semaphores** control how many concurrent invocations of a specific handler are allowed (via a global semaphore registry).
+
+These are separate concerns:
+- Bus concurrency = scheduling (how the bus orders event/handler execution)
+- Retry semaphores = resilience (how individual handlers manage concurrency and failure recovery)
+
+When you use `@retry()` on a bus handler, both layers apply. The execution order is:
+1. Bus acquires the **handler concurrency semaphore** (e.g. `bus-serial`)
+2. `retry()` acquires its own **retry semaphore** (if `semaphore_limit` is set)
+3. The handler function runs (with retries if it throws)
+4. `retry()` releases its semaphore
+5. Bus releases the handler concurrency semaphore
+
+The bus's `handler_timeout` and `retry()`'s `timeout` are independent:
+- `handler_timeout` (set via `bus.on()` options or bus defaults) applies to the **entire** wrapped handler call, including all retry attempts.
+- `retry({ timeout })` applies to **each individual attempt**.
+
+If you need per-attempt timeouts, use `retry({ timeout })`. If you need an overall deadline for the handler
+(including all retries), rely on the bus's `handler_timeout`.
+
+### Discouraged: wrapping `emit()` β `done()` in `retry()`
+
+This pattern is technically supported but **not recommended**:
+
+```ts
+// DON'T DO THIS β retry belongs on the handler, not the emit site.
+const event = await retry({ max_attempts: 4 })(async () => {
+ const ev = bus.emit(ScreenshotRequestEvent({ full_page: false }))
+ await ev.done()
+ if (ev.event_errors.length) throw ev.event_errors[0]
+ return ev
+})()
+```
+
+Why this is worse:
+
+1. **Architecture**: the emit site doesn't know which handler failed or why. The handler is the right
+ place for retry logic because it has the context to decide whether retrying makes sense.
+
+2. **Replayability**: each retry dispatches a **new event**, producing multiple events in the log for
+ one logical operation. On replay, if the handler succeeds on the first attempt, you get a different
+ event topology than the original run. With handler-level retry, the log always shows one emit β one
+ handler result, regardless of how many retry attempts were needed internally.
+
+3. **Determinism**: the same emit may fan out to multiple handlers. Retrying the whole dispatch because
+ one handler failed also re-runs handlers that succeeded β wasteful and potentially side-effectful.
+
+Use the `@retry()` decorator on the handler method instead.
+
+### Differences from the Python `@retry` decorator
+
+| Aspect | Python | TypeScript |
+|--------|--------|------------|
+| **Naming** | `retries=3` (retry count after first attempt) | `max_attempts=1` (total attempts including first) |
+| **Naming** | `wait=3` (seconds between retries) | `retry_after=0` (seconds between retries) |
+| **Naming** | `retry_on` | `retry_on_errors` |
+| **Default retries** | 3 retries (4 total attempts) | 1 attempt (no retries) |
+| **Default delay** | 3 seconds | 0 seconds |
+| **Default timeout** | 5 seconds per attempt | No timeout |
+| **Semaphore scopes** | `'global'`, `'class'`, `'self'`, `'multiprocess'` | `'global'`, `'class'`, `'instance'` (no multiprocess β single-process JS runtime) |
+| **System overload** | Tracks active operations, checks CPU/memory via `psutil` | Not implemented |
+| **Re-entrancy** | Not implemented (relies on Python's GIL + asyncio single-thread) | `AsyncLocalStorage`-based tracking to prevent deadlocks |
+| **Syntax** | `@retry(...)` decorator on `async def` | `@retry({...})` on class methods (TC39 Stage 3), or `retry({...})(fn)` HOF |
+| **Sync functions** | Not supported (async-only) | Supported (wrapper always returns a Promise) |
+
+The TS version intentionally starts with conservative defaults (1 attempt, no delay, no timeout) so that
+`retry()` with no options is a no-op wrapper. The Python version defaults to 3 retries with 3s delay and 5s
+timeout, which is more aggressive.
diff --git a/bubus-ts/eslint.config.js b/bubus-ts/eslint.config.js
new file mode 100644
index 0000000..4783e2a
--- /dev/null
+++ b/bubus-ts/eslint.config.js
@@ -0,0 +1,22 @@
+import ts_parser from '@typescript-eslint/parser'
+import ts_eslint_plugin from '@typescript-eslint/eslint-plugin'
+
+export default [
+ {
+ files: ['**/*.ts'],
+ languageOptions: {
+ parser: ts_parser,
+ parserOptions: {
+ sourceType: 'module',
+ ecmaVersion: 'latest',
+ },
+ },
+ plugins: {
+ '@typescript-eslint': ts_eslint_plugin,
+ },
+ rules: {
+ 'no-unused-vars': 'off',
+ '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
+ },
+ },
+]
diff --git a/bubus-ts/examples/log_tree_demo.ts b/bubus-ts/examples/log_tree_demo.ts
new file mode 100644
index 0000000..a4aaef0
--- /dev/null
+++ b/bubus-ts/examples/log_tree_demo.ts
@@ -0,0 +1,98 @@
+import { z } from 'zod'
+
+import { BaseEvent, EventBus } from '../src/index.js'
+
+const RootEvent = BaseEvent.extend('RootEvent', {
+ url: z.string(),
+ event_result_schema: z.string(),
+ event_result_type: 'string',
+})
+
+const ChildEvent = BaseEvent.extend('ChildEvent', {
+ tab_id: z.string(),
+ event_result_schema: z.string(),
+ event_result_type: 'string',
+})
+
+const GrandchildEvent = BaseEvent.extend('GrandchildEvent', {
+ status: z.string(),
+ event_result_schema: z.string(),
+ event_result_type: 'string',
+})
+
+const delay = (ms: number): Promise =>
+ new Promise((resolve) => {
+ setTimeout(resolve, ms)
+ })
+
+async function main(): Promise {
+ const bus_a = new EventBus('BusA')
+ const bus_b = new EventBus('BusB')
+
+ async function forward_to_bus_b(event: InstanceType): Promise {
+ await delay(20)
+ bus_b.dispatch(event)
+ return 'forwarded_to_bus_b'
+ }
+
+ bus_a.on('*', forward_to_bus_b)
+
+ async function root_fast_handler(event: InstanceType): Promise {
+ await delay(10)
+ const child = event.bus?.emit(ChildEvent({ tab_id: 'tab-123', event_timeout: 0.1 }))
+ if (child) {
+ await child.done()
+ }
+ return 'root_fast_handler_ok'
+ }
+
+ async function root_slow_handler(event: InstanceType): Promise {
+ event.bus?.emit(ChildEvent({ tab_id: 'tab-timeout', event_timeout: 0.1 }))
+ await delay(400)
+ return 'root_slow_handler_timeout'
+ }
+
+ bus_a.on(RootEvent, root_fast_handler)
+ bus_a.on(RootEvent, root_slow_handler)
+
+ async function child_slow_handler(_event: InstanceType): Promise {
+ await delay(150)
+ return 'child_slow_handler_done'
+ }
+
+ async function child_fast_handler(event: InstanceType): Promise {
+ await delay(10)
+ const grandchild = event.bus?.emit(GrandchildEvent({ status: 'ok', event_timeout: 0.05 }))
+ if (grandchild) {
+ await grandchild.done()
+ }
+ return 'child_handler_ok'
+ }
+
+ async function grandchild_fast_handler(): Promise {
+ await delay(5)
+ return 'grandchild_fast_handler_ok'
+ }
+
+ async function grandchild_slow_handler(): Promise {
+ await delay(60)
+ return 'grandchild_slow_handler_timeout'
+ }
+
+ bus_b.on(ChildEvent, child_slow_handler)
+ bus_b.on(ChildEvent, child_fast_handler)
+ bus_b.on(GrandchildEvent, grandchild_fast_handler)
+ bus_b.on(GrandchildEvent, grandchild_slow_handler)
+
+ const root_event = bus_a.dispatch(RootEvent({ url: 'https://example.com', event_timeout: 0.25 }))
+
+ await root_event.done()
+
+ console.log('\n=== BusA logTree ===')
+ console.log(bus_a.logTree())
+
+ console.log('\n=== BusB logTree ===')
+ console.log(bus_b.logTree())
+}
+
+await main()
diff --git a/bubus-ts/package.json b/bubus-ts/package.json
new file mode 100644
index 0000000..67d5406
--- /dev/null
+++ b/bubus-ts/package.json
@@ -0,0 +1,63 @@
+{
+ "name": "bubus",
+ "version": "1.7.3",
+ "description": "Event bus library for browsers and ESM Node.js",
+ "type": "module",
+ "main": "./dist/esm/index.js",
+ "module": "./dist/esm/index.js",
+ "types": "./dist/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/types/index.d.ts",
+ "import": "./dist/esm/index.js",
+ "default": "./dist/esm/index.js"
+ }
+ },
+ "files": [
+ "dist/esm",
+ "dist/types"
+ ],
+ "scripts": {
+ "build": "pnpm run build:esm && pnpm run build:types",
+ "build:esm": "esbuild src/index.ts --bundle --format=esm --platform=neutral --target=es2022 --sourcemap --outdir=dist/esm",
+ "build:types": "tsc -p tsconfig.json --emitDeclarationOnly",
+ "typecheck": "tsc -p tsconfig.json --noEmit",
+ "lint": "pnpm run format:check && eslint . && pnpm run typecheck",
+ "format": "prettier --write .",
+ "format:check": "prettier --check .",
+ "test": "NODE_OPTIONS='--expose-gc' node --expose-gc --test --import tsx tests/**/*.test.ts",
+ "prepack": "pnpm run build",
+ "release:dry-run": "pnpm publish --access public --dry-run --no-git-checks",
+ "release:check": "pnpm run typecheck && pnpm test && pnpm run build"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "MIT",
+ "packageManager": "pnpm@10.23.0",
+ "dependencies": {
+ "uuid": "^11.1.0",
+ "zod": "^4.3.6"
+ },
+ "devDependencies": {
+ "@typescript-eslint/eslint-plugin": "^8.46.0",
+ "@typescript-eslint/parser": "^8.46.0",
+ "esbuild": "^0.27.2",
+ "eslint": "^9.39.2",
+ "prettier": "^3.8.1",
+ "tsx": "^4.20.6",
+ "typescript": "^5.9.3"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/pirate/bbus.git",
+ "directory": "bubus-ts"
+ },
+ "bugs": {
+ "url": "https://github.com/pirate/bbus/issues"
+ },
+ "homepage": "https://github.com/pirate/bbus/tree/main/bubus-ts",
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ }
+}
diff --git a/bubus-ts/pnpm-lock.yaml b/bubus-ts/pnpm-lock.yaml
new file mode 100644
index 0000000..331a564
--- /dev/null
+++ b/bubus-ts/pnpm-lock.yaml
@@ -0,0 +1,1234 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+ .:
+ dependencies:
+ uuid:
+ specifier: ^11.1.0
+ version: 11.1.0
+ zod:
+ specifier: ^4.3.6
+ version: 4.3.6
+ devDependencies:
+ '@typescript-eslint/eslint-plugin':
+ specifier: ^8.46.0
+ version: 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)
+ '@typescript-eslint/parser':
+ specifier: ^8.46.0
+ version: 8.54.0(eslint@9.39.2)(typescript@5.9.3)
+ esbuild:
+ specifier: ^0.27.2
+ version: 0.27.2
+ eslint:
+ specifier: ^9.39.2
+ version: 9.39.2
+ prettier:
+ specifier: ^3.8.1
+ version: 3.8.1
+ tsx:
+ specifier: ^4.20.6
+ version: 4.21.0
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+
+packages:
+ '@esbuild/aix-ppc64@0.27.2':
+ resolution: { integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw== }
+ engines: { node: '>=18' }
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.27.2':
+ resolution: { integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA== }
+ engines: { node: '>=18' }
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.27.2':
+ resolution: { integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA== }
+ engines: { node: '>=18' }
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.27.2':
+ resolution: { integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A== }
+ engines: { node: '>=18' }
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.27.2':
+ resolution: { integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg== }
+ engines: { node: '>=18' }
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.27.2':
+ resolution: { integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA== }
+ engines: { node: '>=18' }
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.27.2':
+ resolution: { integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g== }
+ engines: { node: '>=18' }
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.27.2':
+ resolution: { integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA== }
+ engines: { node: '>=18' }
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.27.2':
+ resolution: { integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw== }
+ engines: { node: '>=18' }
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.27.2':
+ resolution: { integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw== }
+ engines: { node: '>=18' }
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.27.2':
+ resolution: { integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w== }
+ engines: { node: '>=18' }
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.27.2':
+ resolution: { integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg== }
+ engines: { node: '>=18' }
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.27.2':
+ resolution: { integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw== }
+ engines: { node: '>=18' }
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.27.2':
+ resolution: { integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ== }
+ engines: { node: '>=18' }
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.27.2':
+ resolution: { integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA== }
+ engines: { node: '>=18' }
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.27.2':
+ resolution: { integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w== }
+ engines: { node: '>=18' }
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.27.2':
+ resolution: { integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA== }
+ engines: { node: '>=18' }
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.27.2':
+ resolution: { integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw== }
+ engines: { node: '>=18' }
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.27.2':
+ resolution: { integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA== }
+ engines: { node: '>=18' }
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.27.2':
+ resolution: { integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA== }
+ engines: { node: '>=18' }
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.27.2':
+ resolution: { integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg== }
+ engines: { node: '>=18' }
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.27.2':
+ resolution: { integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag== }
+ engines: { node: '>=18' }
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.27.2':
+ resolution: { integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg== }
+ engines: { node: '>=18' }
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.27.2':
+ resolution: { integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg== }
+ engines: { node: '>=18' }
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.27.2':
+ resolution: { integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ== }
+ engines: { node: '>=18' }
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.27.2':
+ resolution: { integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ== }
+ engines: { node: '>=18' }
+ cpu: [x64]
+ os: [win32]
+
+ '@eslint-community/eslint-utils@4.9.1':
+ resolution: { integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== }
+ engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 }
+ peerDependencies:
+ eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
+ '@eslint-community/regexpp@4.12.2':
+ resolution: { integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== }
+ engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 }
+
+ '@eslint/config-array@0.21.1':
+ resolution: { integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+
+ '@eslint/config-helpers@0.4.2':
+ resolution: { integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+
+ '@eslint/core@0.17.0':
+ resolution: { integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+
+ '@eslint/eslintrc@3.3.3':
+ resolution: { integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+
+ '@eslint/js@9.39.2':
+ resolution: { integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+
+ '@eslint/object-schema@2.1.7':
+ resolution: { integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+
+ '@eslint/plugin-kit@0.4.1':
+ resolution: { integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+
+ '@humanfs/core@0.19.1':
+ resolution: { integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== }
+ engines: { node: '>=18.18.0' }
+
+ '@humanfs/node@0.16.7':
+ resolution: { integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== }
+ engines: { node: '>=18.18.0' }
+
+ '@humanwhocodes/module-importer@1.0.1':
+ resolution: { integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== }
+ engines: { node: '>=12.22' }
+
+ '@humanwhocodes/retry@0.4.3':
+ resolution: { integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== }
+ engines: { node: '>=18.18' }
+
+ '@types/estree@1.0.8':
+ resolution: { integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== }
+
+ '@types/json-schema@7.0.15':
+ resolution: { integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== }
+
+ '@typescript-eslint/eslint-plugin@8.54.0':
+ resolution: { integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.54.0
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/parser@8.54.0':
+ resolution: { integrity: sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/project-service@8.54.0':
+ resolution: { integrity: sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/scope-manager@8.54.0':
+ resolution: { integrity: sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+
+ '@typescript-eslint/tsconfig-utils@8.54.0':
+ resolution: { integrity: sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/type-utils@8.54.0':
+ resolution: { integrity: sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/types@8.54.0':
+ resolution: { integrity: sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+
+ '@typescript-eslint/typescript-estree@8.54.0':
+ resolution: { integrity: sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/utils@8.54.0':
+ resolution: { integrity: sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
+
+ '@typescript-eslint/visitor-keys@8.54.0':
+ resolution: { integrity: sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+
+ acorn-jsx@5.3.2:
+ resolution: { integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== }
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ acorn@8.15.0:
+ resolution: { integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== }
+ engines: { node: '>=0.4.0' }
+ hasBin: true
+
+ ajv@6.12.6:
+ resolution: { integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== }
+
+ ansi-styles@4.3.0:
+ resolution: { integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== }
+ engines: { node: '>=8' }
+
+ argparse@2.0.1:
+ resolution: { integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== }
+
+ balanced-match@1.0.2:
+ resolution: { integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== }
+
+ brace-expansion@1.1.12:
+ resolution: { integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== }
+
+ brace-expansion@2.0.2:
+ resolution: { integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== }
+
+ callsites@3.1.0:
+ resolution: { integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== }
+ engines: { node: '>=6' }
+
+ chalk@4.1.2:
+ resolution: { integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== }
+ engines: { node: '>=10' }
+
+ color-convert@2.0.1:
+ resolution: { integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== }
+ engines: { node: '>=7.0.0' }
+
+ color-name@1.1.4:
+ resolution: { integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== }
+
+ concat-map@0.0.1:
+ resolution: { integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== }
+
+ cross-spawn@7.0.6:
+ resolution: { integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== }
+ engines: { node: '>= 8' }
+
+ debug@4.4.3:
+ resolution: { integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== }
+ engines: { node: '>=6.0' }
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ deep-is@0.1.4:
+ resolution: { integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== }
+
+ esbuild@0.27.2:
+ resolution: { integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw== }
+ engines: { node: '>=18' }
+ hasBin: true
+
+ escape-string-regexp@4.0.0:
+ resolution: { integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== }
+ engines: { node: '>=10' }
+
+ eslint-scope@8.4.0:
+ resolution: { integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+
+ eslint-visitor-keys@3.4.3:
+ resolution: { integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== }
+ engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 }
+
+ eslint-visitor-keys@4.2.1:
+ resolution: { integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+
+ eslint@9.39.2:
+ resolution: { integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+ hasBin: true
+ peerDependencies:
+ jiti: '*'
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+
+ espree@10.4.0:
+ resolution: { integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== }
+ engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+
+ esquery@1.7.0:
+ resolution: { integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== }
+ engines: { node: '>=0.10' }
+
+ esrecurse@4.3.0:
+ resolution: { integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== }
+ engines: { node: '>=4.0' }
+
+ estraverse@5.3.0:
+ resolution: { integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== }
+ engines: { node: '>=4.0' }
+
+ esutils@2.0.3:
+ resolution: { integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== }
+ engines: { node: '>=0.10.0' }
+
+ fast-deep-equal@3.1.3:
+ resolution: { integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== }
+
+ fast-json-stable-stringify@2.1.0:
+ resolution: { integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== }
+
+ fast-levenshtein@2.0.6:
+ resolution: { integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== }
+
+ fdir@6.5.0:
+ resolution: { integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== }
+ engines: { node: '>=12.0.0' }
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ file-entry-cache@8.0.0:
+ resolution: { integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== }
+ engines: { node: '>=16.0.0' }
+
+ find-up@5.0.0:
+ resolution: { integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== }
+ engines: { node: '>=10' }
+
+ flat-cache@4.0.1:
+ resolution: { integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== }
+ engines: { node: '>=16' }
+
+ flatted@3.3.3:
+ resolution: { integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== }
+
+ fsevents@2.3.3:
+ resolution: { integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== }
+ engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 }
+ os: [darwin]
+
+ get-tsconfig@4.13.1:
+ resolution: { integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w== }
+
+ glob-parent@6.0.2:
+ resolution: { integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== }
+ engines: { node: '>=10.13.0' }
+
+ globals@14.0.0:
+ resolution: { integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== }
+ engines: { node: '>=18' }
+
+ has-flag@4.0.0:
+ resolution: { integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== }
+ engines: { node: '>=8' }
+
+ ignore@5.3.2:
+ resolution: { integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== }
+ engines: { node: '>= 4' }
+
+ ignore@7.0.5:
+ resolution: { integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== }
+ engines: { node: '>= 4' }
+
+ import-fresh@3.3.1:
+ resolution: { integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== }
+ engines: { node: '>=6' }
+
+ imurmurhash@0.1.4:
+ resolution: { integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== }
+ engines: { node: '>=0.8.19' }
+
+ is-extglob@2.1.1:
+ resolution: { integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== }
+ engines: { node: '>=0.10.0' }
+
+ is-glob@4.0.3:
+ resolution: { integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== }
+ engines: { node: '>=0.10.0' }
+
+ isexe@2.0.0:
+ resolution: { integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== }
+
+ js-yaml@4.1.1:
+ resolution: { integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== }
+ hasBin: true
+
+ json-buffer@3.0.1:
+ resolution: { integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== }
+
+ json-schema-traverse@0.4.1:
+ resolution: { integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== }
+
+ json-stable-stringify-without-jsonify@1.0.1:
+ resolution: { integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== }
+
+ keyv@4.5.4:
+ resolution: { integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== }
+
+ levn@0.4.1:
+ resolution: { integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== }
+ engines: { node: '>= 0.8.0' }
+
+ locate-path@6.0.0:
+ resolution: { integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== }
+ engines: { node: '>=10' }
+
+ lodash.merge@4.6.2:
+ resolution: { integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== }
+
+ minimatch@3.1.2:
+ resolution: { integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== }
+
+ minimatch@9.0.5:
+ resolution: { integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== }
+ engines: { node: '>=16 || 14 >=14.17' }
+
+ ms@2.1.3:
+ resolution: { integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== }
+
+ natural-compare@1.4.0:
+ resolution: { integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== }
+
+ optionator@0.9.4:
+ resolution: { integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== }
+ engines: { node: '>= 0.8.0' }
+
+ p-limit@3.1.0:
+ resolution: { integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== }
+ engines: { node: '>=10' }
+
+ p-locate@5.0.0:
+ resolution: { integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== }
+ engines: { node: '>=10' }
+
+ parent-module@1.0.1:
+ resolution: { integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== }
+ engines: { node: '>=6' }
+
+ path-exists@4.0.0:
+ resolution: { integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== }
+ engines: { node: '>=8' }
+
+ path-key@3.1.1:
+ resolution: { integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== }
+ engines: { node: '>=8' }
+
+ picomatch@4.0.3:
+ resolution: { integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== }
+ engines: { node: '>=12' }
+
+ prelude-ls@1.2.1:
+ resolution: { integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== }
+ engines: { node: '>= 0.8.0' }
+
+ prettier@3.8.1:
+ resolution: { integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== }
+ engines: { node: '>=14' }
+ hasBin: true
+
+ punycode@2.3.1:
+ resolution: { integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== }
+ engines: { node: '>=6' }
+
+ resolve-from@4.0.0:
+ resolution: { integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== }
+ engines: { node: '>=4' }
+
+ resolve-pkg-maps@1.0.0:
+ resolution: { integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== }
+
+ semver@7.7.3:
+ resolution: { integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== }
+ engines: { node: '>=10' }
+ hasBin: true
+
+ shebang-command@2.0.0:
+ resolution: { integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== }
+ engines: { node: '>=8' }
+
+ shebang-regex@3.0.0:
+ resolution: { integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== }
+ engines: { node: '>=8' }
+
+ strip-json-comments@3.1.1:
+ resolution: { integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== }
+ engines: { node: '>=8' }
+
+ supports-color@7.2.0:
+ resolution: { integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== }
+ engines: { node: '>=8' }
+
+ tinyglobby@0.2.15:
+ resolution: { integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== }
+ engines: { node: '>=12.0.0' }
+
+ ts-api-utils@2.4.0:
+ resolution: { integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA== }
+ engines: { node: '>=18.12' }
+ peerDependencies:
+ typescript: '>=4.8.4'
+
+ tsx@4.21.0:
+ resolution: { integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw== }
+ engines: { node: '>=18.0.0' }
+ hasBin: true
+
+ type-check@0.4.0:
+ resolution: { integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== }
+ engines: { node: '>= 0.8.0' }
+
+ typescript@5.9.3:
+ resolution: { integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== }
+ engines: { node: '>=14.17' }
+ hasBin: true
+
+ uri-js@4.4.1:
+ resolution: { integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== }
+
+ uuid@11.1.0:
+ resolution: { integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A== }
+ hasBin: true
+
+ which@2.0.2:
+ resolution: { integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== }
+ engines: { node: '>= 8' }
+ hasBin: true
+
+ word-wrap@1.2.5:
+ resolution: { integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== }
+ engines: { node: '>=0.10.0' }
+
+ yocto-queue@0.1.0:
+ resolution: { integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== }
+ engines: { node: '>=10' }
+
+ zod@4.3.6:
+ resolution: { integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== }
+
+snapshots:
+ '@esbuild/aix-ppc64@0.27.2':
+ optional: true
+
+ '@esbuild/android-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/android-arm@0.27.2':
+ optional: true
+
+ '@esbuild/android-x64@0.27.2':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/darwin-x64@0.27.2':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-arm@0.27.2':
+ optional: true
+
+ '@esbuild/linux-ia32@0.27.2':
+ optional: true
+
+ '@esbuild/linux-loong64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.27.2':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-s390x@0.27.2':
+ optional: true
+
+ '@esbuild/linux-x64@0.27.2':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.27.2':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.27.2':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/sunos-x64@0.27.2':
+ optional: true
+
+ '@esbuild/win32-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/win32-ia32@0.27.2':
+ optional: true
+
+ '@esbuild/win32-x64@0.27.2':
+ optional: true
+
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2)':
+ dependencies:
+ eslint: 9.39.2
+ eslint-visitor-keys: 3.4.3
+
+ '@eslint-community/regexpp@4.12.2': {}
+
+ '@eslint/config-array@0.21.1':
+ dependencies:
+ '@eslint/object-schema': 2.1.7
+ debug: 4.4.3
+ minimatch: 3.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/config-helpers@0.4.2':
+ dependencies:
+ '@eslint/core': 0.17.0
+
+ '@eslint/core@0.17.0':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
+ '@eslint/eslintrc@3.3.3':
+ dependencies:
+ ajv: 6.12.6
+ debug: 4.4.3
+ espree: 10.4.0
+ globals: 14.0.0
+ ignore: 5.3.2
+ import-fresh: 3.3.1
+ js-yaml: 4.1.1
+ minimatch: 3.1.2
+ strip-json-comments: 3.1.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/js@9.39.2': {}
+
+ '@eslint/object-schema@2.1.7': {}
+
+ '@eslint/plugin-kit@0.4.1':
+ dependencies:
+ '@eslint/core': 0.17.0
+ levn: 0.4.1
+
+ '@humanfs/core@0.19.1': {}
+
+ '@humanfs/node@0.16.7':
+ dependencies:
+ '@humanfs/core': 0.19.1
+ '@humanwhocodes/retry': 0.4.3
+
+ '@humanwhocodes/module-importer@1.0.1': {}
+
+ '@humanwhocodes/retry@0.4.3': {}
+
+ '@types/estree@1.0.8': {}
+
+ '@types/json-schema@7.0.15': {}
+
+ '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.54.0(eslint@9.39.2)(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.54.0
+ '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2)(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.2)(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.54.0
+ eslint: 9.39.2
+ ignore: 7.0.5
+ natural-compare: 1.4.0
+ ts-api-utils: 2.4.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.54.0
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.54.0
+ debug: 4.4.3
+ eslint: 9.39.2
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.54.0(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.54.0
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/scope-manager@8.54.0':
+ dependencies:
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/visitor-keys': 8.54.0
+
+ '@typescript-eslint/tsconfig-utils@8.54.0(typescript@5.9.3)':
+ dependencies:
+ typescript: 5.9.3
+
+ '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2)(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.2)(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 9.39.2
+ ts-api-utils: 2.4.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/types@8.54.0': {}
+
+ '@typescript-eslint/typescript-estree@8.54.0(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.54.0(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/visitor-keys': 8.54.0
+ debug: 4.4.3
+ minimatch: 9.0.5
+ semver: 7.7.3
+ tinyglobby: 0.2.15
+ ts-api-utils: 2.4.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/utils@8.54.0(eslint@9.39.2)(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2)
+ '@typescript-eslint/scope-manager': 8.54.0
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
+ eslint: 9.39.2
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/visitor-keys@8.54.0':
+ dependencies:
+ '@typescript-eslint/types': 8.54.0
+ eslint-visitor-keys: 4.2.1
+
+ acorn-jsx@5.3.2(acorn@8.15.0):
+ dependencies:
+ acorn: 8.15.0
+
+ acorn@8.15.0: {}
+
+ ajv@6.12.6:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-json-stable-stringify: 2.1.0
+ json-schema-traverse: 0.4.1
+ uri-js: 4.4.1
+
+ ansi-styles@4.3.0:
+ dependencies:
+ color-convert: 2.0.1
+
+ argparse@2.0.1: {}
+
+ balanced-match@1.0.2: {}
+
+ brace-expansion@1.1.12:
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+
+ brace-expansion@2.0.2:
+ dependencies:
+ balanced-match: 1.0.2
+
+ callsites@3.1.0: {}
+
+ chalk@4.1.2:
+ dependencies:
+ ansi-styles: 4.3.0
+ supports-color: 7.2.0
+
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+
+ color-name@1.1.4: {}
+
+ concat-map@0.0.1: {}
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ deep-is@0.1.4: {}
+
+ esbuild@0.27.2:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.27.2
+ '@esbuild/android-arm': 0.27.2
+ '@esbuild/android-arm64': 0.27.2
+ '@esbuild/android-x64': 0.27.2
+ '@esbuild/darwin-arm64': 0.27.2
+ '@esbuild/darwin-x64': 0.27.2
+ '@esbuild/freebsd-arm64': 0.27.2
+ '@esbuild/freebsd-x64': 0.27.2
+ '@esbuild/linux-arm': 0.27.2
+ '@esbuild/linux-arm64': 0.27.2
+ '@esbuild/linux-ia32': 0.27.2
+ '@esbuild/linux-loong64': 0.27.2
+ '@esbuild/linux-mips64el': 0.27.2
+ '@esbuild/linux-ppc64': 0.27.2
+ '@esbuild/linux-riscv64': 0.27.2
+ '@esbuild/linux-s390x': 0.27.2
+ '@esbuild/linux-x64': 0.27.2
+ '@esbuild/netbsd-arm64': 0.27.2
+ '@esbuild/netbsd-x64': 0.27.2
+ '@esbuild/openbsd-arm64': 0.27.2
+ '@esbuild/openbsd-x64': 0.27.2
+ '@esbuild/openharmony-arm64': 0.27.2
+ '@esbuild/sunos-x64': 0.27.2
+ '@esbuild/win32-arm64': 0.27.2
+ '@esbuild/win32-ia32': 0.27.2
+ '@esbuild/win32-x64': 0.27.2
+
+ escape-string-regexp@4.0.0: {}
+
+ eslint-scope@8.4.0:
+ dependencies:
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+
+ eslint-visitor-keys@3.4.3: {}
+
+ eslint-visitor-keys@4.2.1: {}
+
+ eslint@9.39.2:
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2)
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/config-array': 0.21.1
+ '@eslint/config-helpers': 0.4.2
+ '@eslint/core': 0.17.0
+ '@eslint/eslintrc': 3.3.3
+ '@eslint/js': 9.39.2
+ '@eslint/plugin-kit': 0.4.1
+ '@humanfs/node': 0.16.7
+ '@humanwhocodes/module-importer': 1.0.1
+ '@humanwhocodes/retry': 0.4.3
+ '@types/estree': 1.0.8
+ ajv: 6.12.6
+ chalk: 4.1.2
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ eslint-scope: 8.4.0
+ eslint-visitor-keys: 4.2.1
+ espree: 10.4.0
+ esquery: 1.7.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 8.0.0
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ ignore: 5.3.2
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ json-stable-stringify-without-jsonify: 1.0.1
+ lodash.merge: 4.6.2
+ minimatch: 3.1.2
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ transitivePeerDependencies:
+ - supports-color
+
+ espree@10.4.0:
+ dependencies:
+ acorn: 8.15.0
+ acorn-jsx: 5.3.2(acorn@8.15.0)
+ eslint-visitor-keys: 4.2.1
+
+ esquery@1.7.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ esrecurse@4.3.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ estraverse@5.3.0: {}
+
+ esutils@2.0.3: {}
+
+ fast-deep-equal@3.1.3: {}
+
+ fast-json-stable-stringify@2.1.0: {}
+
+ fast-levenshtein@2.0.6: {}
+
+ fdir@6.5.0(picomatch@4.0.3):
+ optionalDependencies:
+ picomatch: 4.0.3
+
+ file-entry-cache@8.0.0:
+ dependencies:
+ flat-cache: 4.0.1
+
+ find-up@5.0.0:
+ dependencies:
+ locate-path: 6.0.0
+ path-exists: 4.0.0
+
+ flat-cache@4.0.1:
+ dependencies:
+ flatted: 3.3.3
+ keyv: 4.5.4
+
+ flatted@3.3.3: {}
+
+ fsevents@2.3.3:
+ optional: true
+
+ get-tsconfig@4.13.1:
+ dependencies:
+ resolve-pkg-maps: 1.0.0
+
+ glob-parent@6.0.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ globals@14.0.0: {}
+
+ has-flag@4.0.0: {}
+
+ ignore@5.3.2: {}
+
+ ignore@7.0.5: {}
+
+ import-fresh@3.3.1:
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+
+ imurmurhash@0.1.4: {}
+
+ is-extglob@2.1.1: {}
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+
+ isexe@2.0.0: {}
+
+ js-yaml@4.1.1:
+ dependencies:
+ argparse: 2.0.1
+
+ json-buffer@3.0.1: {}
+
+ json-schema-traverse@0.4.1: {}
+
+ json-stable-stringify-without-jsonify@1.0.1: {}
+
+ keyv@4.5.4:
+ dependencies:
+ json-buffer: 3.0.1
+
+ levn@0.4.1:
+ dependencies:
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+
+ locate-path@6.0.0:
+ dependencies:
+ p-locate: 5.0.0
+
+ lodash.merge@4.6.2: {}
+
+ minimatch@3.1.2:
+ dependencies:
+ brace-expansion: 1.1.12
+
+ minimatch@9.0.5:
+ dependencies:
+ brace-expansion: 2.0.2
+
+ ms@2.1.3: {}
+
+ natural-compare@1.4.0: {}
+
+ optionator@0.9.4:
+ dependencies:
+ deep-is: 0.1.4
+ fast-levenshtein: 2.0.6
+ levn: 0.4.1
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+ word-wrap: 1.2.5
+
+ p-limit@3.1.0:
+ dependencies:
+ yocto-queue: 0.1.0
+
+ p-locate@5.0.0:
+ dependencies:
+ p-limit: 3.1.0
+
+ parent-module@1.0.1:
+ dependencies:
+ callsites: 3.1.0
+
+ path-exists@4.0.0: {}
+
+ path-key@3.1.1: {}
+
+ picomatch@4.0.3: {}
+
+ prelude-ls@1.2.1: {}
+
+ prettier@3.8.1: {}
+
+ punycode@2.3.1: {}
+
+ resolve-from@4.0.0: {}
+
+ resolve-pkg-maps@1.0.0: {}
+
+ semver@7.7.3: {}
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ strip-json-comments@3.1.1: {}
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
+ tinyglobby@0.2.15:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+
+ ts-api-utils@2.4.0(typescript@5.9.3):
+ dependencies:
+ typescript: 5.9.3
+
+ tsx@4.21.0:
+ dependencies:
+ esbuild: 0.27.2
+ get-tsconfig: 4.13.1
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ type-check@0.4.0:
+ dependencies:
+ prelude-ls: 1.2.1
+
+ typescript@5.9.3: {}
+
+ uri-js@4.4.1:
+ dependencies:
+ punycode: 2.3.1
+
+ uuid@11.1.0: {}
+
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
+ word-wrap@1.2.5: {}
+
+ yocto-queue@0.1.0: {}
+
+ zod@4.3.6: {}
diff --git a/bubus-ts/prettier.config.js b/bubus-ts/prettier.config.js
new file mode 100644
index 0000000..98b89f5
--- /dev/null
+++ b/bubus-ts/prettier.config.js
@@ -0,0 +1,8 @@
+const config = {
+ semi: false,
+ singleQuote: true,
+ trailingComma: 'es5',
+ printWidth: 140,
+}
+
+export default config
diff --git a/bubus-ts/src/async_context.ts b/bubus-ts/src/async_context.ts
new file mode 100644
index 0000000..c2ed50a
--- /dev/null
+++ b/bubus-ts/src/async_context.ts
@@ -0,0 +1,53 @@
+declare const process: { versions?: { node?: string } } | undefined
+
+type AsyncLocalStorageLike = {
+ getStore(): unknown
+ run(store: unknown, callback: () => T): T
+ enterWith?(store: unknown): void
+}
+
+export type { AsyncLocalStorageLike }
+
+// Cache the AsyncLocalStorage constructor so multiple modules can create separate instances.
+let _AsyncLocalStorageClass: (new () => AsyncLocalStorageLike) | null = null
+
+const is_node = typeof process !== 'undefined' && typeof process.versions !== 'undefined' && typeof process.versions.node === 'string'
+
+if (is_node) {
+ try {
+ const importer = new Function('specifier', 'return import(specifier)') as (
+ specifier: string
+ ) => Promise<{ AsyncLocalStorage?: new () => AsyncLocalStorageLike }>
+ const mod = await importer('node:async_hooks')
+ if (mod?.AsyncLocalStorage) {
+ _AsyncLocalStorageClass = mod.AsyncLocalStorage
+ }
+ } catch {
+ _AsyncLocalStorageClass = null
+ }
+}
+
+/** Create a new AsyncLocalStorage instance, or null if unavailable (e.g. in browsers). */
+export const createAsyncLocalStorage = (): AsyncLocalStorageLike | null => {
+ if (!_AsyncLocalStorageClass) return null
+ return new _AsyncLocalStorageClass()
+}
+
+// The primary AsyncLocalStorage instance used for event dispatch context propagation.
+export let async_local_storage: AsyncLocalStorageLike | null = _AsyncLocalStorageClass ? new _AsyncLocalStorageClass() : null
+
+export const captureAsyncContext = (): unknown | null => {
+ if (!async_local_storage) {
+ return null
+ }
+ return async_local_storage.getStore() ?? null
+}
+
+export const runWithAsyncContext = (context: unknown | null, fn: () => T): T => {
+ if (!async_local_storage) {
+ return fn()
+ }
+ return async_local_storage.run(context ?? undefined, fn)
+}
+
+export const hasAsyncLocalStorage = (): boolean => async_local_storage !== null
diff --git a/bubus-ts/src/base_event.ts b/bubus-ts/src/base_event.ts
new file mode 100644
index 0000000..b333b89
--- /dev/null
+++ b/bubus-ts/src/base_event.ts
@@ -0,0 +1,482 @@
+import { z } from 'zod'
+import { v7 as uuidv7 } from 'uuid'
+
+import type { EventBus } from './event_bus.js'
+import { EventResult } from './event_result.js'
+import type { ConcurrencyMode, Deferred } from './lock_manager.js'
+import { CONCURRENCY_MODES, withResolvers } from './lock_manager.js'
+import { extractZodShape, getStringTypeName, isZodSchema, toJsonSchema } from './types.js'
+
+export const BaseEventSchema = z
+ .object({
+ event_id: z.string().uuid(),
+ event_created_at: z.string().datetime(),
+ event_created_ts: z.number().optional(),
+ event_type: z.string(),
+ event_timeout: z.number().positive().nullable(),
+ event_parent_id: z.string().uuid().optional(),
+ event_path: z.array(z.string()).optional(),
+ event_result_type: z.string().optional(),
+ event_result_schema: z.unknown().optional(),
+ event_emitted_by_handler_id: z.string().uuid().optional(),
+ event_pending_bus_count: z.number().nonnegative().optional(),
+ event_status: z.enum(['pending', 'started', 'completed']).optional(),
+ event_started_at: z.string().datetime().optional(),
+ event_started_ts: z.number().optional(),
+ event_completed_at: z.string().datetime().optional(),
+ event_completed_ts: z.number().optional(),
+ event_results: z.array(z.unknown()).optional(),
+ event_concurrency: z.enum(CONCURRENCY_MODES).optional(),
+ event_handler_concurrency: z.enum(CONCURRENCY_MODES).optional(),
+ })
+ .loose()
+
+export type BaseEventData = z.infer
+type BaseEventFields = Pick<
+ BaseEventData,
+ | 'event_id'
+ | 'event_created_at'
+ | 'event_created_ts'
+ | 'event_type'
+ | 'event_timeout'
+ | 'event_parent_id'
+ | 'event_path'
+ | 'event_result_type'
+ | 'event_result_schema'
+ | 'event_emitted_by_handler_id'
+ | 'event_pending_bus_count'
+ | 'event_status'
+ | 'event_started_at'
+ | 'event_started_ts'
+ | 'event_completed_at'
+ | 'event_completed_ts'
+ | 'event_results'
+ | 'event_concurrency'
+ | 'event_handler_concurrency'
+>
+
+export type BaseEventInit> = TFields & Partial
+
+type BaseEventSchemaShape = typeof BaseEventSchema.shape
+
+export type EventSchema = z.ZodObject
+type EventPayload = z.infer>
+
+type EventInput = z.input>
+export type EventInit = Omit, keyof BaseEventFields> & Partial
+
+type EventWithResult = BaseEvent & { __event_result_type__?: TResult }
+
+type ResultTypeFromShape = TShape extends { event_result_schema: infer S }
+ ? S extends z.ZodTypeAny
+ ? z.infer
+ : unknown
+ : unknown
+
+export type EventFactory = {
+ (data: EventInit): EventWithResult & EventPayload
+ new (data: EventInit): EventWithResult & EventPayload
+ schema: EventSchema
+ event_type?: string
+ event_result_schema?: z.ZodTypeAny
+ event_result_type?: string
+ fromJSON?: (data: unknown) => EventWithResult & EventPayload
+}
+
+type ZodShapeFrom> = {
+ [K in keyof TShape as K extends 'event_result_schema' | 'event_result_type' | 'event_result_schema_json'
+ ? never
+ : TShape[K] extends z.ZodTypeAny
+ ? K
+ : never]: Extract
+}
+
+export class BaseEvent {
+ // event metadata fields
+ event_id!: string // unique uuidv7 identifier for the event
+ event_created_at!: string // ISO datetime string version of event_created_at
+ event_created_ts!: number // nanosecond monotonic version of event_created_at
+ event_type!: string // should match the class name of the event, e.g. BaseEvent.extend("MyEvent").event_type === "MyEvent"
+ event_timeout!: number | null // maximum time in seconds that the event is allowed to run before it is aborted
+ event_parent_id?: string // id of the parent event that triggered this event, if this event was emitted during handling of another event
+ event_path!: string[] // list of bus names that the event has been dispatched to, including the current bus
+ event_result_schema?: z.ZodTypeAny // optional zod schema to enforce the shape of return values from handlers
+ event_result_type?: string // optional string identifier of the type of the return values from handlers, to make it easier to reference common shapes across networkboundaries e.g. ScreenshotEventResultType
+ event_results!: Map> // map of handler ids to EventResult objects for the event
+ event_emitted_by_handler_id?: string // if event was emitted inside a handler while it was running, this will be set to the enclosing handler's handler id
+ event_pending_bus_count!: number // number of buses that have accepted this event and not yet finished processing or removed it from their queues (for queue-jump processing)
+ event_status!: 'pending' | 'started' | 'completed' // processing status of the event as a whole, no separate 'error' state because events can not error, only individual handlers can
+ event_started_at?: string // ISO datetime string version of event_started_ts
+ event_started_ts?: number // nanosecond monotonic version of event_started_at
+ event_completed_at?: string // ISO datetime string version of event_completed_ts
+ event_completed_ts?: number // nanosecond monotonic version of event_completed_at
+ event_concurrency?: ConcurrencyMode // concurrency mode for the event as a whole in relation to other events
+ event_handler_concurrency?: ConcurrencyMode // concurrency mode for the handlers within the event
+
+ static event_type?: string // class name of the event, e.g. BaseEvent.extend("MyEvent").event_type === "MyEvent"
+ static schema = BaseEventSchema // zod schema for the event data fields, used to parse and validate event data when creating a new event
+
+ // internal runtime state
+ bus?: EventBus // shortcut to the bus that dispatched this event, for event.bus.dispatch(event) auto-child tracking via proxy wrapping
+ _event_original?: BaseEvent // underlying event object that was dispatched, if this is a bus-scoped proxy wrapping it
+ _event_dispatch_context?: unknown | null // captured AsyncLocalStorage context at dispatch site, used to restore that context when running handlers
+
+ _event_done_signal: Deferred | null
+
+ constructor(data: BaseEventInit> = {}) {
+ const ctor = this.constructor as typeof BaseEvent & {
+ event_result_schema?: z.ZodTypeAny
+ event_result_type?: string
+ }
+ const event_type = data.event_type ?? ctor.event_type ?? ctor.name
+ const event_result_schema = (data.event_result_schema ?? ctor.event_result_schema) as z.ZodTypeAny | undefined
+ const event_result_type = data.event_result_type ?? ctor.event_result_type ?? getStringTypeName(event_result_schema)
+ const event_id = data.event_id ?? uuidv7()
+ const { isostring: default_event_created_at, ts: event_created_ts } = BaseEvent.nextTimestamp()
+ const event_created_at = data.event_created_at ?? default_event_created_at
+ const event_timeout = data.event_timeout ?? null
+
+ const base_data = {
+ ...data,
+ event_id,
+ event_created_at,
+ event_type,
+ event_timeout,
+ event_result_schema,
+ event_result_type,
+ }
+
+ const schema = ctor.schema ?? BaseEventSchema
+ const parsed = schema.parse(base_data) as BaseEventData & Record
+
+ Object.assign(this, parsed)
+
+ const parsed_path = (parsed as { event_path?: string[] }).event_path
+ this.event_path = Array.isArray(parsed_path) ? [...parsed_path] : []
+
+ // load event results from potentially raw objects from JSON to proper EventResult objects
+ this.event_results = hydrateEventResults(this, (parsed as { event_results?: unknown }).event_results)
+ this.event_pending_bus_count =
+ typeof (parsed as { event_pending_bus_count?: unknown }).event_pending_bus_count === 'number'
+ ? Math.max(0, Number((parsed as { event_pending_bus_count?: number }).event_pending_bus_count))
+ : 0
+ const parsed_status = (parsed as { event_status?: unknown }).event_status
+ this.event_status =
+ parsed_status === 'pending' || parsed_status === 'started' || parsed_status === 'completed' ? parsed_status : 'pending'
+
+ this.event_started_at =
+ typeof (parsed as { event_started_at?: unknown }).event_started_at === 'string'
+ ? (parsed as { event_started_at: string }).event_started_at
+ : undefined
+ this.event_started_ts =
+ typeof (parsed as { event_started_ts?: unknown }).event_started_ts === 'number'
+ ? (parsed as { event_started_ts: number }).event_started_ts
+ : undefined
+ this.event_completed_at =
+ typeof (parsed as { event_completed_at?: unknown }).event_completed_at === 'string'
+ ? (parsed as { event_completed_at: string }).event_completed_at
+ : undefined
+ this.event_completed_ts =
+ typeof (parsed as { event_completed_ts?: unknown }).event_completed_ts === 'number'
+ ? (parsed as { event_completed_ts: number }).event_completed_ts
+ : undefined
+ this.event_emitted_by_handler_id =
+ typeof (parsed as { event_emitted_by_handler_id?: unknown }).event_emitted_by_handler_id === 'string'
+ ? (parsed as { event_emitted_by_handler_id: string }).event_emitted_by_handler_id
+ : undefined
+
+ this.event_result_schema = event_result_schema
+ this.event_result_type = event_result_type
+ this.event_created_ts =
+ typeof (parsed as { event_created_ts?: unknown }).event_created_ts === 'number'
+ ? (parsed as { event_created_ts: number }).event_created_ts
+ : event_created_ts
+
+ this._event_done_signal = null
+ this._event_dispatch_context = undefined
+ }
+
+ // "MyEvent#a48f"
+ toString(): string {
+ return `${this.event_type}#${this.event_id.slice(-4)}`
+ }
+
+ // get the next monotonic timestamp for global ordering of all operations
+ static nextTimestamp(): { date: Date; isostring: string; ts: number } {
+ const ts = performance.now()
+ const date = new Date(performance.timeOrigin + ts)
+ return { date, isostring: date.toISOString(), ts }
+ }
+
+ // main entry point for users to define their own event types
+ // BaseEvent.extend("MyEvent", { some_custom_field: z.string(), event_result_schema: z.string(), event_timeout: 25, ... }) -> MyEvent
+ static extend(event_type: string, shape?: TShape): EventFactory>
+ static extend>(
+ event_type: string,
+ shape?: TShape
+ ): EventFactory, ResultTypeFromShape>
+ static extend>(
+ event_type: string,
+ shape: TShape = {} as TShape
+ ): EventFactory, ResultTypeFromShape> {
+ const raw_shape = shape as Record
+
+ const event_result_schema = isZodSchema(raw_shape.event_result_schema) ? (raw_shape.event_result_schema as z.ZodTypeAny) : undefined
+ const explicit_event_result_type = typeof raw_shape.event_result_type === 'string' ? raw_shape.event_result_type : undefined
+ const event_result_type = explicit_event_result_type ?? getStringTypeName(event_result_schema)
+
+ const zod_shape = extractZodShape(raw_shape)
+ const full_schema = BaseEventSchema.extend(zod_shape)
+
+ // create a new event class that extends BaseEvent and adds the custom fields
+ class ExtendedEvent extends BaseEvent {
+ static schema = full_schema as unknown as typeof BaseEvent.schema
+ static event_type = event_type
+ static event_result_schema = event_result_schema
+ static event_result_type = event_result_type
+
+ constructor(data: EventInit>) {
+ super(data as BaseEventInit>)
+ }
+ }
+
+ type FactoryResult = EventWithResult> & EventPayload>
+
+ function EventFactory(data: EventInit>): FactoryResult {
+ return new ExtendedEvent(data) as FactoryResult
+ }
+
+ EventFactory.schema = full_schema as EventSchema>
+ EventFactory.event_type = event_type
+ EventFactory.event_result_schema = event_result_schema
+ EventFactory.event_result_type = event_result_type
+ EventFactory.fromJSON = (data: unknown) => (ExtendedEvent.fromJSON as (data: unknown) => FactoryResult)(data)
+ EventFactory.prototype = ExtendedEvent.prototype
+ ;(EventFactory as unknown as { class: typeof ExtendedEvent }).class = ExtendedEvent
+
+ return EventFactory as unknown as EventFactory, ResultTypeFromShape>
+ }
+
+ // parse raw event data into a new event object
+ static parse(this: T, data: unknown): InstanceType {
+ const schema = this.schema ?? BaseEventSchema
+ const parsed = schema.parse(data)
+ return new this(parsed) as InstanceType
+ }
+
+ static fromJSON(this: T, data: unknown): InstanceType {
+ if (!data || typeof data !== 'object') {
+ return this.parse(data)
+ }
+ const record = { ...(data as Record) }
+ if (record.event_result_schema && !isZodSchema(record.event_result_schema)) {
+ const zod_any = z as unknown as { fromJSONSchema?: (schema: unknown) => z.ZodTypeAny }
+ if (typeof zod_any.fromJSONSchema === 'function') {
+ record.event_result_schema = zod_any.fromJSONSchema(record.event_result_schema)
+ }
+ }
+ return new this(record as BaseEventInit>) as InstanceType
+ }
+
+ toJSON(): BaseEventData {
+ return {
+ event_id: this.event_id,
+ event_created_at: this.event_created_at,
+ event_created_ts: this.event_created_ts,
+ event_type: this.event_type,
+ event_timeout: this.event_timeout,
+ event_parent_id: this.event_parent_id,
+ event_path: this.event_path,
+ event_result_type: this.event_result_type,
+ event_emitted_by_handler_id: this.event_emitted_by_handler_id,
+ event_pending_bus_count: this.event_pending_bus_count,
+ event_status: this.event_status,
+ event_started_at: this.event_started_at,
+ event_started_ts: this.event_started_ts,
+ event_completed_at: this.event_completed_at,
+ event_completed_ts: this.event_completed_ts,
+ event_results: Array.from(this.event_results.values()).map((result) => result.toJSON()),
+ event_concurrency: this.event_concurrency,
+ event_handler_concurrency: this.event_handler_concurrency,
+ event_result_schema: this.event_result_schema ? toJsonSchema(this.event_result_schema) : this.event_result_schema,
+ }
+ }
+
+ // Get parent event object from event_parent_id (checks across all busses)
+ get event_parent(): BaseEvent | undefined {
+ const original = this._event_original ?? this
+ const parent_id = original.event_parent_id
+ if (!parent_id) {
+ return undefined
+ }
+ return original.bus?.findEventById(parent_id) ?? undefined
+ }
+
+ // get all direct children of this event
+ get event_children(): BaseEvent[] {
+ const children: BaseEvent[] = []
+ const seen = new Set()
+ for (const result of this.event_results.values()) {
+ for (const child of result.event_children) {
+ if (!seen.has(child.event_id)) {
+ seen.add(child.event_id)
+ children.push(child)
+ }
+ }
+ }
+ return children
+ }
+
+ // get all children grandchildren etc. recursively
+ get event_descendants(): BaseEvent[] {
+ const descendants: BaseEvent[] = []
+ const visited = new Set()
+ const root_id = this.event_id
+ const stack = [...this.event_children]
+
+ while (stack.length > 0) {
+ const child = stack.pop()
+ if (!child) {
+ continue
+ }
+ const child_id = child.event_id
+ if (child_id === root_id) {
+ continue
+ }
+ if (visited.has(child_id)) {
+ continue
+ }
+ visited.add(child_id)
+ descendants.push(child)
+ if (child.event_children.length > 0) {
+ stack.push(...child.event_children)
+ }
+ }
+
+ return descendants
+ }
+
+ // awaitable that triggers immediate (queue-jump) processing of the event on all buses where it is queued
+ // use event.waitForCompletion() or event.finished() to wait for the event to be processed in normal queue order
+ done(): Promise {
+ if (!this.bus) {
+ return Promise.reject(new Error('event has no bus attached'))
+ }
+ if (this.event_status === 'completed') {
+ return Promise.resolve(this)
+ }
+ // Always delegate to processEventImmediately β it walks up the parent event tree
+ // to determine whether we're inside a handler (works cross-bus). If no
+ // ancestor handler is in-flight, it falls back to waitForCompletion().
+ const runner_bus = this.bus as {
+ processEventImmediately: (event: BaseEvent) => Promise
+ }
+ return runner_bus.processEventImmediately(this) as Promise
+ }
+
+ // clearer alias for done() to indicate that the event will be processed immediately
+ // await bus.dispatch(event).immediate() is less ambiguous than await event.done()
+ immediate(): Promise {
+ return this.done()
+ }
+
+ // awaitable that waits for the event to be processed in normal queue order by the runloop
+ waitForCompletion(): Promise {
+ if (this.event_status === 'completed') {
+ return Promise.resolve(this)
+ }
+ this._notifyDoneListeners()
+ return this._event_done_signal!.promise
+ }
+
+ // convenience alias for await event.waitForCompletion()
+ finished(): Promise {
+ return this.waitForCompletion()
+ }
+
+ markStarted(): void {
+ if (this.event_status !== 'pending') {
+ return
+ }
+ this.event_status = 'started'
+ const { isostring: event_started_at, ts: event_started_ts } = BaseEvent.nextTimestamp()
+ this.event_started_at = event_started_at
+ this.event_started_ts = event_started_ts
+ }
+
+ markCompleted(force: boolean = true): void {
+ if (this.event_status === 'completed') {
+ return
+ }
+ if (!force) {
+ if (this.event_pending_bus_count > 0) {
+ return
+ }
+ if (!this.eventAreAllChildrenComplete()) {
+ return
+ }
+ }
+ this.event_status = 'completed'
+ const { isostring: event_completed_at, ts: event_completed_ts } = BaseEvent.nextTimestamp()
+ this.event_completed_at = event_completed_at
+ this.event_completed_ts = event_completed_ts
+ this._event_dispatch_context = null
+ this._notifyDoneListeners()
+ this._event_done_signal!.resolve(this)
+ this._event_done_signal = null
+ }
+
+ get event_errors(): unknown[] {
+ const errors: unknown[] = []
+ for (const result of this.event_results.values()) {
+ if (result.error !== undefined) {
+ errors.push(result.error)
+ }
+ }
+ return errors
+ }
+
+ eventAreAllChildrenComplete(): boolean {
+ for (const descendant of this.event_descendants) {
+ if (descendant.event_status !== 'completed') {
+ return false
+ }
+ }
+ return true
+ }
+
+ _notifyDoneListeners(): void {
+ if (this._event_done_signal) {
+ return
+ }
+ this._event_done_signal = withResolvers()
+ }
+
+ // Break internal reference chains so a completed event can be GC'd when
+ // evicted from event_history. Called by EventBus.trimHistory().
+ _gc(): void {
+ this._event_done_signal = null
+ this._event_dispatch_context = null
+ this.bus = undefined
+ for (const result of this.event_results.values()) {
+ result.event_children = []
+ }
+ this.event_results.clear()
+ }
+}
+
+const hydrateEventResults = (event: TEvent, raw_event_results: unknown): Map> => {
+ const event_results = new Map>()
+ if (!Array.isArray(raw_event_results)) {
+ return event_results
+ }
+ for (const item of raw_event_results) {
+ const result = EventResult.fromJSON(event, item)
+ if (!result) {
+ continue
+ }
+ const map_key = typeof result.handler_id === 'string' && result.handler_id.length > 0 ? result.handler_id : result.id
+ event_results.set(map_key, result)
+ }
+ return event_results
+}
diff --git a/bubus-ts/src/event_bus.ts b/bubus-ts/src/event_bus.ts
new file mode 100644
index 0000000..9e4409a
--- /dev/null
+++ b/bubus-ts/src/event_bus.ts
@@ -0,0 +1,1184 @@
+import { BaseEvent } from './base_event.js'
+import { EventResult } from './event_result.js'
+import { captureAsyncContext, runWithAsyncContext } from './async_context.js'
+import { AsyncSemaphore, type ConcurrencyMode, HandlerLock, LockManager, runWithSemaphore, withResolvers } from './lock_manager.js'
+import {
+ EventHandlerAbortedError,
+ EventHandlerCancelledError,
+ EventHandlerTimeoutError,
+ EventHandlerResultSchemaError,
+ EventHandler,
+} from './event_handler.js'
+import { logTree } from './logging.js'
+
+import type { EventClass, EventHandlerFunction, EventKey, FindOptions, UntypedEventHandlerFunction } from './types.js'
+
+type FindWaiter = {
+ // similar to a handler, except its for .find() calls
+ // needs to be different because it's resolved on dispatch not event processing time
+ // also is ephemeral, gets unregistered the moment it resolves and
+ // doesnt show up in event processing tree, doesn't block runloop, etc.
+ event_key: EventKey
+ matches: (event: BaseEvent) => boolean
+ resolve: (event: BaseEvent) => void
+ timeout_id?: ReturnType
+}
+
+type EventBusOptions = {
+ max_history_size?: number | null
+ event_concurrency?: ConcurrencyMode
+ event_handler_concurrency?: ConcurrencyMode
+ event_timeout?: number | null // default handler timeout in seconds, applied when event.event_timeout is undefined
+ event_handler_slow_timeout?: number | null // threshold before a warning is logged about slow handler execution
+ event_slow_timeout?: number | null // threshold before a warning is logged about slow event processing
+}
+
+// Global registry of all EventBus instances to allow for cross-bus coordination when global-serial concurrency mode is used
+class GlobalEventBusInstanceRegistry {
+ private _refs = new Set>()
+ private _lookup = new WeakMap>()
+ private _gc =
+ typeof FinalizationRegistry !== 'undefined'
+ ? new FinalizationRegistry>((ref) => {
+ this._refs.delete(ref)
+ })
+ : null
+
+ add(bus: EventBus): void {
+ const ref = new WeakRef(bus)
+ this._refs.add(ref)
+ this._lookup.set(bus, ref)
+ this._gc?.register(bus, ref, bus)
+ }
+
+ delete(bus: EventBus): void {
+ const ref = this._lookup.get(bus)
+ if (!ref) return
+ this._refs.delete(ref)
+ this._lookup.delete(bus)
+ this._gc?.unregister(bus)
+ }
+
+ has(bus: EventBus): boolean {
+ return this._lookup.get(bus)?.deref() !== undefined
+ }
+
+ get size(): number {
+ let n = 0
+ for (const ref of this._refs) ref.deref() ? n++ : this._refs.delete(ref)
+ return n
+ }
+
+ *[Symbol.iterator](): Iterator {
+ for (const ref of this._refs) {
+ const bus = ref.deref()
+ if (bus) yield bus
+ else this._refs.delete(ref)
+ }
+ }
+
+ // find an event by its id across all buses
+ findEventById(event_id: string): BaseEvent | null {
+ for (const bus of this) {
+ const event = bus.event_history.get(event_id)
+ if (event) {
+ return event
+ }
+ }
+ return null
+ }
+}
+
+export class EventBus {
+ static _all_instances = new GlobalEventBusInstanceRegistry()
+
+ name: string // name of the event bus, recommended to include the word "Bus" in the name for clarity in logs
+
+ // configuration options
+ max_history_size: number | null // max number of completed events kept in log, set to null for unlimited history
+ event_concurrency_default: ConcurrencyMode
+ event_handler_concurrency_default: ConcurrencyMode
+ event_timeout_default: number | null
+ event_handler_slow_timeout: number | null
+ event_slow_timeout: number | null
+
+ // public runtime state
+ handlers: Map // map of handler uuidv5 ids to EventHandler objects
+ event_history: Map // map of event uuidv7 ids to processed BaseEvent objects
+
+ // internal runtime state
+ pending_event_queue: BaseEvent[] // queue of events that have been dispatched to the bus but not yet processed
+ in_flight_event_ids: Set // set of event ids that are currently being processed by the bus
+ runloop_running: boolean
+ locks: LockManager
+ find_waiters: Set // set of FindWaiter objects that are waiting for a matching future event
+
+ constructor(name: string = 'EventBus', options: EventBusOptions = {}) {
+ this.name = name
+
+ // set configuration options
+ this.max_history_size = options.max_history_size === undefined ? 100 : options.max_history_size
+ this.event_concurrency_default = options.event_concurrency ?? 'bus-serial'
+ this.event_handler_concurrency_default = options.event_handler_concurrency ?? 'bus-serial'
+ this.event_timeout_default = options.event_timeout === undefined ? 60 : options.event_timeout
+ this.event_handler_slow_timeout = options.event_handler_slow_timeout === undefined ? 30 : options.event_handler_slow_timeout
+ this.event_slow_timeout = options.event_slow_timeout === undefined ? 300 : options.event_slow_timeout
+
+ // initialize runtime state
+ this.handlers = new Map()
+ this.event_history = new Map()
+ this.pending_event_queue = []
+ this.in_flight_event_ids = new Set()
+ this.runloop_running = false
+ this.locks = new LockManager(this)
+ this.find_waiters = new Set()
+
+ EventBus._all_instances.add(this)
+
+ this.dispatch = this.dispatch.bind(this)
+ this.emit = this.emit.bind(this)
+ }
+
+ toString(): string {
+ if (this.name.toLowerCase().includes('bus')) {
+ return `${this.name}`
+ }
+ return `EventBus(${this.name})` // for clarity that its a bus if bus is not in the name
+ }
+
+ // destroy the event bus and all its state to allow for garbage collection
+ destroy(): void {
+ EventBus._all_instances.delete(this)
+ this.handlers.clear()
+ for (const event of this.event_history.values()) {
+ event._gc()
+ }
+ this.event_history.clear()
+ this.pending_event_queue.length = 0
+ this.in_flight_event_ids.clear()
+ this.find_waiters.clear()
+ this.locks.clear()
+ }
+
+ on(
+ event_key: EventClass,
+ handler: EventHandlerFunction,
+ options?: { event_handler_concurrency?: ConcurrencyMode; handler_timeout?: number | null }
+ ): EventHandler
+ on(
+ event_key: string | '*',
+ handler: UntypedEventHandlerFunction,
+ options?: { event_handler_concurrency?: ConcurrencyMode; handler_timeout?: number | null }
+ ): EventHandler
+ on(
+ event_key: EventKey | '*',
+ handler: EventHandlerFunction | UntypedEventHandlerFunction,
+ options: { event_handler_concurrency?: ConcurrencyMode; handler_timeout?: number | null } = {}
+ ): EventHandler {
+ const normalized_key = this.normalizeEventKey(event_key) // get string event_type or '*'
+ const handler_name = handler.name || 'anonymous' // get handler function name or 'anonymous' if the handler is an anonymous/arrow function
+ const { isostring: handler_registered_at, ts: handler_registered_ts } = BaseEvent.nextTimestamp()
+ const handler_timeout = options.handler_timeout ?? this.event_timeout_default
+ const handler_entry = new EventHandler({
+ handler: handler as EventHandlerFunction,
+ handler_name,
+ handler_timeout,
+ event_handler_concurrency: options.event_handler_concurrency,
+ handler_registered_at,
+ handler_registered_ts,
+ event_key: normalized_key,
+ eventbus_name: this.name,
+ })
+
+ this.handlers.set(handler_entry.id, handler_entry)
+ return handler_entry
+ }
+
+ off(event_key: EventKey | '*', handler?: EventHandlerFunction | string | EventHandler): void {
+ const normalized_key = this.normalizeEventKey(event_key)
+ if (typeof handler === 'object' && handler instanceof EventHandler && handler.id !== undefined) {
+ handler = handler.id
+ }
+ const match_by_id = typeof handler === 'string'
+ for (const entry of this.handlers.values()) {
+ if (entry.event_key !== normalized_key) {
+ continue
+ }
+ const handler_id = entry.id
+ if (handler === undefined || (match_by_id ? handler_id === handler : entry.handler === (handler as EventHandlerFunction))) {
+ this.handlers.delete(handler_id)
+ }
+ }
+ }
+
+ dispatch(event: T, _event_key?: EventKey): T {
+ const original_event = event._event_original ?? event // if event is a bus-scoped proxy already, get the original underlying event object
+ if (!original_event.bus) {
+ // if we are the first bus to dispatch this event, set the bus property on the original event object
+ original_event.bus = this
+ }
+ if (!Array.isArray(original_event.event_path)) {
+ original_event.event_path = []
+ }
+ if (original_event._event_dispatch_context === undefined) {
+ // when used in fastify/nextjs/other contexts with tracing based on AsyncLocalStorage in node
+ // we want to capture the context at the dispatch site and use it when running handlers
+ // because events may be handled async in a separate context than the dispatch site
+ original_event._event_dispatch_context = captureAsyncContext()
+ }
+ if (original_event.event_timeout === null) {
+ original_event.event_timeout = this.event_timeout_default
+ }
+
+ if (original_event.event_path.includes(this.name) || this.hasProcessedEvent(original_event)) {
+ return this.getEventProxyScopedToThisBus(original_event) as T
+ }
+
+ if (!original_event.event_path.includes(this.name)) {
+ original_event.event_path.push(this.name)
+ }
+
+ if (original_event.event_parent_id && original_event.event_emitted_by_handler_id) {
+ const parent_result = original_event.event_parent?.event_results.get(original_event.event_emitted_by_handler_id)
+ if (parent_result) {
+ parent_result.linkEmittedChildEvent(original_event)
+ }
+ }
+
+ this.event_history.set(original_event.event_id, original_event)
+ this.trimHistory()
+
+ original_event.event_pending_bus_count += 1
+ this.pending_event_queue.push(original_event)
+ this.startRunloop()
+
+ return this.getEventProxyScopedToThisBus(original_event) as T
+ }
+
+ // alias for dispatch
+ emit(event: T, event_key?: EventKey): T {
+ return this.dispatch(event, event_key)
+ }
+
+ // find a recent event or wait for a future event that matches some criteria
+ find(event_key: EventKey, options?: FindOptions): Promise
+ find(event_key: EventKey, where: (event: T) => boolean, options?: FindOptions): Promise
+ async find(
+ event_key: EventKey,
+ where_or_options: ((event: T) => boolean) | FindOptions = {},
+ maybe_options: FindOptions = {}
+ ): Promise {
+ const where = typeof where_or_options === 'function' ? where_or_options : () => true
+ const options = typeof where_or_options === 'function' ? maybe_options : where_or_options
+
+ const past = options.past ?? true
+ const future = options.future ?? true
+ const child_of = options.child_of ?? null
+
+ if (past === false && future === false) {
+ return null
+ }
+
+ const matches = (event: BaseEvent): boolean => {
+ if (!this.eventMatchesKey(event, event_key)) {
+ return false
+ }
+ if (!where(event as T)) {
+ return false
+ }
+ if (child_of && !this.eventIsChildOf(event, child_of)) {
+ return false
+ }
+ return true
+ }
+
+ // find an event in the history that matches the criteria
+ if (past !== false || future !== false) {
+ const now_ms = performance.timeOrigin + performance.now()
+ const cutoff_ms = past === true ? null : now_ms - Math.max(0, Number(past)) * 1000
+
+ const history_values = Array.from(this.event_history.values())
+ for (let i = history_values.length - 1; i >= 0; i -= 1) {
+ const event = history_values[i]
+ if (!matches(event)) {
+ continue
+ }
+ if (event.event_status === 'completed') {
+ if (past === false) {
+ continue
+ }
+ if (cutoff_ms !== null && Date.parse(event.event_created_at) < cutoff_ms) {
+ continue
+ }
+ return this.getEventProxyScopedToThisBus(event) as T
+ }
+ if (future !== false) {
+ return this.getEventProxyScopedToThisBus(event) as T
+ }
+ }
+ }
+
+ // if we are only looking for past events, return null when no match is found
+ if (future === false) {
+ return null
+ }
+
+ // if we are looking for future events, return a promise that resolves when a match is found
+ return new Promise((resolve) => {
+ const waiter: FindWaiter = {
+ event_key,
+ matches,
+ resolve: (event) => resolve(this.getEventProxyScopedToThisBus(event) as T),
+ }
+
+ if (future !== true) {
+ const timeout_ms = Math.max(0, Number(future)) * 1000
+ waiter.timeout_id = setTimeout(() => {
+ this.find_waiters.delete(waiter)
+ resolve(null)
+ }, timeout_ms)
+ }
+
+ this.find_waiters.add(waiter)
+ })
+ }
+
+ // Called when a handler does `await child.done()` β processes the child event
+ // immediately ("queue-jump") instead of waiting for the runloop to pick it up.
+ //
+ // Yield-and-reacquire: if the calling handler holds a handler concurrency semaphore,
+ // we temporarily release it so child handlers on the same bus can acquire it
+ // (preventing deadlock for bus-serial/global-serial modes). We re-acquire after
+ // the child completes so the parent handler can continue with the semaphore held.
+ async processEventImmediately(event: T, handler_result?: EventResult): Promise {
+ const original_event = event._event_original ?? event
+ // Find the parent handler's result: prefer the proxy-provided one (only if
+ // the handler is still running), then this bus's stack, then walk up the
+ // parent event tree (cross-bus case). If none found, we're not inside a
+ // handler and should fall back to waitForCompletion.
+ const proxy_result = handler_result?.status === 'started' ? handler_result : undefined
+ const currently_active_event_result =
+ proxy_result ?? this.locks.getActiveHandlerResult() ?? this.getParentEventResultAcrossAllBusses(original_event) ?? undefined
+ if (!currently_active_event_result) {
+ // Not inside any handler scope β avoid queue-jump, but if this event is
+ // next in line we can process it immediately without waiting on the runloop.
+ const queue_index = this.pending_event_queue.indexOf(original_event)
+ const can_process_now =
+ queue_index === 0 &&
+ !this.locks.isPaused() &&
+ !this.in_flight_event_ids.has(original_event.event_id) &&
+ !this.hasProcessedEvent(original_event)
+ if (can_process_now) {
+ this.pending_event_queue.shift()
+ this.in_flight_event_ids.add(original_event.event_id)
+ await this.scheduleEventProcessing(original_event)
+ if (original_event.event_status !== 'completed') {
+ await original_event.waitForCompletion()
+ }
+ return event
+ }
+ await original_event.waitForCompletion()
+ return event
+ }
+
+ // ensure a pause request is set so the runloop pauses and (will resume when the event is completed)
+ this.locks.requestRunloopPauseForQueueJumpEvent(currently_active_event_result)
+ if (original_event.event_status === 'completed') {
+ return event
+ }
+
+ const run_queue_jump = currently_active_event_result._lock
+ ? (fn: () => Promise) => currently_active_event_result._lock!.runQueueJump(fn)
+ : (fn: () => Promise) => fn()
+ return await run_queue_jump(async () => {
+ if (original_event.event_status === 'started') {
+ await this.runImmediatelyAcrossBuses(original_event)
+ return event
+ }
+
+ const index = this.pending_event_queue.indexOf(original_event)
+ if (index >= 0) {
+ this.pending_event_queue.splice(index, 1)
+ }
+
+ await this.runImmediatelyAcrossBuses(original_event)
+ return event
+ })
+ }
+
+ async waitUntilIdle(): Promise {
+ await this.locks.waitForIdle()
+ }
+
+ // Weak idle check: only checks if handlers are idle, doesnt check that the queue is empty
+ isIdle(): boolean {
+ for (const event of this.event_history.values()) {
+ for (const result of event.event_results.values()) {
+ if (result.eventbus_name !== this.name) {
+ continue
+ }
+ if (result.status === 'pending' || result.status === 'started') {
+ return false
+ }
+ }
+ }
+ return true // no handlers are pending or started
+ }
+
+ // Stronger idle check: no queued work, no in-flight processing, runloop not
+ // active, and no handlers pending/running for this bus.
+ isIdleAndQueueEmpty(): boolean {
+ return this.pending_event_queue.length === 0 && this.in_flight_event_ids.size === 0 && this.isIdle() && !this.runloop_running
+ }
+
+ eventIsChildOf(event: BaseEvent, ancestor: BaseEvent): boolean {
+ if (event.event_id === ancestor.event_id) {
+ return false
+ }
+
+ let current_parent_id = event.event_parent_id
+ while (current_parent_id) {
+ if (current_parent_id === ancestor.event_id) {
+ return true
+ }
+ const parent = this.event_history.get(current_parent_id)
+ if (!parent) {
+ return false
+ }
+ current_parent_id = parent.event_parent_id
+ }
+ return false
+ }
+
+ eventIsParentOf(parent_event: BaseEvent, child_event: BaseEvent): boolean {
+ return this.eventIsChildOf(child_event, parent_event)
+ }
+
+ // return a full detailed tree diagram of all events and results on this bus
+ logTree(): string {
+ return logTree(this)
+ }
+
+ // Resolve an event id from this bus first, then across all known buses.
+ findEventById(event_id: string): BaseEvent | null {
+ return this.event_history.get(event_id) ?? EventBus._all_instances.findEventById(event_id)
+ }
+
+ // Walk up the parent event chain to find an in-flight ancestor handler result.
+ // Returns the result if found, null otherwise. Used by processEventImmediately to detect
+ // cross-bus queue-jump scenarios where the calling handler is on a different bus.
+ getParentEventResultAcrossAllBusses(event: BaseEvent): EventResult | null {
+ const original = event._event_original ?? event
+ let current_parent_id = original.event_parent_id
+ let current_handler_id = original.event_emitted_by_handler_id
+ while (current_handler_id && current_parent_id) {
+ const parent = EventBus._all_instances.findEventById(current_parent_id)
+ if (!parent) break
+ const handler_result = parent.event_results.get(current_handler_id)
+ if (handler_result && handler_result.status === 'started') return handler_result
+ current_parent_id = parent.event_parent_id
+ current_handler_id = parent.event_emitted_by_handler_id
+ }
+ return null
+ }
+
+ // Processes a queue-jumped event across all buses that have it dispatched.
+ // Called from processEventImmediately after the parent handler's semaphore has been yielded.
+ //
+ // Event semaphore bypass: the initiating bus (this) always bypasses its event semaphore
+ // since we're inside a handler that already holds it. Other buses only bypass if
+ // they resolve to the same semaphore instance (i.e. global-serial mode where all
+ // buses share LockManager.global_event_semaphore).
+ //
+ // Handler semaphores are NOT bypassed β child handlers must acquire the handler
+ // semaphore normally. This works because processEventImmediately already released the
+ // parent's handler semaphore via yield-and-reacquire.
+ private async runImmediatelyAcrossBuses(event: BaseEvent): Promise {
+ const buses = this.getBusesForImmediateRun(event)
+ if (buses.length === 0) {
+ await event.waitForCompletion()
+ return
+ }
+
+ const pause_releases = buses.map((bus) => bus.locks.requestPause())
+
+ // Determine which event semaphore the initiating bus resolves to, so we can
+ // detect when other buses share the same instance (global-serial).
+ const initiating_event_semaphore = this.locks.getSemaphoreForEvent(event)
+
+ try {
+ for (const bus of buses) {
+ const index = bus.pending_event_queue.indexOf(event)
+ if (index >= 0) {
+ bus.pending_event_queue.splice(index, 1)
+ }
+ if (bus.hasProcessedEvent(event)) {
+ continue
+ }
+ if (bus.in_flight_event_ids.has(event.event_id)) {
+ continue
+ }
+ bus.in_flight_event_ids.add(event.event_id)
+
+ // Bypass event semaphore on the initiating bus (we're already inside a handler
+ // that acquired it). For other buses, only bypass if they resolve to the same
+ // semaphore instance (global-serial shares one semaphore across all buses).
+ const bus_event_semaphore = bus.locks.getSemaphoreForEvent(event)
+ const should_bypass_event_semaphore =
+ bus === this || (initiating_event_semaphore !== null && bus_event_semaphore === initiating_event_semaphore)
+
+ await bus.scheduleEventProcessing(event, {
+ bypass_event_semaphores: should_bypass_event_semaphore,
+ })
+ }
+
+ if (event.event_status !== 'completed') {
+ await event.waitForCompletion()
+ }
+ } finally {
+ for (const release of pause_releases) {
+ release()
+ }
+ }
+ }
+
+ // Collects buses that currently "own" this event so queue-jump can run it immediately
+ // across all forwarded buses. Called by runImmediatelyAcrossBuses(), which itself is
+ // invoked from processEventImmediately (via BaseEvent.done()) when an event is awaited inside
+ // a handler. Uses event.event_path ordering to pick candidate buses and filters out
+ // buses that haven't seen the event or already processed it.
+ private getBusesForImmediateRun(event: BaseEvent): EventBus[] {
+ const ordered: EventBus[] = []
+ const seen = new Set()
+
+ const event_path = Array.isArray(event.event_path) ? event.event_path : []
+ for (const name of event_path) {
+ for (const bus of EventBus._all_instances) {
+ if (bus.name !== name) {
+ continue
+ }
+ if (!bus.event_history.has(event.event_id)) {
+ continue
+ }
+ if (bus.hasProcessedEvent(event)) {
+ continue
+ }
+ if (!seen.has(bus)) {
+ ordered.push(bus)
+ seen.add(bus)
+ }
+ }
+ }
+
+ if (!seen.has(this) && this.event_history.has(event.event_id)) {
+ ordered.push(this)
+ }
+
+ return ordered
+ }
+
+ private startRunloop(): void {
+ if (this.runloop_running) {
+ return
+ }
+ this.runloop_running = true
+ queueMicrotask(() => {
+ void this.runloop()
+ })
+ }
+
+ // schedule the processing of an event on the event bus by its normal runloop
+ // but set up the bus to process the given event immediately if it is a queue-jump event
+ private async scheduleEventProcessing(
+ event: BaseEvent,
+ options: {
+ bypass_event_semaphores?: boolean
+ pre_acquired_semaphore?: AsyncSemaphore | null
+ } = {}
+ ): Promise {
+ try {
+ const semaphore = options.bypass_event_semaphores ? null : this.locks.getSemaphoreForEvent(event)
+ const pre_acquired_semaphore = options.pre_acquired_semaphore ?? null
+ if (pre_acquired_semaphore) {
+ await this.processEvent(event)
+ } else {
+ await runWithSemaphore(semaphore, async () => {
+ await this.processEvent(event)
+ })
+ }
+ } finally {
+ if (options.pre_acquired_semaphore) {
+ options.pre_acquired_semaphore.release()
+ }
+ this.in_flight_event_ids.delete(event.event_id)
+ this.locks.notifyIdleListeners()
+ }
+ }
+
+ private async runloop(): Promise {
+ for (;;) {
+ while (this.pending_event_queue.length > 0) {
+ await Promise.resolve()
+ if (this.locks.isPaused()) {
+ await this.locks.waitUntilRunloopResumed()
+ continue
+ }
+ const next_event = this.pending_event_queue[0]
+ if (!next_event) {
+ continue
+ }
+ const original_event = next_event._event_original ?? next_event
+ if (this.hasProcessedEvent(original_event)) {
+ this.pending_event_queue.shift()
+ continue
+ }
+ let pre_acquired_semaphore: AsyncSemaphore | null = null
+ const event_semaphore = this.locks.getSemaphoreForEvent(original_event)
+ if (event_semaphore) {
+ await event_semaphore.acquire()
+ pre_acquired_semaphore = event_semaphore
+ }
+ this.pending_event_queue.shift()
+ if (this.in_flight_event_ids.has(original_event.event_id)) {
+ if (pre_acquired_semaphore) {
+ pre_acquired_semaphore.release()
+ }
+ continue
+ }
+ this.in_flight_event_ids.add(original_event.event_id)
+ void this.scheduleEventProcessing(original_event, {
+ bypass_event_semaphores: true,
+ pre_acquired_semaphore,
+ })
+ await Promise.resolve()
+ }
+ this.runloop_running = false
+ if (this.pending_event_queue.length > 0) {
+ this.startRunloop()
+ return
+ }
+ this.locks.notifyIdleListeners()
+ return
+ }
+ }
+
+ private async processEvent(event: BaseEvent): Promise {
+ if (this.hasProcessedEvent(event)) {
+ return
+ }
+ event.markStarted()
+ this.notifyFindListeners(event)
+
+ const slow_event_warning_timer = this.createSlowEventWarningTimer(event)
+
+ try {
+ const handler_entries = this.createPendingHandlerResults(event)
+
+ const handler_promises = handler_entries.map((entry) => this.runEventHandler(event, entry.handler, entry.result))
+ await Promise.all(handler_promises)
+
+ event.event_pending_bus_count = Math.max(0, event.event_pending_bus_count - 1)
+ event.markCompleted(false)
+ if (event.event_status === 'completed') {
+ this.notifyEventParentsOfCompletion(event)
+ }
+ } finally {
+ if (slow_event_warning_timer) {
+ clearTimeout(slow_event_warning_timer)
+ }
+ }
+ }
+
+ // Manually manages the handler concurrency semaphore instead of using runWithSemaphore,
+ // because processEventImmediately may temporarily yield it during queue-jumping.
+ async runEventHandler(event: BaseEvent, handler: EventHandler, result: EventResult): Promise {
+ if (result.status === 'error' && result.error instanceof EventHandlerCancelledError) {
+ return
+ }
+
+ const handler_event = this.getEventProxyScopedToThisBus(event, result)
+ const semaphore = this.locks.getSemaphoreForHandler(event, handler)
+
+ if (semaphore) {
+ await semaphore.acquire()
+ }
+
+ // if the result is already in an error or completed state, release the semaphore immediately and return
+ // prevent double-processing of the event by the same handler
+ if (result.status === 'error' || result.status === 'completed') {
+ if (semaphore) semaphore.release()
+ return
+ }
+
+ // exit the handler lock if it is already held
+ if (result._lock) result._lock.exitHandlerRun()
+ // create a new handler lock to track ownership of the semaphore during handler execution
+ result._lock = new HandlerLock(semaphore)
+ this.locks.enterActiveHandlerContext(result)
+
+ // resolve the effective timeout by combining the event timeout and the handler timeout
+ const effective_timeout = this.resolveEffectiveTimeout(event.event_timeout, result.handler.handler_timeout)
+ const slow_handler_warning_timer = this.createSlowHandlerWarningTimer(event, result, effective_timeout)
+
+ try {
+ const abort_signal = result.markStarted()
+ const handler_result = await Promise.race([this.runHandlerWithTimeout(event, handler, handler_event, result), abort_signal])
+ if (event.event_result_schema && handler_result !== undefined) {
+ // if there is a result schema to enforce, parse the handler's return value and mark the event as completed or errored if it doesn't match the schema
+ const parsed = event.event_result_schema.safeParse(handler_result)
+ if (parsed.success) {
+ result.markCompleted(parsed.data)
+ } else {
+ // if the handler's return value doesn't match the schema, mark the event as errored with an error message
+ const error = new EventHandlerResultSchemaError(
+ `${this.toString()}.on(${event.toString()}, ${result.handler.toString()}) return value ${JSON.stringify(handler_result).slice(0, 20)}... did not match event_result_schema ${event.event_result_type}: ${parsed.error.message}`,
+ { event_result: result, cause: parsed.error, raw_value: handler_result }
+ )
+ result.markError(error)
+ }
+ } else {
+ // if there is no result schema to enforce, just mark the event as completed with the raw handler's return value
+ result.markCompleted(handler_result)
+ }
+ } catch (error) {
+ // if the handler timed out, cancel all pending descendants and mark the event as errored
+ if (error instanceof EventHandlerTimeoutError) {
+ result.markError(error)
+ this.cancelPendingDescendants(event, error)
+ } else {
+ result.markError(error)
+ }
+ } finally {
+ result._abort = null
+ result._lock?.exitHandlerRun()
+ this.locks.exitActiveHandlerContext(result)
+ this.locks.releaseRunloopPauseForQueueJumpEvent(result)
+ if (slow_handler_warning_timer) {
+ clearTimeout(slow_handler_warning_timer)
+ }
+ }
+ }
+
+ // run a handler with a timeout, returning a promise that resolves or rejects with the handler's result or an error if the timeout is exceeded
+ private async runHandlerWithTimeout(
+ event: BaseEvent,
+ handler: EventHandler,
+ handler_event: BaseEvent = event,
+ result: EventResult
+ ): Promise {
+ // resolve the effective timeout by combining the event timeout and the handler timeout
+ const effective_timeout = this.resolveEffectiveTimeout(event.event_timeout, result.handler.handler_timeout)
+ const run_handler = () =>
+ Promise.resolve().then(() => runWithAsyncContext(event._event_dispatch_context ?? null, () => handler.handler(handler_event)))
+
+ if (effective_timeout === null) {
+ // if there is no timeout to enforce, just run the handler directly and return the promise
+ return run_handler()
+ }
+
+ const timeout_seconds = effective_timeout
+ const timeout_ms = timeout_seconds * 1000
+
+ const { promise, resolve, reject } = withResolvers()
+ let settled = false
+
+ // finalize the promise by clearing the timeout and calling the resolve or reject function
+ const finalize = (fn: (value?: unknown) => void) => {
+ return (value?: unknown) => {
+ if (settled) {
+ return
+ }
+ settled = true
+ clearTimeout(timer)
+ fn(value)
+ }
+ }
+
+ // set a timeout to reject the promise if the handler takes too long
+ const timer = setTimeout(() => {
+ finalize(reject)(
+ new EventHandlerTimeoutError(
+ `${this.toString()}.on(${event.toString()}, ${result.handler.toString()}) timed out after ${timeout_seconds}s`,
+ {
+ event_result: result,
+ timeout_seconds,
+ }
+ )
+ )
+ }, timeout_ms)
+
+ run_handler().then(finalize(resolve)).catch(finalize(reject))
+
+ return promise
+ }
+
+ private createSlowEventWarningTimer(event: BaseEvent): ReturnType | null {
+ const event_warn_ms = this.event_slow_timeout === null ? null : this.event_slow_timeout * 1000
+ if (event_warn_ms === null) {
+ return null
+ }
+ return setTimeout(() => {
+ if (event.event_status === 'completed') {
+ return
+ }
+ const running_handler_count = [...event.event_results.values()].filter((result) => result.status === 'started').length
+ const started_ts = event.event_started_ts ?? event.event_created_ts ?? performance.now()
+ const elapsed_ms = Math.max(0, performance.now() - started_ts)
+ const elapsed_seconds = (elapsed_ms / 1000).toFixed(2)
+ console.warn(
+ `[bubus] Slow event processing: ${this.name}.on(${event.event_type}#${event.event_id.slice(-4)}, ${running_handler_count} handlers) still running after ${elapsed_seconds}s`
+ )
+ }, event_warn_ms)
+ }
+
+ private createSlowHandlerWarningTimer(
+ event: BaseEvent,
+ result: EventResult,
+ effective_timeout: number | null
+ ): ReturnType | null {
+ const warn_ms = this.event_handler_slow_timeout === null ? null : this.event_handler_slow_timeout * 1000
+ const should_warn = warn_ms !== null && (effective_timeout === null || effective_timeout * 1000 > warn_ms)
+ if (!should_warn || warn_ms === null) {
+ return null
+ }
+ const started_at_ms = performance.now()
+ return setTimeout(() => {
+ if (result.status !== 'started') {
+ return
+ }
+ const elapsed_ms = performance.now() - started_at_ms
+ const elapsed_seconds = (elapsed_ms / 1000).toFixed(1)
+ console.warn(
+ `[bubus] Slow event handler: ${this.name}.on(${event.toString()}, ${result.handler.toString()}) still running after ${elapsed_seconds}s`
+ )
+ }, warn_ms)
+ }
+
+ private resolveEffectiveTimeout(event_timeout: number | null, handler_timeout: number | null): number | null {
+ if (handler_timeout === null && event_timeout === null) {
+ return null
+ }
+ if (handler_timeout === null) {
+ return event_timeout
+ }
+ if (event_timeout === null) {
+ return handler_timeout
+ }
+ return Math.min(handler_timeout, event_timeout)
+ }
+
+ // check if an event has been processed (and completed) by this bus
+ hasProcessedEvent(event: BaseEvent): boolean {
+ const results = Array.from(event.event_results.values()).filter((result) => result.eventbus_name === this.name)
+ if (results.length === 0) {
+ return false
+ }
+ return results.every((result) => result.status === 'completed' || result.status === 'error')
+ }
+
+ private notifyEventParentsOfCompletion(event: BaseEvent): void {
+ const visited = new Set()
+ let parent_id = event.event_parent_id
+ while (parent_id && !visited.has(parent_id)) {
+ visited.add(parent_id)
+ const parent = EventBus._all_instances.findEventById(parent_id)
+ if (!parent) {
+ break
+ }
+ parent.markCompleted(false)
+ if (parent.event_status !== 'completed') {
+ break
+ }
+ parent_id = parent.event_parent_id
+ }
+ }
+
+ // get a proxy wrapper around an Event that will automatically link emitted child events to this bus and handler
+ // proxy is what gets passed into the handler, if handler does event.bus.emit(...) to dispatch child events,
+ // the proxy auto-sets event.parent_event_id and event.event_emitted_by_handler_id
+ getEventProxyScopedToThisBus(event: T, handler_result?: EventResult): T {
+ const original_event = event._event_original ?? event
+ const bus = this
+ const parent_event_id = original_event.event_id
+ const bus_proxy = new Proxy(bus, {
+ get(target, prop, receiver) {
+ if (prop === 'processEventImmediately') {
+ return (child_event: BaseEvent) => {
+ const runner = Reflect.get(target, prop, receiver) as (event: BaseEvent, handler_result?: EventResult) => Promise
+ return runner.call(target, child_event, handler_result)
+ }
+ }
+ if (prop === 'dispatch' || prop === 'emit') {
+ return (child_event: BaseEvent, event_key?: EventKey) => {
+ const original_child = child_event._event_original ?? child_event
+ if (handler_result) {
+ handler_result.linkEmittedChildEvent(original_child)
+ } else if (!original_child.event_parent_id) {
+ // fallback for non-handler scoped dispatch
+ original_child.event_parent_id = parent_event_id
+ }
+ const dispatcher = Reflect.get(target, prop, receiver) as (event: BaseEvent, event_key?: EventKey) => BaseEvent
+ const dispatched = dispatcher.call(target, original_child, event_key)
+ return target.getEventProxyScopedToThisBus(dispatched, handler_result)
+ }
+ }
+ return Reflect.get(target, prop, receiver)
+ },
+ })
+ const scoped = new Proxy(original_event, {
+ get(target, prop, receiver) {
+ if (prop === 'bus') {
+ return bus_proxy
+ }
+ if (prop === '_event_original') {
+ return target
+ }
+ return Reflect.get(target, prop, receiver)
+ },
+ set(target, prop, value) {
+ if (prop === 'bus') {
+ return true
+ }
+ return Reflect.set(target, prop, value, target)
+ },
+ has(target, prop) {
+ if (prop === 'bus') {
+ return true
+ }
+ if (prop === '_event_original') {
+ return true
+ }
+ return Reflect.has(target, prop)
+ },
+ })
+
+ return scoped as T
+ }
+
+ // force-abort processing of all pending descendants of an event regardless of whether they have already started
+ cancelPendingDescendants(event: BaseEvent, reason: unknown): void {
+ const cancellation_cause = this.normalizeCancellationCause(reason)
+ const visited = new Set()
+ const cancelChildEvent = (child: BaseEvent): void => {
+ const original_child = child._event_original ?? child
+ if (visited.has(original_child.event_id)) {
+ return
+ }
+ visited.add(original_child.event_id)
+
+ // Depth-first: cancel grandchildren before parent so
+ // eventAreAllChildrenComplete() returns true when we get back up.
+ for (const grandchild of original_child.event_children) {
+ cancelChildEvent(grandchild)
+ }
+
+ const path = Array.isArray(original_child.event_path) ? original_child.event_path : []
+ const buses_to_cancel = new Set(path)
+ for (const bus of EventBus._all_instances) {
+ if (!buses_to_cancel.has(bus.name)) {
+ continue
+ }
+ bus.cancelEvent(original_child, cancellation_cause)
+ }
+
+ // Force-complete the child event. In JS we can't stop running async
+ // handlers, but markCompleted() resolves the done() promise so callers
+ // aren't blocked waiting for background work to finish. The background
+ // handler's eventual markCompleted/markError is a no-op (terminal guard).
+ if (original_child.event_status !== 'completed') {
+ original_child.markCompleted()
+ }
+ }
+
+ for (const child of event.event_children) {
+ cancelChildEvent(child)
+ }
+ }
+
+ private normalizeCancellationCause(reason: unknown): Error {
+ if (reason instanceof EventHandlerCancelledError || reason instanceof EventHandlerAbortedError) {
+ return reason.cause instanceof Error ? reason.cause : reason
+ }
+ if (reason instanceof EventHandlerTimeoutError) {
+ return reason
+ }
+ return reason instanceof Error ? reason : new Error(String(reason))
+ }
+
+ // force-abort processing of an event regardless of whether it is pending or has already started
+ private cancelEvent(event: BaseEvent, cause: Error): void {
+ const original_event = event._event_original ?? event
+ const handler_entries = this.createPendingHandlerResults(original_event)
+ let updated = false
+ for (const entry of handler_entries) {
+ if (entry.result.status === 'pending') {
+ const cancelled_error = new EventHandlerCancelledError(`Cancelled pending handler due to parent error: ${cause.message}`, {
+ event_result: entry.result,
+ cause,
+ })
+ entry.result.markError(cancelled_error)
+ updated = true
+ } else if (entry.result.status === 'started') {
+ // Abort running handlers. In JS we can't actually stop a running async
+ // function, but marking it as error means the event system treats it as
+ // done. The background handler will finish silently (its markCompleted/
+ // markError call is a no-op once in terminal state).
+ //
+ // Exit handler-run ownership immediately so any held lock is released.
+ // If reacquire is currently pending, exit closes ownership and the
+ // reacquire path auto-releases when it wakes.
+ entry.result._lock?.exitHandlerRun()
+
+ const aborted_error = new EventHandlerAbortedError(`Aborted running handler due to parent error: ${cause.message}`, {
+ event_result: entry.result,
+ cause,
+ })
+ entry.result.markError(aborted_error)
+ entry.result.signalAbort(aborted_error)
+ updated = true
+ }
+ }
+
+ let removed = 0
+ if (this.pending_event_queue.length > 0) {
+ const before_len = this.pending_event_queue.length
+ this.pending_event_queue = this.pending_event_queue.filter(
+ (queued) => (queued._event_original ?? queued).event_id !== original_event.event_id
+ )
+ removed = before_len - this.pending_event_queue.length
+ }
+
+ if (removed > 0 && !this.in_flight_event_ids.has(original_event.event_id)) {
+ original_event.event_pending_bus_count = Math.max(0, original_event.event_pending_bus_count - 1)
+ }
+
+ if (updated || removed > 0) {
+ original_event.markCompleted(false)
+ if (original_event.event_status === 'completed') {
+ this.notifyEventParentsOfCompletion(original_event)
+ }
+ }
+ }
+
+ private notifyFindListeners(event: BaseEvent): void {
+ for (const waiter of Array.from(this.find_waiters)) {
+ if (!this.eventMatchesKey(event, waiter.event_key)) {
+ continue
+ }
+ if (!waiter.matches(event)) {
+ continue
+ }
+ if (waiter.timeout_id) {
+ clearTimeout(waiter.timeout_id)
+ }
+ this.find_waiters.delete(waiter)
+ waiter.resolve(event)
+ }
+ }
+
+ private createPendingHandlerResults(event: BaseEvent): Array<{
+ handler: EventHandler
+ result: EventResult
+ }> {
+ const handlers = this.getHandlersForEvent(event)
+ return handlers.map((entry) => {
+ const handler_id = entry.id
+ const existing = event.event_results.get(handler_id)
+ const result = existing ?? new EventResult({ event, handler: entry })
+ if (!existing) {
+ event.event_results.set(handler_id, result)
+ }
+ return { handler: entry, result }
+ })
+ }
+
+ getHandlersForEvent(event: BaseEvent): EventHandler[] {
+ const handlers: EventHandler[] = []
+
+ // Exact-match handlers first, then wildcard β preserves original ordering
+ for (const entry of this.handlers.values()) {
+ if (entry.event_key === event.event_type) {
+ handlers.push(entry)
+ }
+ }
+ for (const entry of this.handlers.values()) {
+ if (entry.event_key === '*') {
+ handlers.push(entry)
+ }
+ }
+
+ return handlers
+ }
+
+ private eventMatchesKey(event: BaseEvent, event_key: EventKey): boolean {
+ if (event_key === '*') {
+ return true
+ }
+ const normalized = this.normalizeEventKey(event_key)
+ if (normalized === '*') {
+ return true
+ }
+ return event.event_type === normalized
+ }
+
+ private normalizeEventKey(event_key: EventKey | '*'): string | '*' {
+ if (event_key === '*') {
+ return '*'
+ }
+ if (typeof event_key === 'string') {
+ return event_key
+ }
+ const event_type = (event_key as { event_type?: unknown }).event_type
+ if (typeof event_type === 'string' && event_type.length > 0 && event_type !== 'BaseEvent') {
+ return event_type
+ }
+ throw new Error(
+ 'bus.on(match_pattern, ...) must be a string event type, "*", or a BaseEvent class, got: ' + JSON.stringify(event_key).slice(0, 30)
+ )
+ }
+
+ private trimHistory(): void {
+ if (this.max_history_size === null) {
+ return
+ }
+ if (this.event_history.size <= this.max_history_size) {
+ return
+ }
+
+ let remaining_overage = this.event_history.size - this.max_history_size
+
+ // First pass: remove completed events (oldest first, Map iterates in insertion order)
+ for (const [event_id, event] of this.event_history) {
+ if (remaining_overage <= 0) {
+ break
+ }
+ if (event.event_status !== 'completed') {
+ continue
+ }
+ this.event_history.delete(event_id)
+ event._gc()
+ remaining_overage -= 1
+ }
+
+ // Second pass: force-remove oldest events regardless of status
+ let dropped_pending_events = 0
+ if (remaining_overage > 0) {
+ for (const [event_id, event] of this.event_history) {
+ if (remaining_overage <= 0) {
+ break
+ }
+ if (event.event_status !== 'completed') {
+ dropped_pending_events += 1
+ }
+ this.event_history.delete(event_id)
+ event._gc()
+ remaining_overage -= 1
+ }
+ if (dropped_pending_events > 0) {
+ console.error(
+ `[bubus] β οΈ Bus ${this.toString()} has exceeded its limit of ${this.max_history_size} inflight events and has started dropping oldest pending events! Increase bus.max_history_size or reduce the event volume.`
+ )
+ }
+ }
+ }
+}
diff --git a/bubus-ts/src/event_handler.ts b/bubus-ts/src/event_handler.ts
new file mode 100644
index 0000000..a165408
--- /dev/null
+++ b/bubus-ts/src/event_handler.ts
@@ -0,0 +1,191 @@
+import { v5 as uuidv5 } from 'uuid'
+
+import type { ConcurrencyMode } from './lock_manager.js'
+import type { EventHandlerFunction } from './types.js'
+import { BaseEvent } from './base_event.js'
+import { EventResult } from './event_result.js'
+
+const HANDLER_ID_NAMESPACE = uuidv5('bubus-handler', uuidv5.DNS)
+
+// an entry in the list of event handlers that are registered on a bus
+export class EventHandler {
+ id: string // unique uuidv5 based on hash of bus name, handler name, handler file path:lineno, registered at timestamp, and event key
+ handler: EventHandlerFunction // the handler function itself
+ handler_name: string // name of the handler function, or 'anonymous' if the handler is an anonymous/arrow function
+ handler_file_path?: string // ~/path/to/source/file.ts:123
+ handler_timeout: number | null // maximum time in seconds that the handler is allowed to run before it is aborted, defaults to event.event_timeout if not set
+ event_handler_concurrency?: ConcurrencyMode // per-handler concurrency override
+ handler_registered_at: string // ISO datetime string version of handler_registered_ts
+ handler_registered_ts: number // nanosecond monotonic version of handler_registered_at
+ event_key: string | '*' // event_type string to match against, or '*' to match all events
+ eventbus_name: string // name of the event bus that the handler is registered on
+
+ constructor(params: {
+ id?: string
+ handler: EventHandlerFunction
+ handler_name: string
+ handler_file_path?: string
+ handler_timeout: number | null
+ event_handler_concurrency?: ConcurrencyMode
+ handler_registered_at: string
+ handler_registered_ts: number
+ event_key: string | '*'
+ eventbus_name: string
+ }) {
+ const handler_file_path = EventHandler.detectHandlerFilePath(params.handler_file_path)
+ this.id =
+ params.id ??
+ EventHandler.computeHandlerId({
+ eventbus_name: params.eventbus_name,
+ handler_name: params.handler_name,
+ handler_file_path,
+ handler_registered_at: params.handler_registered_at,
+ event_key: params.event_key,
+ })
+ this.handler = params.handler
+ this.handler_name = params.handler_name
+ this.handler_file_path = handler_file_path
+ this.handler_timeout = params.handler_timeout
+ this.event_handler_concurrency = params.event_handler_concurrency
+ this.handler_registered_at = params.handler_registered_at
+ this.handler_registered_ts = params.handler_registered_ts
+ this.event_key = params.event_key
+ this.eventbus_name = params.eventbus_name
+ }
+
+ // compute globally unique handler uuid as a hash of the bus name, handler name, handler file path, registered at timestamp, and event key
+ static computeHandlerId(params: {
+ eventbus_name: string
+ handler_name: string
+ handler_file_path?: string
+ handler_registered_at: string
+ event_key: string | '*'
+ }): string {
+ const file_path = EventHandler.detectHandlerFilePath(params.handler_file_path, 'unknown') ?? 'unknown'
+ const seed = `${params.eventbus_name}|${params.handler_name}|${file_path}|${params.handler_registered_at}|${params.event_key}`
+ return uuidv5(seed, HANDLER_ID_NAMESPACE)
+ }
+
+ // "someHandlerName() (~/path/to/source/file.ts:123)"
+ toString(): string {
+ const label = this.handler_name && this.handler_name !== 'anonymous' ? `${this.handler_name}()` : `function#${this.id.slice(-4)}()`
+ const file_path = this.handler_file_path ?? 'unknown'
+ return `${label} (${file_path})`
+ }
+
+ // walk the stack trace at registration time to detect the location of the source code file that defines the handler function
+ // and return the file path and line number as a string, or 'unknown' if the file path cannot be determined
+ private static detectHandlerFilePath(file_path?: string, fallback: string = 'unknown'): string | undefined {
+ const extract = (value: string): string =>
+ value.trim().match(/\(([^)]+)\)$/)?.[1] ??
+ value.trim().match(/^\s*at\s+(.+)$/)?.[1] ??
+ value.trim().match(/^[^@]+@(.+)$/)?.[1] ??
+ value.trim()
+ let resolved_path = file_path ? extract(file_path) : file_path
+ if (!resolved_path) {
+ const line = new Error().stack
+ ?.split('\n')
+ .map((l) => l.trim())
+ .filter(Boolean)[4]
+ if (line) resolved_path = extract(line)
+ }
+ if (!resolved_path) return fallback
+ const match = resolved_path.match(/^(.*?):(\d+)(?::\d+)?$/)
+ let normalized = match ? match[1] : resolved_path
+ const line_number = match?.[2]
+ if (normalized.startsWith('file://')) {
+ let path = normalized.slice('file://'.length)
+ if (path.startsWith('localhost/')) path = path.slice('localhost'.length)
+ if (!path.startsWith('/')) path = `/${path}`
+ try {
+ normalized = decodeURIComponent(path)
+ } catch {
+ normalized = path
+ }
+ }
+ normalized = normalized.replace(/\/users\/[^/]+\//i, '~/').replace(/\/home\/[^/]+\//i, '~/')
+ return line_number ? `${normalized}:${line_number}` : normalized
+ }
+}
+
+// Generic base TimeoutError used for EventHandlerTimeoutError.cause default value if
+export class TimeoutError extends Error {
+ constructor(message: string) {
+ super(message)
+ this.name = 'TimeoutError'
+ }
+}
+
+// Base class for all errors that can occur while running an event handler
+export class EventHandlerError extends Error {
+ event_result: EventResult
+ timeout_seconds: number | null
+ cause: Error
+
+ constructor(message: string, params: { event_result: EventResult; timeout_seconds?: number | null; cause: Error }) {
+ super(message)
+ this.name = 'EventHandlerError'
+ this.event_result = params.event_result
+ this.cause = params.cause
+ this.timeout_seconds = params.timeout_seconds ?? this.event_result.event.event_timeout ?? null
+ }
+
+ get event(): BaseEvent {
+ return this.event_result.event
+ }
+
+ get event_type(): string {
+ return this.event.event_type
+ }
+
+ get handler_name(): string {
+ return this.event_result.handler_name
+ }
+
+ get handler_id(): string {
+ return this.event_result.handler_id
+ }
+
+ get event_timeout(): number | null {
+ return this.event.event_timeout
+ }
+}
+
+// When the handler itself timed out while executing (due to handler.handler_timeout being exceeded)
+export class EventHandlerTimeoutError extends EventHandlerError {
+ constructor(message: string, params: { event_result: EventResult; timeout_seconds?: number | null; cause?: Error }) {
+ super(message, {
+ event_result: params.event_result,
+ timeout_seconds: params.timeout_seconds,
+ cause: params.cause ?? new TimeoutError(message),
+ })
+ this.name = 'EventHandlerTimeoutError'
+ }
+}
+
+// When a pending handler was cancelled and never run due to an error (e.g. timeout) in a parent scope
+export class EventHandlerCancelledError extends EventHandlerError {
+ constructor(message: string, params: { event_result: EventResult; timeout_seconds?: number | null; cause: Error }) {
+ super(message, params)
+ this.name = 'EventHandlerCancelledError'
+ }
+}
+
+// When a handler that was already running was aborted due to an error in the parent scope, not due to an error in its own logic / exceeding its own timeout
+export class EventHandlerAbortedError extends EventHandlerError {
+ constructor(message: string, params: { event_result: EventResult; timeout_seconds?: number | null; cause: Error }) {
+ super(message, params)
+ this.name = 'EventHandlerAbortedError'
+ }
+}
+
+// When a handler run succesfully but returned a value that failed event_result_schema validation
+export class EventHandlerResultSchemaError extends EventHandlerError {
+ raw_value: unknown
+
+ constructor(message: string, params: { event_result: EventResult; timeout_seconds?: number | null; cause: Error; raw_value: unknown }) {
+ super(message, params)
+ this.name = 'EventHandlerResultSchemaError'
+ this.raw_value = params.raw_value
+ }
+}
diff --git a/bubus-ts/src/event_result.ts b/bubus-ts/src/event_result.ts
new file mode 100644
index 0000000..5d6ef20
--- /dev/null
+++ b/bubus-ts/src/event_result.ts
@@ -0,0 +1,252 @@
+import { v7 as uuidv7 } from 'uuid'
+
+import { BaseEvent } from './base_event.js'
+import type { EventHandler } from './event_handler.js'
+import { HandlerLock, type ConcurrencyMode, withResolvers } from './lock_manager.js'
+import type { Deferred } from './lock_manager.js'
+import type { EventHandlerFunction, EventResultType } from './types.js'
+
+// More precise than event.event_status, includes separate 'error' state for handlers that throw errors during execution
+export type EventResultStatus = 'pending' | 'started' | 'completed' | 'error'
+
+export type EventResultData = {
+ id?: string
+ status?: EventResultStatus
+ event_id?: string
+ handler?: {
+ id?: string
+ handler_name?: string
+ handler_file_path?: string
+ handler_timeout?: number | null
+ event_handler_concurrency?: ConcurrencyMode
+ handler_registered_at?: string
+ handler_registered_ts?: number
+ event_key?: string | '*'
+ eventbus_name?: string
+ }
+ started_at?: string
+ started_ts?: number
+ completed_at?: string
+ completed_ts?: number
+ result?: unknown
+ error?: unknown
+ event_children?: string[]
+}
+
+// Object that tracks the pending or completed execution of a single event handler
+export class EventResult {
+ id: string // unique uuidv7 identifier for the event result
+ status: EventResultStatus // 'pending', 'started', 'completed', or 'error'
+ event: TEvent // the Event that the handler is processing
+ handler: EventHandler // the EventHandler object that going to process the event
+ started_at?: string // ISO datetime string version of started_ts
+ started_ts?: number // nanosecond monotonic version of started_at
+ completed_at?: string // ISO datetime string version of completed_ts
+ completed_ts?: number // nanosecond monotonic version of completed_at
+ result?: EventResultType // parsed return value from the event handler
+ error?: unknown // error object thrown by the event handler, or null if the handler completed successfully
+ event_children: BaseEvent[] // any child events that were emitted during handler execution are captured automatically and stored here to track hierarchy
+
+ // Abort signal: created when handler starts, rejected by signalAbort() to
+ // interrupt runEventHandler's await via Promise.race.
+ _abort: Deferred | null
+ // Handler lock: tracks ownership of the handler concurrency semaphore
+ // during handler execution. Set by EventBus.runEventHandler, used by
+ // processEventImmediately for yield-and-reacquire during queue-jumps.
+ _lock: HandlerLock | null
+
+ constructor(params: { event: TEvent; handler: EventHandler }) {
+ this.id = uuidv7()
+ this.status = 'pending'
+ this.event = params.event
+ this.handler = params.handler
+ this.event_children = []
+ this.result = undefined
+ this.error = undefined
+ this._abort = null
+ this._lock = null
+ }
+
+ toString(): string {
+ return `${this.result ?? 'null'} (${this.status})`
+ }
+
+ get event_id(): string {
+ return this.event.event_id
+ }
+
+ get handler_id(): string {
+ return this.handler.id
+ }
+
+ get handler_name(): string {
+ return this.handler.handler_name
+ }
+
+ get handler_file_path(): string | undefined {
+ return this.handler.handler_file_path
+ }
+
+ get handler_timeout(): number | null {
+ return this.handler.handler_timeout
+ }
+
+ get eventbus_name(): string {
+ return this.handler.eventbus_name
+ }
+
+ // shortcut for the result value so users can do event_result.value instead of event_result.result
+ get value(): EventResultType | undefined {
+ return this.result
+ }
+
+ // Link a child event emitted by this handler run to the parent event/result.
+ linkEmittedChildEvent(child_event: BaseEvent): void {
+ const original_child = child_event._event_original ?? child_event
+ const parent_event = this.event._event_original ?? this.event
+ if (!original_child.event_parent_id) {
+ original_child.event_parent_id = parent_event.event_id
+ }
+ if (!original_child.event_emitted_by_handler_id) {
+ original_child.event_emitted_by_handler_id = this.handler_id
+ }
+ if (!this.event_children.some((child) => child.event_id === original_child.event_id)) {
+ this.event_children.push(original_child)
+ }
+ }
+
+ // Get the raw return value from the handler, even if it threw an error / failed validation
+ get raw_value(): EventResultType | undefined {
+ if (this.error && (this.error as any).raw_value !== undefined) {
+ return (this.error as any).raw_value
+ }
+ return this.result
+ }
+
+ // Reject the abort promise, causing runEventHandler's Promise.race to
+ // throw immediately β even if the handler has no timeout.
+ signalAbort(error: Error): void {
+ if (this._abort) {
+ this._abort.reject(error)
+ this._abort = null
+ }
+ }
+
+ // Mark started and return the abort promise for Promise.race.
+ markStarted(): Promise {
+ if (!this._abort) {
+ this._abort = withResolvers()
+ }
+ if (this.status === 'pending') {
+ this.status = 'started'
+ const { isostring: started_at, ts: started_ts } = BaseEvent.nextTimestamp()
+ this.started_at = started_at
+ this.started_ts = started_ts
+ }
+ return this._abort.promise
+ }
+
+ markCompleted(result: EventResultType | undefined): void {
+ if (this.status === 'completed' || this.status === 'error') return
+ this.status = 'completed'
+ this.result = result
+ const { isostring: completed_at, ts: completed_ts } = BaseEvent.nextTimestamp()
+ this.completed_at = completed_at
+ this.completed_ts = completed_ts
+ }
+
+ markError(error: unknown): void {
+ if (this.status === 'completed' || this.status === 'error') return
+ this.status = 'error'
+ this.error = error
+ const { isostring: completed_at, ts: completed_ts } = BaseEvent.nextTimestamp()
+ this.completed_at = completed_at
+ this.completed_ts = completed_ts
+ }
+
+ toJSON(): EventResultData {
+ return {
+ id: this.id,
+ status: this.status,
+ event_id: this.event.event_id,
+ handler: {
+ id: this.handler.id,
+ handler_name: this.handler.handler_name,
+ handler_file_path: this.handler.handler_file_path,
+ handler_timeout: this.handler.handler_timeout,
+ event_handler_concurrency: this.handler.event_handler_concurrency,
+ handler_registered_at: this.handler.handler_registered_at,
+ handler_registered_ts: this.handler.handler_registered_ts,
+ event_key: this.handler.event_key,
+ eventbus_name: this.handler.eventbus_name,
+ },
+ started_at: this.started_at,
+ started_ts: this.started_ts,
+ completed_at: this.completed_at,
+ completed_ts: this.completed_ts,
+ result: this.result,
+ error: this.error,
+ event_children: this.event_children.map((child) => child.event_id),
+ }
+ }
+
+ static fromJSON(event: TEvent, data: unknown): EventResult | null {
+ if (!data || typeof data !== 'object') {
+ return null
+ }
+ const record = data as EventResultData
+ const handler_record = record.handler ?? {}
+
+ const handler_stub = {
+ id: typeof handler_record.id === 'string' ? handler_record.id : `deserialized_handler_${uuidv7()}`,
+ handler: (() => undefined) as EventHandlerFunction,
+ handler_name: typeof handler_record.handler_name === 'string' ? handler_record.handler_name : 'deserialized_handler',
+ handler_file_path: typeof handler_record.handler_file_path === 'string' ? handler_record.handler_file_path : undefined,
+ handler_timeout:
+ typeof handler_record.handler_timeout === 'number' || handler_record.handler_timeout === null
+ ? handler_record.handler_timeout
+ : null,
+ event_handler_concurrency: handler_record.event_handler_concurrency,
+ handler_registered_at:
+ typeof handler_record.handler_registered_at === 'string' ? handler_record.handler_registered_at : event.event_created_at,
+ handler_registered_ts:
+ typeof handler_record.handler_registered_ts === 'number' ? handler_record.handler_registered_ts : event.event_created_ts,
+ event_key:
+ handler_record.event_key === '*' || typeof handler_record.event_key === 'string' ? handler_record.event_key : event.event_type,
+ eventbus_name: typeof handler_record.eventbus_name === 'string' ? handler_record.eventbus_name : (event.bus?.name ?? 'unknown'),
+ toString: () => {
+ const name = typeof handler_record.handler_name === 'string' ? handler_record.handler_name : 'deserialized_handler'
+ const file = typeof handler_record.handler_file_path === 'string' ? handler_record.handler_file_path : 'unknown'
+ return `${name}() (${file})`
+ },
+ } as unknown as EventHandler
+
+ const result = new EventResult({ event, handler: handler_stub })
+ if (typeof record.id === 'string') {
+ result.id = record.id
+ }
+ if (record.status === 'pending' || record.status === 'started' || record.status === 'completed' || record.status === 'error') {
+ result.status = record.status
+ }
+ if (typeof record.started_at === 'string') {
+ result.started_at = record.started_at
+ }
+ if (typeof record.started_ts === 'number') {
+ result.started_ts = record.started_ts
+ }
+ if (typeof record.completed_at === 'string') {
+ result.completed_at = record.completed_at
+ }
+ if (typeof record.completed_ts === 'number') {
+ result.completed_ts = record.completed_ts
+ }
+ if ('result' in record) {
+ result.result = record.result as EventResultType
+ }
+ if ('error' in record) {
+ result.error = record.error
+ }
+ result.event_children = []
+ return result
+ }
+}
diff --git a/bubus-ts/src/index.ts b/bubus-ts/src/index.ts
new file mode 100644
index 0000000..ed57151
--- /dev/null
+++ b/bubus-ts/src/index.ts
@@ -0,0 +1,13 @@
+export { BaseEvent, BaseEventSchema } from './base_event.js'
+export { EventResult } from './event_result.js'
+export { EventBus } from './event_bus.js'
+export {
+ EventHandlerTimeoutError,
+ EventHandlerCancelledError,
+ EventHandlerAbortedError,
+ EventHandlerResultSchemaError,
+} from './event_handler.js'
+export type { ConcurrencyMode, EventBusInterfaceForLockManager } from './lock_manager.js'
+export type { EventClass, EventHandlerFunction as EventHandler, EventKey, EventStatus, FindOptions, FindWindow } from './types.js'
+export { retry, clearSemaphoreRegistry, RetryTimeoutError, SemaphoreTimeoutError } from './retry.js'
+export type { RetryOptions } from './retry.js'
diff --git a/bubus-ts/src/lock_manager.ts b/bubus-ts/src/lock_manager.ts
new file mode 100644
index 0000000..d814368
--- /dev/null
+++ b/bubus-ts/src/lock_manager.ts
@@ -0,0 +1,377 @@
+import type { BaseEvent } from './base_event.js'
+import type { EventHandler } from './event_handler.js'
+import type { EventResult } from './event_result.js'
+
+// βββ Deferred / withResolvers ββββββββββββββββββββββββββββββββββββββββββββββββ
+
+export type Deferred = {
+ promise: Promise
+ resolve: (value: T | PromiseLike) => void
+ reject: (reason?: unknown) => void
+}
+
+export const withResolvers = (): Deferred => {
+ if (typeof Promise.withResolvers === 'function') {
+ return Promise.withResolvers()
+ }
+ let resolve!: (value: T | PromiseLike) => void
+ let reject!: (reason?: unknown) => void
+ const promise = new Promise((resolve_fn, reject_fn) => {
+ resolve = resolve_fn
+ reject = reject_fn
+ })
+ return { promise, resolve, reject }
+}
+
+// βββ Concurrency modes ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+export const CONCURRENCY_MODES = ['global-serial', 'bus-serial', 'parallel', 'auto'] as const
+export type ConcurrencyMode = (typeof CONCURRENCY_MODES)[number] // union type of the values in the CONCURRENCY_MODES array
+export const DEFAULT_CONCURRENCY_MODE = 'bus-serial'
+
+export const resolveConcurrencyMode = (mode: ConcurrencyMode | undefined, fallback: ConcurrencyMode): ConcurrencyMode => {
+ const normalized_fallback = fallback === 'auto' ? DEFAULT_CONCURRENCY_MODE : fallback
+ if (!mode || mode === 'auto') {
+ return normalized_fallback
+ }
+ return mode
+}
+
+// βββ AsyncSemaphore ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+export class AsyncSemaphore {
+ size: number
+ in_use: number
+ waiters: Array<() => void>
+
+ constructor(size: number) {
+ this.size = size
+ this.in_use = 0
+ this.waiters = []
+ }
+
+ async acquire(): Promise {
+ if (this.size === Infinity) {
+ return
+ }
+ if (this.in_use < this.size) {
+ this.in_use += 1
+ return
+ }
+ await new Promise((resolve) => {
+ this.waiters.push(resolve)
+ })
+ this.in_use += 1
+ }
+
+ release(): void {
+ if (this.size === Infinity) {
+ return
+ }
+ this.in_use = Math.max(0, this.in_use - 1)
+ const next = this.waiters.shift()
+ if (next) {
+ next()
+ }
+ }
+}
+
+export const semaphoreForMode = (
+ mode: ConcurrencyMode,
+ global_semaphore: AsyncSemaphore,
+ bus_semaphore: AsyncSemaphore
+): AsyncSemaphore | null => {
+ if (mode === 'parallel') {
+ return null
+ }
+ if (mode === 'global-serial') {
+ return global_semaphore
+ }
+ if (mode === 'bus-serial') {
+ return bus_semaphore
+ }
+ return bus_semaphore
+}
+
+export const runWithSemaphore = async (semaphore: AsyncSemaphore | null, fn: () => Promise): Promise => {
+ if (!semaphore) {
+ return await fn()
+ }
+ await semaphore.acquire()
+ try {
+ return await fn()
+ } finally {
+ semaphore.release()
+ }
+}
+
+// βββ HandlerLock βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+export type HandlerExecutionState = 'held' | 'yielded' | 'closed'
+
+// Tracks a single handler execution's ownership of a semaphore lock.
+// Reacquire is race-safe: if the handler exits while waiting to reclaim,
+// the reclaimed lock is immediately released to avoid leaks.
+export class HandlerLock {
+ private semaphore: AsyncSemaphore | null
+ private state: HandlerExecutionState
+
+ constructor(semaphore: AsyncSemaphore | null) {
+ this.semaphore = semaphore
+ this.state = 'held'
+ }
+
+ // used by EventBus.processEventImmediately to yield the parent handler's lock to the child event so it can be processed immediately
+ yieldHandlerLockForChildRun(): boolean {
+ if (!this.semaphore || this.state !== 'held') {
+ return false
+ }
+ this.state = 'yielded'
+ this.semaphore.release()
+ return true
+ }
+
+ // used by EventBus.processEventImmediately to reacquire the handler lock after the child event has been processed
+ async reclaimHandlerLockIfRunning(): Promise {
+ if (!this.semaphore || this.state !== 'yielded') {
+ return false
+ }
+ await this.semaphore.acquire()
+ if (this.state !== 'yielded') {
+ // Handler exited while this reacquire was pending.
+ this.semaphore.release()
+ return false
+ }
+ this.state = 'held'
+ return true
+ }
+
+ // used by EventBus.runEventHandler to exit the handler lock after the handler has finished executing
+ exitHandlerRun(): void {
+ if (this.state === 'closed') {
+ return
+ }
+ const should_release = !!this.semaphore && this.state === 'held'
+ this.state = 'closed'
+ if (should_release) {
+ this.semaphore!.release()
+ }
+ }
+
+ // used by EventBus.processEventImmediately to yield the handler lock and reacquire it after the child event has been processed
+ async runQueueJump(fn: () => Promise): Promise {
+ const yielded = this.yieldHandlerLockForChildRun()
+ try {
+ return await fn()
+ } finally {
+ if (yielded) {
+ await this.reclaimHandlerLockIfRunning()
+ }
+ }
+ }
+}
+
+// βββ LockManager βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+// Interface that must be implemented by the EventBus class to be used by the LockManager
+export type EventBusInterfaceForLockManager = {
+ isIdleAndQueueEmpty: () => boolean
+ event_concurrency_default: ConcurrencyMode
+ event_handler_concurrency_default: ConcurrencyMode
+}
+
+// The LockManager is responsible for managing the concurrency of events and handlers
+export class LockManager {
+ static global_event_semaphore = new AsyncSemaphore(1) // used for the global-serial concurrency mode
+ static global_handler_semaphore = new AsyncSemaphore(1) // used for the global-serial concurrency mode
+
+ private bus: EventBusInterfaceForLockManager // Live bus reference; used to read defaults and idle state.
+ readonly bus_event_semaphore: AsyncSemaphore // Per-bus event semaphore; created with LockManager and never swapped.
+ readonly bus_handler_semaphore: AsyncSemaphore // Per-bus handler semaphore; created with LockManager and never swapped.
+
+ private pause_depth: number // Re-entrant pause counter; increments on requestPause, decrements on release.
+ private pause_waiters: Array<() => void> // Resolvers for waitUntilRunloopResumed; drained when pause_depth hits 0.
+ private queue_jump_pause_releases: WeakMap void> // Per-handler pause release for queue-jump; cleared on handler exit.
+ private active_handler_results: EventResult[] // Stack of active handler results for "inside handler" detection.
+
+ private idle_waiters: Array<() => void> // Resolvers waiting for stable idle; cleared when idle confirmed.
+ private idle_check_pending: boolean // Debounce flag to avoid scheduling redundant idle checks.
+ private idle_check_streak: number // Counts consecutive idle checks; used to require two ticks of idle.
+
+ constructor(bus: EventBusInterfaceForLockManager) {
+ this.bus = bus
+ this.bus_event_semaphore = new AsyncSemaphore(1) // used for the bus-serial concurrency mode
+ this.bus_handler_semaphore = new AsyncSemaphore(1) // used for the bus-serial concurrency mode
+
+ this.pause_depth = 0
+ this.pause_waiters = []
+ this.queue_jump_pause_releases = new WeakMap()
+ this.active_handler_results = []
+
+ this.idle_waiters = []
+ this.idle_check_pending = false
+ this.idle_check_streak = 0
+ }
+
+ // Low-level runloop pause: increments a re-entrant counter and returns a release
+ // function. Used for broad, bus-scoped pauses (e.g. runImmediatelyAcrossBuses).
+ requestPause(): () => void {
+ this.pause_depth += 1
+ let released = false
+ return () => {
+ if (released) {
+ return
+ }
+ released = true
+ this.pause_depth = Math.max(0, this.pause_depth - 1)
+ if (this.pause_depth !== 0) {
+ return
+ }
+ const waiters = this.pause_waiters
+ this.pause_waiters = []
+ for (const resolve of waiters) {
+ resolve()
+ }
+ }
+ }
+
+ waitUntilRunloopResumed(): Promise {
+ if (this.pause_depth === 0) {
+ return Promise.resolve()
+ }
+ return new Promise((resolve) => {
+ this.pause_waiters.push(resolve)
+ })
+ }
+
+ isPaused(): boolean {
+ return this.pause_depth > 0
+ }
+
+ enterActiveHandlerContext(result: EventResult): void {
+ this.active_handler_results.push(result)
+ }
+
+ exitActiveHandlerContext(result: EventResult): void {
+ const idx = this.active_handler_results.indexOf(result)
+ if (idx >= 0) {
+ this.active_handler_results.splice(idx, 1)
+ }
+ }
+
+ getActiveHandlerResult(): EventResult | undefined {
+ return this.active_handler_results[this.active_handler_results.length - 1]
+ }
+
+ // Per-bus check: true only if this specific bus has a handler on its stack.
+ // For cross-bus queue-jumping, EventBus.processEventImmediately uses getParentEventResultAcrossAllBusses()
+ // to walk up the parent event tree, and the bus proxy passes handler_result
+ // to processEventImmediately so it can yield/reacquire the correct semaphore.
+ isAnyHandlerActive(): boolean {
+ return this.active_handler_results.length > 0
+ }
+
+ // Queue-jump pause: wraps requestPause with per-handler deduping so repeated
+ // calls during the same handler run don't stack pauses. Released via
+ // releaseRunloopPauseForQueueJumpEvent when the handler finishes.
+ requestRunloopPauseForQueueJumpEvent(result: EventResult): void {
+ if (this.queue_jump_pause_releases.has(result)) {
+ return
+ }
+ this.queue_jump_pause_releases.set(result, this.requestPause())
+ }
+
+ // release the eventt bus runloop pause for a given event result if there is a pause request for it
+ // i.e. if it was a queue-jump event that was processed immediately, notify the runloop to resume
+ releaseRunloopPauseForQueueJumpEvent(result: EventResult): void {
+ const release_pause = this.queue_jump_pause_releases.get(result)
+ if (!release_pause) {
+ return
+ }
+ this.queue_jump_pause_releases.delete(result)
+ release_pause()
+ }
+
+ waitForIdle(): Promise {
+ if (this.bus.isIdleAndQueueEmpty()) {
+ return Promise.resolve()
+ }
+ return new Promise((resolve) => {
+ this.idle_waiters.push(resolve)
+ this.scheduleIdleCheck()
+ })
+ }
+
+ // Called by EventBus.markEventCompleted and EventBus.markHandlerCompleted to notify
+ // waitUntilIdle() callers that the bus may now be idle.
+ notifyIdleListeners(): void {
+ // Fast-path: most completions have no waitUntilIdle() callers waiting,
+ // so skip expensive idle snapshot scans in that common case.
+ if (this.idle_waiters.length === 0) {
+ this.idle_check_streak = 0
+ return
+ }
+
+ if (!this.bus.isIdleAndQueueEmpty()) {
+ this.idle_check_streak = 0
+ if (this.idle_waiters.length > 0) {
+ this.scheduleIdleCheck()
+ }
+ return
+ }
+
+ this.idle_check_streak += 1
+ if (this.idle_check_streak < 2) {
+ if (this.idle_waiters.length > 0) {
+ this.scheduleIdleCheck()
+ }
+ return
+ }
+
+ this.idle_check_streak = 0
+ const waiters = this.idle_waiters
+ this.idle_waiters = []
+ for (const resolve of waiters) {
+ resolve()
+ }
+ }
+
+ getSemaphoreForEvent(event: BaseEvent): AsyncSemaphore | null {
+ const resolved = resolveConcurrencyMode(event.event_concurrency, this.bus.event_concurrency_default)
+ return semaphoreForMode(resolved, LockManager.global_event_semaphore, this.bus_event_semaphore)
+ }
+
+ getSemaphoreForHandler(event: BaseEvent, handler?: Pick): AsyncSemaphore | null {
+ const event_override =
+ event.event_handler_concurrency && event.event_handler_concurrency !== 'auto' ? event.event_handler_concurrency : undefined
+ const handler_override =
+ handler?.event_handler_concurrency && handler.event_handler_concurrency !== 'auto' ? handler.event_handler_concurrency : undefined
+ const fallback = this.bus.event_handler_concurrency_default
+ const resolved = resolveConcurrencyMode(event_override ?? handler_override ?? fallback, fallback)
+ return semaphoreForMode(resolved, LockManager.global_handler_semaphore, this.bus_handler_semaphore)
+ }
+
+ // Schedules a debounced idle check to run after a short delay. Used to gate
+ // waitUntilIdle() calls during handler execution and after event completion.
+ private scheduleIdleCheck(): void {
+ if (this.idle_check_pending) {
+ return
+ }
+ this.idle_check_pending = true
+ setTimeout(() => {
+ this.idle_check_pending = false
+ this.notifyIdleListeners()
+ }, 0)
+ }
+
+ // Reset all state to initial values
+ clear(): void {
+ this.pause_depth = 0
+ this.pause_waiters = []
+ this.queue_jump_pause_releases = new WeakMap()
+ this.active_handler_results = []
+ this.idle_waiters = []
+ this.idle_check_pending = false
+ this.idle_check_streak = 0
+ }
+}
diff --git a/bubus-ts/src/logging.ts b/bubus-ts/src/logging.ts
new file mode 100644
index 0000000..8d242e7
--- /dev/null
+++ b/bubus-ts/src/logging.ts
@@ -0,0 +1,242 @@
+import { BaseEvent } from './base_event.js'
+import { EventResult } from './event_result.js'
+import { EventHandlerCancelledError, EventHandlerTimeoutError } from './event_handler.js'
+
+type LogTreeBus = {
+ name: string
+ event_history: Map
+}
+
+export const logTree = (bus: LogTreeBus): string => {
+ const parent_to_children = new Map()
+
+ const add_child = (parent_id: string, child: BaseEvent): void => {
+ const existing = parent_to_children.get(parent_id) ?? []
+ existing.push(child)
+ parent_to_children.set(parent_id, existing)
+ }
+
+ const root_events: BaseEvent[] = []
+ const seen = new Set()
+
+ for (const event of bus.event_history.values()) {
+ const parent_id = event.event_parent_id
+ if (!parent_id || parent_id === event.event_id || !bus.event_history.has(parent_id)) {
+ if (!seen.has(event.event_id)) {
+ root_events.push(event)
+ seen.add(event.event_id)
+ }
+ }
+ }
+
+ if (root_events.length === 0) {
+ return '(No events in history)'
+ }
+
+ const nodes_by_id = new Map()
+ for (const root of root_events) {
+ nodes_by_id.set(root.event_id, root)
+ for (const descendant of root.event_descendants) {
+ nodes_by_id.set(descendant.event_id, descendant)
+ }
+ }
+
+ for (const node of nodes_by_id.values()) {
+ const parent_id = node.event_parent_id
+ if (!parent_id || parent_id === node.event_id) {
+ continue
+ }
+ if (!nodes_by_id.has(parent_id)) {
+ continue
+ }
+ add_child(parent_id, node)
+ }
+
+ for (const children of parent_to_children.values()) {
+ children.sort((a, b) => (a.event_created_at < b.event_created_at ? -1 : a.event_created_at > b.event_created_at ? 1 : 0))
+ }
+
+ const lines: string[] = []
+ lines.push(`π Event History Tree for ${bus.name}`)
+ lines.push('='.repeat(80))
+
+ root_events.sort((a, b) => (a.event_created_at < b.event_created_at ? -1 : a.event_created_at > b.event_created_at ? 1 : 0))
+ const visited = new Set()
+ root_events.forEach((event, index) => {
+ lines.push(buildTreeLine(event, '', index === root_events.length - 1, parent_to_children, visited))
+ })
+
+ lines.push('='.repeat(80))
+
+ return lines.join('\n')
+}
+
+export const buildTreeLine = (
+ event: BaseEvent,
+ indent: string,
+ is_last: boolean,
+ parent_to_children: Map,
+ visited: Set
+): string => {
+ const connector = is_last ? 'βββ ' : 'βββ '
+ const status_icon = event.event_status === 'completed' ? 'β ' : event.event_status === 'started' ? 'π' : 'β³'
+
+ const created_at = formatTimestamp(event.event_created_at)
+ let timing = `[${created_at}`
+ if (event.event_completed_at) {
+ const created_ms = Date.parse(event.event_created_at)
+ const completed_ms = Date.parse(event.event_completed_at)
+ if (!Number.isNaN(created_ms) && !Number.isNaN(completed_ms)) {
+ const duration = (completed_ms - created_ms) / 1000
+ timing += ` (${duration.toFixed(3)}s)`
+ }
+ }
+ timing += ']'
+
+ const line = `${indent}${connector}${status_icon} ${event.event_type}#${event.event_id.slice(-4)} ${timing}`
+
+ if (visited.has(event.event_id)) {
+ return line
+ }
+ visited.add(event.event_id)
+
+ const extension = is_last ? ' ' : 'β '
+ const new_indent = indent + extension
+
+ const result_items: Array<{ type: 'result'; result: EventResult } | { type: 'child'; child: BaseEvent }> = []
+ for (const result of event.event_results.values()) {
+ result_items.push({ type: 'result', result })
+ }
+ const children = parent_to_children.get(event.event_id) ?? []
+ const printed_child_ids = new Set(event.event_results.size > 0 ? event.event_results.keys() : [])
+ for (const child of children) {
+ if (!printed_child_ids.has(child.event_id) && !child.event_emitted_by_handler_id) {
+ result_items.push({ type: 'child', child })
+ printed_child_ids.add(child.event_id)
+ }
+ }
+
+ if (result_items.length === 0) {
+ return line
+ }
+
+ const child_lines: string[] = []
+ result_items.forEach((item, index) => {
+ const is_last_item = index === result_items.length - 1
+ if (item.type === 'result') {
+ child_lines.push(buildResultLine(item.result, new_indent, is_last_item, parent_to_children, visited))
+ } else {
+ child_lines.push(buildTreeLine(item.child, new_indent, is_last_item, parent_to_children, visited))
+ }
+ })
+
+ return [line, ...child_lines].join('\n')
+}
+
+export const buildResultLine = (
+ result: EventResult,
+ indent: string,
+ is_last: boolean,
+ parent_to_children: Map,
+ visited: Set
+): string => {
+ const connector = is_last ? 'βββ ' : 'βββ '
+ const status_icon = result.status === 'completed' ? 'β ' : result.status === 'error' ? 'β' : result.status === 'started' ? 'π' : 'β³'
+
+ const handler_label =
+ result.handler_name && result.handler_name !== 'anonymous'
+ ? result.handler_name
+ : result.handler_file_path
+ ? result.handler_file_path
+ : 'anonymous'
+ const handler_display = `${result.eventbus_name}.${handler_label}#${result.handler_id.slice(-4)}`
+ let line = `${indent}${connector}${status_icon} ${handler_display}`
+
+ if (result.started_at) {
+ line += ` [${formatTimestamp(result.started_at)}`
+ if (result.completed_at) {
+ const started_ms = Date.parse(result.started_at)
+ const completed_ms = Date.parse(result.completed_at)
+ if (!Number.isNaN(started_ms) && !Number.isNaN(completed_ms)) {
+ const duration = (completed_ms - started_ms) / 1000
+ line += ` (${duration.toFixed(3)}s)`
+ }
+ }
+ line += ']'
+ }
+
+ if (result.status === 'error' && result.error) {
+ if (result.error instanceof EventHandlerTimeoutError) {
+ line += ` β±οΈ Timeout: ${result.error.message}`
+ } else if (result.error instanceof EventHandlerCancelledError) {
+ line += ` π« Cancelled: ${result.error.message}`
+ } else {
+ const error_name = result.error instanceof Error ? result.error.name : 'Error'
+ const error_message = result.error instanceof Error ? result.error.message : String(result.error)
+ line += ` β οΈ ${error_name}: ${error_message}`
+ }
+ } else if (result.status === 'completed') {
+ line += ` β ${formatResultValue(result.result)}`
+ }
+
+ const extension = is_last ? ' ' : 'β '
+ const new_indent = indent + extension
+
+ if (result.event_children.length === 0) {
+ return line
+ }
+
+ const child_lines: string[] = []
+ const direct_children = result.event_children
+ const parent_children = parent_to_children.get(result.event_id) ?? []
+ const emitted_children = parent_children.filter((child) => child.event_emitted_by_handler_id === result.handler_id)
+ const children_by_id = new Map()
+ direct_children.forEach((child) => {
+ children_by_id.set(child.event_id, child)
+ })
+ emitted_children.forEach((child) => {
+ if (!children_by_id.has(child.event_id)) {
+ children_by_id.set(child.event_id, child)
+ }
+ })
+ const children_to_print = Array.from(children_by_id.values()).filter((child) => !visited.has(child.event_id))
+
+ children_to_print.forEach((child, index) => {
+ child_lines.push(buildTreeLine(child, new_indent, index === children_to_print.length - 1, parent_to_children, visited))
+ })
+
+ return [line, ...child_lines].join('\n')
+}
+
+export const formatTimestamp = (value?: string): string => {
+ if (!value) {
+ return 'N/A'
+ }
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) {
+ return 'N/A'
+ }
+ return date.toISOString().slice(11, 23)
+}
+
+export const formatResultValue = (value: unknown): string => {
+ if (value === null || value === undefined) {
+ return 'None'
+ }
+ if (value instanceof BaseEvent) {
+ return `Event(${value.event_type}#${value.event_id.slice(-4)})`
+ }
+ if (typeof value === 'string') {
+ return JSON.stringify(value)
+ }
+ if (typeof value === 'number' || typeof value === 'boolean') {
+ return String(value)
+ }
+ if (Array.isArray(value)) {
+ return `list(${value.length} items)`
+ }
+ if (typeof value === 'object') {
+ return `dict(${Object.keys(value as Record).length} items)`
+ }
+ return `${typeof value}(...)`
+}
diff --git a/bubus-ts/src/retry.ts b/bubus-ts/src/retry.ts
new file mode 100644
index 0000000..8ef1542
--- /dev/null
+++ b/bubus-ts/src/retry.ts
@@ -0,0 +1,346 @@
+import { AsyncSemaphore } from './lock_manager.js'
+import { createAsyncLocalStorage, type AsyncLocalStorageLike } from './async_context.js'
+
+// βββ Types βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+export interface RetryOptions {
+ /** Total number of attempts including the initial call (1 = no retry, 3 = up to 2 retries). Default: 1 */
+ max_attempts?: number
+
+ /** Seconds to wait between retries. Default: 0 */
+ retry_after?: number
+
+ /** Multiplier applied to retry_after after each attempt for exponential backoff. Default: 1.0 (constant delay) */
+ retry_backoff_factor?: number
+
+ /** Only retry when the thrown error matches one of these matchers. Accepts error class constructors,
+ * string error names (matched against error.name), or RegExp patterns (tested against String(error)).
+ * Default: undefined (retry on any error) */
+ retry_on_errors?: Array<(new (...args: any[]) => Error) | string | RegExp>
+
+ /** Per-attempt timeout in seconds. Default: undefined (no per-attempt timeout) */
+ timeout?: number | null
+
+ /** Maximum concurrent executions sharing this semaphore. Default: undefined (no concurrency limit) */
+ semaphore_limit?: number | null
+
+ /** Semaphore identifier. Functions with the same name share the same concurrency slot pool. Default: function name */
+ semaphore_name?: string | null
+
+ /** If true, proceed without concurrency limit when semaphore acquisition times out. Default: true */
+ semaphore_lax?: boolean
+
+ /** Semaphore scoping strategy. Default: 'global'
+ * - 'global': all calls share one semaphore (keyed by semaphore_name)
+ * - 'class': all instances of the same class share one semaphore (keyed by className.semaphore_name)
+ * - 'instance': each object instance gets its own semaphore (keyed by instanceId.semaphore_name)
+ * 'class' and 'instance' require `this` to be an object; they fall back to 'global' for standalone calls. */
+ semaphore_scope?: 'global' | 'class' | 'instance'
+
+ /** Maximum seconds to wait for semaphore acquisition. Default: undefined β timeout * max(1, limit - 1) */
+ semaphore_timeout?: number | null
+}
+
+// βββ Errors ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+/** Thrown when a single attempt exceeds the per-attempt timeout. */
+export class RetryTimeoutError extends Error {
+ timeout_seconds: number
+ attempt: number
+
+ constructor(message: string, params: { timeout_seconds: number; attempt: number }) {
+ super(message)
+ this.name = 'RetryTimeoutError'
+ this.timeout_seconds = params.timeout_seconds
+ this.attempt = params.attempt
+ }
+}
+
+/** Thrown (when semaphore_lax=false) if the semaphore cannot be acquired within the timeout. */
+export class SemaphoreTimeoutError extends Error {
+ semaphore_name: string
+ semaphore_limit: number
+ timeout_seconds: number
+
+ constructor(message: string, params: { semaphore_name: string; semaphore_limit: number; timeout_seconds: number }) {
+ super(message)
+ this.name = 'SemaphoreTimeoutError'
+ this.semaphore_name = params.semaphore_name
+ this.semaphore_limit = params.semaphore_limit
+ this.timeout_seconds = params.timeout_seconds
+ }
+}
+
+// βββ Re-entrancy tracking via AsyncLocalStorage ββββββββββββββββββββββββββββββ
+//
+// Prevents deadlocks when a retry()-wrapped function calls another retry()-wrapped
+// function that shares the same semaphore (or calls itself recursively).
+//
+// Each async call stack tracks which semaphore names it currently holds. When a
+// nested call encounters a semaphore it already holds, it skips acquisition and
+// runs directly within the parent's slot.
+//
+// Uses the same AsyncLocalStorage polyfill as the rest of bubus (see async_context.ts)
+// so it works in Node.js and gracefully degrades to a no-op in browsers.
+
+type ReentrantStore = Set
+
+// Separate AsyncLocalStorage instance for retry re-entrancy tracking.
+// Created via the shared factory in async_context.ts (returns null in browsers).
+const retry_context_storage: AsyncLocalStorageLike | null = createAsyncLocalStorage()
+
+function getHeldSemaphores(): ReentrantStore {
+ return (retry_context_storage?.getStore() as ReentrantStore | undefined) ?? new Set()
+}
+
+function runWithHeldSemaphores(held: ReentrantStore, fn: () => T): T {
+ if (!retry_context_storage) return fn()
+ return retry_context_storage.run(held, fn)
+}
+
+// βββ Semaphore scope helpers βββββββββββββββββββββββββββββββββββββββββββββββββ
+
+let _next_instance_id = 1
+const _instance_ids = new WeakMap