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/.github/workflows/test.yaml b/.github/workflows/test_py.yaml
similarity index 93%
rename from .github/workflows/test.yaml
rename to .github/workflows/test_py.yaml
index 7a1b98e..d195b07 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test_py.yaml
@@ -1,4 +1,4 @@
-name: test
+name: test-py
permissions:
actions: read
contents: write
@@ -17,7 +17,7 @@ on:
- '*'
pull_request:
workflow_dispatch:
-
+
jobs:
find_tests:
runs-on: ubuntu-latest
@@ -68,7 +68,7 @@ jobs:
- run: uv sync --dev --all-extras
- run: pytest -x tests/${{ matrix.test_filename }}.py --cov=bubus --cov-report=term
-
+
- name: Check coverage files
run: |
echo "Looking for coverage files..."
@@ -76,7 +76,7 @@ jobs:
if [ -f .coverage ]; then
echo "Found .coverage file, size: $(stat -f%z .coverage 2>/dev/null || stat -c%s .coverage) bytes"
fi
-
+
- name: Upload coverage data
uses: actions/upload-artifact@v4
with:
@@ -91,20 +91,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
-
+
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
activate-environment: true
-
+
- run: uv sync --dev --all-extras
-
+
- name: Download all coverage data
uses: actions/download-artifact@v4
with:
pattern: coverage-*
path: coverage-data/
-
+
- name: Combine coverage data
run: |
# Find all .coverage files and copy them with unique names
@@ -113,7 +113,7 @@ jobs:
cp "$coverage_file" ".coverage.$counter"
counter=$((counter + 1))
done
-
+
- name: Combine coverage & fail if it's <80%
run: |
uv tool install 'coverage[toml]'
@@ -126,7 +126,7 @@ jobs:
# Report again and fail if under 80%.
coverage report --fail-under=80
-
+
- name: Upload combined coverage report
uses: actions/upload-artifact@v4
with:
@@ -135,10 +135,3 @@ jobs:
htmlcov/
coverage.xml
retention-days: 7
-
- - name: Upload coverage to Codecov (optional)
- uses: codecov/codecov-action@v4
- with:
- file: ./coverage.xml
- fail_ci_if_error: false
- continue-on-error: true
diff --git a/.github/workflows/test_ts.yaml b/.github/workflows/test_ts.yaml
new file mode 100644
index 0000000..6c3a302
--- /dev/null
+++ b/.github/workflows/test_ts.yaml
@@ -0,0 +1,74 @@
+name: test-ts
+
+on:
+ push:
+ branches:
+ - main
+ - stable
+ - 'releases/**'
+ tags:
+ - '*'
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ find_ts_tests:
+ runs-on: ubuntu-latest
+ outputs:
+ TS_TEST_FILENAMES: ${{ steps.lsgrep.outputs.TS_TEST_FILENAMES }}
+ # ["eventbus_basics", ...]
+ steps:
+ - uses: actions/checkout@v4
+ - id: lsgrep
+ run: |
+ TS_TEST_FILENAMES="$(ls bubus-ts/tests/*.test.ts | sed 's|^bubus-ts/tests/||' | sed 's|\\.test\\.ts$||' | jq -R -s -c 'split("\n")[:-1]')"
+ echo "TS_TEST_FILENAMES=${TS_TEST_FILENAMES}" >> "$GITHUB_OUTPUT"
+ echo "$TS_TEST_FILENAMES"
+ - name: Check that at least one test file is found
+ run: |
+ if [ -z "${{ steps.lsgrep.outputs.TS_TEST_FILENAMES }}" ]; then
+ echo "Failed to find any *.test.ts files in bubus-ts/tests/ folder!" > /dev/stderr
+ exit 1
+ fi
+
+ tests:
+ needs: find_ts_tests
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ test_filename: ${{ fromJson(needs.find_ts_tests.outputs.TS_TEST_FILENAMES || '["FAILED_TO_DISCOVER_TESTS"]') }}
+ # autodiscovers all the files in bubus-ts/tests/*.test.ts
+ # - eventbus_basics
+ # ... and more
+ name: ts-${{ matrix.test_filename }}
+ defaults:
+ run:
+ working-directory: bubus-ts
+ steps:
+ - name: Check that the previous step managed to find some test files for us to run
+ run: |
+ if [[ "${{ matrix.test_filename }}" == "FAILED_TO_DISCOVER_TESTS" ]]; then
+ echo "Failed get list of test files in bubus-ts/tests/*.test.ts from find_ts_tests job" > /dev/stderr
+ exit 1
+ fi
+
+ - 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
+
+ - run: pnpm install --frozen-lockfile
+ - name: Run tests with coverage
+ run: |
+ NODE_OPTIONS='--expose-gc' node --expose-gc --test --experimental-test-coverage --import tsx tests/${{ matrix.test_filename }}.test.ts | tee coverage-output.txt
+ - name: Append coverage report to summary
+ run: |
+ echo "### TypeScript coverage: ${{ matrix.test_filename }}" >> "$GITHUB_STEP_SUMMARY"
+ awk '/# start of coverage report/{flag=1} flag{print} /# end of coverage report/{flag=0}' coverage-output.txt >> "$GITHUB_STEP_SUMMARY"
diff --git a/.gitignore b/.gitignore
index 6d5adec..2cc365b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,6 +20,8 @@ CLAUDE.local.md
# Build files
dist/
+node_modules/
+package-lock.json
# Coverage files
.coverage
@@ -27,7 +29,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/README.md b/bubus-ts/README.md
new file mode 100644
index 0000000..de05c40
--- /dev/null
+++ b/bubus-ts/README.md
@@ -0,0 +1,436 @@
+# `bubus`: 📢 Production-ready multi-language event bus
+
+
+
+[](https://deepwiki.com/pirate/bbus)   
+
+[](https://deepwiki.com/pirate/bbus/3-typescript-implementation) 
+
+Bubus is an in-memory event bus library for async Python and TS (node/bun/deno/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 event 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 Zod / Pydantic 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
+
+
+
+## 🔢 Quickstart
+
+```bash
+npm install bubus
+```
+
+```ts
+import { BaseEvent, EventBus } from 'bubus'
+import { z } from 'zod'
+
+const CreateUserEvent = BaseEvent.extend('CreateUserEvent', {
+ email: z.string(),
+ event_result_schema: z.object({ user_id: z.string() }),
+})
+
+const bus = new EventBus('MyAuthEventBus')
+
+bus.on(CreateUserEvent, async (event) => {
+ const user = await yourCreateUserLogic(event.email)
+ return { user_id: user.id }
+})
+
+const event = bus.emit(CreateUserEvent({ email: 'someuser@example.com' }))
+await event.done()
+console.log(event.first_result) // { user_id: 'some-user-uuid' }
+```
+
+
+
+---
+
+
+
+## ✨ Features
+
+The features offered in TS are broadly similar to the ones offered in the python library.
+
+- Typed events with Zod schemas (cross-compatible with Pydantic events from python library)
+- FIFO event queueing with configurable concurrency
+- Nested event support with automatic parent/child tracking
+- Cross-bus forwarding with loop prevention
+- Handler result tracking + validation + timeout enforcement
+- History retention controls (`max_history_size`) for memory bounds
+- Optional `@retry` decorator for easy management of per-handler retries, timeouts, and semaphore-limited execution
+
+See the [Python README](../README.md) for more details.
+
+
+
+---
+
+
+
+## 📚 API Documentation
+
+### `EventBus`
+
+Create a bus:
+
+```ts
+const bus = new EventBus('MyBus', {
+ max_history_size: 100, // keep small, copy events to external store manually if you want to persist/query long-term logs
+ event_concurrency: 'bus-serial', // 'global-serial' | 'bus-serial' (default) | 'parallel'
+ event_handler_concurrency: 'serial', // 'serial' (default) | 'parallel'
+ event_handler_completion: 'all', // 'all' (default) | 'first' (stop handlers after the first non-undefined result from any handler)
+ event_timeout: 60, // default hard timeout for event handlers before they are marked result.status = 'error' w/ result.error = HandlerTimeoutError(...)
+ event_handler_slow_timeout: 30, // default timeout before a console.warn("Slow event handler bus.on(SomeEvent, someHandler()) has taken more than 30s"
+ event_slow_timeout: 300, // default timeout before a console.warn("Slow event processing: bus.on(SomeEvent, ...4 handlers) have taken more than 300s"
+})
+```
+
+Core methods:
+
+- `bus.emit(event)` aka `bus.dispatch(event)`
+- `bus.on(eventKey, handler, options?)`
+- `bus.off(eventKey, handler)`
+- `bus.find(eventKey, options?)`
+- `bus.waitUntilIdle()`
+- `bus.destroy()`
+
+Notes:
+
+- String matching of event types using `bus.on('SomeEvent', ...)` and `bus.on('*', ...)` wildcard matching is supported
+- Prefer passing event class to (`bus.on(MyEvent, handler)`) over string-based maching for strictest type inference
+
+### `BaseEvent`
+
+Define typed events:
+
+```ts
+const MyEvent = BaseEvent.extend('MyEvent', {
+ some_key: z.string(),
+ some_other_key: z.number(),
+ // ...
+ // any other payload fields you want to include can go here
+
+ // fields that start with event_* are reserved for metadata used by the library
+ event_result_schema: z.string().optional(),
+ event_timeout: 60,
+ // ...
+})
+
+const pending_event: MyEvent = MyEvent({ some_key: 'abc', some_other_key: 234 })
+const queued_event: MyEvent = bus.emit(pending_event)
+const completed_event: MyEvent = queued_event.done()
+```
+
+Special fields that change how the event is processed:
+
+- `event_result_schema` defines the type to enforce for handler return values
+- `event_concurrency`, `event_handler_concurrency`, `event_handler_completion`
+- `event_timeout`, `event_handler_timeout`, `event_handler_slow_timeout`
+
+Common methods:
+
+- `await event.done()`
+- `await event.first()`
+- `event.toJSON()` (serialization format is compatible with python library)
+- `event.fromJSON()`
+
+#### `done()`
+
+- Runs the event with completion mode `'all'` and waits for all handlers/buses to finish.
+- Returns the same event instance in completed state so you can inspect `event_results`, `event_errors`, etc.
+- Want to dispatch and await an event like a function call? simply `await event.done()` and it will process immediately, skipping queued events.
+- Want to wait for normal processing in the order it was originally queued? use `await event.waitForCompletion()`
+
+#### `first()`
+
+- Runs the event with completion mode `'first'`.
+- Returns the temporally first non-`undefined` handler result (not registration order).
+- If all handlers return `undefined` (or only error), it resolves to `undefined`.
+- Remaining handlers are cancelled after the winning result is found.
+
+### `EventResult`
+
+Each handler run produces an `EventResult` stored in `event.event_results` with:
+
+- `status`: `pending | started | completed | error`
+- `result: EventType.event_result_schema` or `error: Error | undefined`
+- handler metadata (`handler_id`, `handler_name`, bus metadata)
+- `event_children` list of any sub-events that were emitted during handling
+
+The event aggregates these via `event.event_results` and exposes the values from them via getters like `event.first_result`, `event.event_errors`, and others.
+
+
+
+---
+
+
+
+## 🧵 Advanced Concurrency Control
+
+### Concurrency Config Options
+
+#### Bus-level config options (`new EventBus(name, {...options...})`)
+
+- `max_history_size?: number | null` (default: `100`)
+ - Max completed events kept in history. `null` = unlimited. `bus.find(...)` uses this log to query recently completed events
+- `event_concurrency?: 'global-serial' | 'bus-serial' | 'parallel' | null` (default: `'bus-serial'`)
+ - Event-level scheduling policy (`global-serial`: FIFO across all buses, `bus-serial`: FIFO per bus, `parallel`: concurrent events per bus).
+- `event_handler_concurrency?: 'serial' | 'parallel' | null` (default: `'serial'`)
+ - Handler-level scheduling policy for each event (`serial`: one handler at a time per event, `parallel`: all handlers for the event can run concurrently).
+- `event_handler_completion?: 'all' | 'first'` (default: `'all'`)
+ - Completion strategy (`all`: wait for all handlers, `first`: stop after first non-`undefined` result).
+- `event_timeout?: number | null` (default: `60`)
+ - Default handler timeout budget in seconds.
+- `event_handler_slow_timeout?: number | null` (default: `30`)
+ - Slow-handler warning threshold in seconds.
+- `event_slow_timeout?: number | null` (default: `300`)
+ - Slow-event warning threshold in seconds.
+
+#### Event-level config options
+
+Override the bus defaults on a per-event basis by using these special fields in the event:
+
+```ts
+const event = MyEvent({
+ event_concurrency: 'parallel',
+ event_handler_concurrency: 'parallel',
+ event_handler_completion: 'first',
+ event_timeout: 10,
+ event_handler_timeout: 3,
+})
+```
+
+Notes:
+
+- `null` means "inherit/fall back to bus default" for event-level concurrency and timeout fields.
+- Forwarded events are processed under the target bus's config; source bus config is not inherited.
+- `event_handler_completion` is independent from handler scheduling mode (`serial` vs `parallel`).
+
+#### Handler-level config options
+
+Set at registration:
+
+```ts
+bus.on(MyEvent, handler, { handler_timeout: 2 }) // max time in seconds this handler is allowed to run before it's aborted
+```
+
+#### Precedence and interaction
+
+Event and handler concurrency precedence:
+
+1. Event instance override (`event.event_concurrency`, `event.event_handler_concurrency`)
+2. Bus defaults (`EventBus` options)
+3. Built-in defaults (`bus-serial`, `serial`)
+
+Timeout resolution for each handler run:
+
+1. Resolve handler timeout source:
+ - `bus.on(..., { handler_timeout })`
+ - else `event.event_handler_timeout`
+ - else bus `event_timeout`
+2. Apply event cap:
+ - effective timeout is `min(resolved_handler_timeout, event.event_timeout)` when both are non-null
+ - if either is `null`, the non-null value wins; both null means no timeout
+
+Additional timeout nuance:
+
+- `BaseEvent.event_timeout` starts as `null` unless set; dispatch applies bus default timeout when still unset.
+- Bus/event timeouts are outer budgets for handler execution; use `@retry({ timeout })` for per-attempt timeouts.
+
+Use `@retry` for per-handler execution timeout/retry/backoff/semaphore control. Keep bus/event timeouts as outer execution budgets.
+
+### Runtime lifecycle (bus -> event -> handler)
+
+Dispatch flow:
+
+1. `dispatch()` normalizes to original event and captures async context when available.
+2. Bus applies defaults and appends itself to `event_path`.
+3. Event enters `event_history`, `pending_event_queue`, and runloop starts.
+4. Runloop dequeues and calls `processEvent()`.
+5. Event-level semaphore (`event_concurrency`) is applied.
+6. Handler results are created and executed under handler-level semaphore (`event_handler_concurrency`).
+7. Event completion and child completion propagate through `event_pending_bus_count` and result states.
+8. History trimming evicts completed events first; if still over limit, oldest pending events can be dropped (with warning), then cleanup runs.
+
+Locking model:
+
+- Global event semaphore: `global-serial`
+- Bus event semaphore: `bus-serial`
+- Per-event handler semaphore: `serial` handler mode
+
+### Queue-jumping (`await event.done()` inside handlers)
+
+Want to dispatch and await an event like a function call? simply `await event.done()`.
+When called inside a handler, the awaited event is processed immediately (queue-jump behavior) before normal queued work continues.
+
+### `@retry` Decorator
+
+`retry()` adds retry logic and optional semaphore-based concurrency limiting to async functions/handlers.
+
+#### Why retry is handler-level
+
+Retry and timeout belong on handlers, not emit sites:
+
+- Handlers fail; events are messages.
+- Handler-level retries preserve replay semantics (one event dispatch, internal retry attempts).
+- Bus concurrency and retry concerns are orthogonal and compose cleanly.
+
+#### Recommended pattern: `@retry()` on class methods
+
+```ts
+import { retry, EventBus } from 'bubus'
+
+class ScreenshotService {
+ constructor(private bus: InstanceType) {
+ bus.on(ScreenshotRequestEvent, this.onScreenshot.bind(this))
+ }
+
+ @retry({
+ max_attempts: 4,
+ retry_on_errors: [/timeout/i],
+ timeout: 5,
+ semaphore_scope: 'global',
+ semaphore_name: 'Screenshots',
+ semaphore_limit: 2,
+ })
+ async onScreenshot(event: InstanceType): Promise {
+ return await takeScreenshot(event.data.url)
+ }
+}
+
+const ev = bus.emit(ScreenshotRequestEvent({ url: 'https://example.com' }))
+await ev.done()
+```
+
+#### Also works: inline HOF
+
+```ts
+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 first call. |
+| `retry_after` | `number` | `0` | Seconds between retries. |
+| `retry_backoff_factor` | `number` | `1.0` | Multiplier for retry delay. |
+| `retry_on_errors` | `(ErrorClass \| string \| RegExp)[]` | `undefined` | Retry filter. `undefined` retries on any error. |
+| `timeout` | `number \| null` | `undefined` | Per-attempt timeout in seconds. |
+| `semaphore_limit` | `number \| null` | `undefined` | Max concurrent executions sharing semaphore. |
+| `semaphore_name` | `string \| ((...args) => string) \| null` | fn name | Semaphore key. |
+| `semaphore_lax` | `boolean` | `true` | Continue if semaphore acquisition times out. |
+| `semaphore_scope` | `'global' \| 'class' \| 'instance'` | `'global'` | Scope for semaphore identity. |
+| `semaphore_timeout` | `number \| null` | `undefined` | Max seconds waiting for semaphore. |
+
+#### Error types
+
+- `RetryTimeoutError`: per-attempt timeout exceeded.
+- `SemaphoreTimeoutError`: semaphore acquisition timeout (`semaphore_lax=false`).
+
+#### Re-entrancy
+
+On Node.js/Bun, `AsyncLocalStorage` tracks held semaphores and avoids deadlocks for nested calls using the same semaphore.
+In browsers, this tracking is unavailable, avoid recursive/nested same-semaphore patterns there.
+
+#### Interaction with bus concurrency
+
+Execution order when used on bus handlers:
+
+1. Bus acquires handler semaphore (`event_handler_concurrency`)
+2. `retry()` acquires retry semaphore (if configured)
+3. Handler executes (with retries)
+4. `retry()` releases retry semaphore
+5. Bus releases handler semaphore
+
+Use bus/event timeouts for outer deadlines and `retry({ timeout })` for per-handler-attempt deadlines.
+
+#### Discouraged: retrying emit sites
+
+Avoid wrapping `emit()/done()` in `retry()` unless you intentionally want multiple event dispatches (a new event for every retry).
+Keep retries on handlers so that your logs represent the original high-level intent, with a single event per call even if handling it took multiple tries.
+Emitting a new event for each retry is only recommended if you are using the logs for debugging more than for replayability / time-travel.
+
+
+
+---
+
+
+
+## 🏃 Runtimes
+
+`bubus-ts` supports all major JS runtimes.
+
+- Node.js (default development and test runtime)
+- Browsers (ESM)
+- Bun
+- Deno
+
+### Browser support notes
+
+- The package output is ESM (`./dist/esm`) which is supported by all browsers [released after 2018](https://caniuse.com/?search=ESM)
+- `AsyncLocalStorage` is preserved at dispatch and used during handling when availabe (Node/Bun), otel/tracing context will work normally in those environments
+
+### Performance comparison (local run, per-event)
+
+Measured locally on an `Apple M4 Pro` with:
+
+- `pnpm run perf:node` (`node v22.21.1`)
+- `pnpm run perf:bun` (`bun v1.3.9`)
+- `pnpm run perf:deno` (`deno v2.6.8`)
+- `pnpm run perf:browser` (`chrome v145.0.7632.6`)
+
+| Runtime | 1 bus x 50k events x 1 handler | 500 busses x 100 events x 1 handler | 1 bus x 1 event x 50k parallel handlers | 1 bus x 50k events x 50k one-off handlers | Worst case (N busses x N events x N handlers) |
+| ------------------ | ------------------------------ | ----------------------------------- | --------------------------------------- | ----------------------------------------- | --------------------------------------------- |
+| Node | `0.015ms/event`, `0.6kb/event` | `0.058ms/event`, `0.1kb/event` | `0.021ms/handler`, `189792.0kb/event` | `0.028ms/event`, `0.6kb/event` | `0.442ms/event`, `0.9kb/event` |
+| Bun | `0.011ms/event`, `2.5kb/event` | `0.054ms/event`, `1.0kb/event` | `0.006ms/handler`, `223296.0kb/event` | `0.019ms/event`, `2.8kb/event` | `0.441ms/event`, `3.1kb/event` |
+| Deno | `0.018ms/event`, `1.2kb/event` | `0.063ms/event`, `0.4kb/event` | `0.024ms/handler`, `156752.0kb/event` | `0.064ms/event`, `2.6kb/event` | `0.461ms/event`, `7.9kb/event` |
+| Browser (Chromium) | `0.030ms/event` | `0.197ms/event` | `0.022ms/handler` | `0.022ms/event` | `1.566ms/event` |
+
+Notes:
+
+- `kb/event` is peak RSS delta per event during active processing (most representative of OS-visible RAM in Activity Monitor / Task Manager, with `EventBus.max_history_size=1`)
+- In `1 bus x 1 event x 50k parallel handlers` stats are shown per-handler for clarity, `0.02ms/handler * 50k handlers ~= 1000ms` for the entire event
+- Browser runtime does not expose memory usage easily, in practice memory performance in-browser is comparable to Node (they both use V8)
+
+
+
+---
+
+
+
+## 👾 Development
+
+```bash
+git clone https://github.com/pirate/bbus bubus && cd bubus
+
+cd ./bubus-ts
+pnpm install
+pnpm lint
+pnpm test
+```
diff --git a/bubus-ts/eslint.config.js b/bubus-ts/eslint.config.js
new file mode 100644
index 0000000..458a8b7
--- /dev/null
+++ b/bubus-ts/eslint.config.js
@@ -0,0 +1,25 @@
+import ts_parser from '@typescript-eslint/parser'
+import ts_eslint_plugin from '@typescript-eslint/eslint-plugin'
+
+export default [
+ {
+ ignores: ['dist/**', 'README.md'],
+ },
+ {
+ 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/concurrency_options.ts b/bubus-ts/examples/concurrency_options.ts
new file mode 100755
index 0000000..57f14da
--- /dev/null
+++ b/bubus-ts/examples/concurrency_options.ts
@@ -0,0 +1,222 @@
+#!/usr/bin/env -S node --import tsx
+// Run: node --import tsx examples/concurrency_options.ts
+
+import { z } from 'zod'
+import { BaseEvent, EventBus, EventHandlerTimeoutError } from '../src/index.js'
+const sleep = (ms: number): Promise =>
+ new Promise((resolve) => {
+ setTimeout(resolve, ms)
+ })
+
+const makeLogger = (section: string) => {
+ const started_at = performance.now()
+ return (message: string) => {
+ const elapsed = (performance.now() - started_at).toFixed(1)
+ console.log(`[${section}] +${elapsed}ms ${message}`)
+ }
+}
+const WorkEvent = BaseEvent.extend('ConcurrencyOptionsWorkEvent', { lane: z.string(), order: z.number(), ms: z.number() })
+const HandlerEvent = BaseEvent.extend('ConcurrencyOptionsHandlerEvent', { label: z.string() })
+const OverrideEvent = BaseEvent.extend('ConcurrencyOptionsOverrideEvent', { label: z.string(), order: z.number(), ms: z.number() })
+const TimeoutEvent = BaseEvent.extend('ConcurrencyOptionsTimeoutEvent', { ms: z.number() })
+
+// 1) Event concurrency at bus level: global-serial vs bus-serial.
+// Observe how max in-flight events differs across two buses.
+async function eventConcurrencyDemo(): Promise {
+ const global_log = makeLogger('event:global-serial')
+ const global_a = new EventBus('GlobalSerialA', { event_concurrency: 'global-serial', event_handler_concurrency: 'serial' })
+ const global_b = new EventBus('GlobalSerialB', { event_concurrency: 'global-serial', event_handler_concurrency: 'serial' })
+ let global_in_flight = 0
+ let global_max = 0
+ const global_handler = async (event: InstanceType) => {
+ global_in_flight += 1
+ global_max = Math.max(global_max, global_in_flight)
+ global_log(`${event.lane}${event.order} start (global in-flight=${global_in_flight})`)
+ await sleep(event.ms)
+ global_log(`${event.lane}${event.order} end`)
+ global_in_flight -= 1
+ }
+ global_a.on(WorkEvent, global_handler)
+ global_b.on(WorkEvent, global_handler)
+ global_a.emit(WorkEvent({ lane: 'A', order: 0, ms: 45 }))
+ global_b.emit(WorkEvent({ lane: 'B', order: 0, ms: 45 }))
+ global_a.emit(WorkEvent({ lane: 'A', order: 1, ms: 45 }))
+ global_b.emit(WorkEvent({ lane: 'B', order: 1, ms: 45 }))
+ await Promise.all([global_a.waitUntilIdle(), global_b.waitUntilIdle()])
+ global_log(`max in-flight across both buses: ${global_max} (expect 1 in global-serial)`)
+ console.log('\n=== global_a.logTree() ===')
+ console.log(global_a.logTree())
+ console.log('\n=== global_b.logTree() ===')
+ console.log(global_b.logTree())
+ const bus_log = makeLogger('event:bus-serial')
+ const bus_a = new EventBus('BusSerialA', { event_concurrency: 'bus-serial', event_handler_concurrency: 'serial' })
+ const bus_b = new EventBus('BusSerialB', { event_concurrency: 'bus-serial', event_handler_concurrency: 'serial' })
+ const per_bus_in_flight = { A: 0, B: 0 }
+ const per_bus_max = { A: 0, B: 0 }
+ let mixed_global_in_flight = 0
+ let mixed_global_max = 0
+ const bus_handler = async (event: InstanceType) => {
+ const lane = event.lane as 'A' | 'B'
+ mixed_global_in_flight += 1
+ mixed_global_max = Math.max(mixed_global_max, mixed_global_in_flight)
+ per_bus_in_flight[lane] += 1
+ per_bus_max[lane] = Math.max(per_bus_max[lane], per_bus_in_flight[lane])
+ bus_log(`${lane}${event.order} start (global=${mixed_global_in_flight}, lane=${per_bus_in_flight[lane]})`)
+ await sleep(event.ms)
+ bus_log(`${lane}${event.order} end`)
+ per_bus_in_flight[lane] -= 1
+ mixed_global_in_flight -= 1
+ }
+ bus_a.on(WorkEvent, bus_handler)
+ bus_b.on(WorkEvent, bus_handler)
+ bus_a.emit(WorkEvent({ lane: 'A', order: 0, ms: 45 }))
+ bus_b.emit(WorkEvent({ lane: 'B', order: 0, ms: 45 }))
+ bus_a.emit(WorkEvent({ lane: 'A', order: 1, ms: 45 }))
+ bus_b.emit(WorkEvent({ lane: 'B', order: 1, ms: 45 }))
+ await Promise.all([bus_a.waitUntilIdle(), bus_b.waitUntilIdle()])
+ bus_log(`max in-flight global=${mixed_global_max}, per-bus A=${per_bus_max.A}, B=${per_bus_max.B} (expect global >= 2, per-bus = 1)`)
+ console.log('\n=== bus_a.logTree() ===')
+ console.log(bus_a.logTree())
+ console.log('\n=== bus_b.logTree() ===')
+ console.log(bus_b.logTree())
+}
+
+// 2) Handler concurrency at bus level: serial vs parallel on the same event.
+// Observe handler overlap for one event with two handlers.
+async function handlerConcurrencyDemo(): Promise {
+ const run_case = async (mode: 'serial' | 'parallel') => {
+ const log = makeLogger(`handler:${mode}`)
+ const bus = new EventBus(`HandlerMode-${mode}`, { event_concurrency: 'parallel', event_handler_concurrency: mode })
+ let in_flight = 0
+ let max_in_flight = 0
+ const make_handler = (name: string, ms: number) => async (event: InstanceType) => {
+ in_flight += 1
+ max_in_flight = Math.max(max_in_flight, in_flight)
+ log(`${event.label}:${name} start (handlers in-flight=${in_flight})`)
+ await sleep(ms)
+ log(`${event.label}:${name} end`)
+ in_flight -= 1
+ }
+ bus.on(HandlerEvent, make_handler('slow', 60))
+ bus.on(HandlerEvent, make_handler('fast', 20))
+ const event = bus.emit(HandlerEvent({ label: mode }))
+ await event.done()
+ await bus.waitUntilIdle()
+ log(`max handler overlap: ${max_in_flight} (expect 1 for serial, >= 2 for parallel)`)
+ console.log(`\n=== ${bus.name}.logTree() ===`)
+ console.log(bus.logTree())
+ }
+ await run_case('serial')
+ await run_case('parallel')
+}
+
+// 3) Event-level overrides take precedence over bus defaults.
+// Bus defaults are strict (bus-serial + serial), then we override both to parallel on event instances.
+async function eventOverrideDemo(): Promise {
+ const log = makeLogger('override:precedence')
+ const bus = new EventBus('OverrideBus', { event_concurrency: 'bus-serial', event_handler_concurrency: 'serial' })
+ let active_events = new Set()
+ let per_event_handlers = new Map()
+ let active_handlers = 0
+ let max_handlers = 0
+ let max_events = 0
+
+ const reset_metrics = () => {
+ active_events = new Set()
+ per_event_handlers = new Map()
+ active_handlers = 0
+ max_handlers = 0
+ max_events = 0
+ }
+ const track_start = (event: InstanceType, handler_name: string, label: string) => {
+ active_handlers += 1
+ max_handlers = Math.max(max_handlers, active_handlers)
+ const count = (per_event_handlers.get(event.event_id) ?? 0) + 1
+ per_event_handlers.set(event.event_id, count)
+ active_events.add(event.event_id)
+ max_events = Math.max(max_events, active_events.size)
+ log(`${label}:${event.order}:${handler_name} start (events=${active_events.size}, handlers=${active_handlers})`)
+ }
+ const track_end = (event: InstanceType, handler_name: string, label: string) => {
+ active_handlers -= 1
+ const count = (per_event_handlers.get(event.event_id) ?? 1) - 1
+ if (count <= 0) {
+ per_event_handlers.delete(event.event_id)
+ active_events.delete(event.event_id)
+ } else {
+ per_event_handlers.set(event.event_id, count)
+ }
+ log(`${label}:${event.order}:${handler_name} end`)
+ }
+
+ const run_pair = async (label: string, use_override: boolean) => {
+ reset_metrics()
+ const handler_a = async (event: InstanceType) => {
+ track_start(event, 'A', label)
+ await sleep(event.ms)
+ track_end(event, 'A', label)
+ }
+ const handler_b = async (event: InstanceType) => {
+ track_start(event, 'B', label)
+ await sleep(event.ms)
+ track_end(event, 'B', label)
+ }
+ bus.off(OverrideEvent)
+ bus.on(OverrideEvent, handler_a)
+ bus.on(OverrideEvent, handler_b)
+ const overrides = use_override ? ({ event_concurrency: 'parallel', event_handler_concurrency: 'parallel' } as const) : {}
+ bus.emit(OverrideEvent({ label, order: 0, ms: 45, ...overrides }))
+ bus.emit(OverrideEvent({ label, order: 1, ms: 45, ...overrides }))
+ await bus.waitUntilIdle()
+ log(`${label} summary -> max events=${max_events}, max handlers=${max_handlers}`)
+ }
+
+ await run_pair('bus-defaults', false)
+ await run_pair('event-overrides', true)
+ console.log('\n=== OverrideBus.logTree() ===')
+ console.log(bus.logTree())
+}
+
+// 4) Handler-level timeout via bus.on(..., { handler_timeout }).
+// Observe one handler timing out while another succeeds on the same event.
+async function handlerTimeoutDemo(): Promise {
+ const log = makeLogger('timeout:handler-option')
+ const bus = new EventBus('TimeoutBus', { event_concurrency: 'parallel', event_handler_concurrency: 'parallel', event_timeout: 0.2 })
+
+ const slow_entry = bus.on(
+ TimeoutEvent,
+ async (event) => {
+ log('slow handler start')
+ await sleep(event.ms)
+ log('slow handler finished body (but may already be timed out)')
+ return 'slow'
+ },
+ { handler_timeout: 0.03 }
+ )
+ bus.on(
+ TimeoutEvent,
+ async () => {
+ log('fast handler start')
+ await sleep(10)
+ log('fast handler end')
+ return 'fast'
+ },
+ { handler_timeout: 0.1 }
+ )
+ const event = bus.emit(TimeoutEvent({ ms: 60, event_handler_timeout: 0.5 }))
+ await event.done()
+ const slow_result = event.event_results.get(slow_entry.id)
+ const slow_timeout = slow_result?.error instanceof EventHandlerTimeoutError
+ log(`slow handler status=${slow_result?.status}, timeout_error=${slow_timeout ? 'yes' : 'no'}`)
+ await bus.waitUntilIdle()
+ console.log('\n=== TimeoutBus.logTree() ===')
+ console.log(bus.logTree())
+}
+
+async function main(): Promise {
+ await eventConcurrencyDemo()
+ await handlerConcurrencyDemo()
+ await eventOverrideDemo()
+ await handlerTimeoutDemo()
+}
+await main()
diff --git a/bubus-ts/examples/forwarding_between_busses.ts b/bubus-ts/examples/forwarding_between_busses.ts
new file mode 100755
index 0000000..49f7361
--- /dev/null
+++ b/bubus-ts/examples/forwarding_between_busses.ts
@@ -0,0 +1,96 @@
+#!/usr/bin/env -S node --import tsx
+// Run: node --import tsx examples/forwarding_between_busses.ts
+
+import { z } from 'zod'
+
+import { BaseEvent, EventBus } from '../src/index.js'
+
+const ForwardedEvent = BaseEvent.extend('ForwardedEvent', {
+ message: z.string(),
+})
+
+async function main(): Promise {
+ const busA = new EventBus('BusA')
+ const busB = new EventBus('BusB')
+ const busC = new EventBus('BusC')
+
+ const handleCounts = {
+ BusA: 0,
+ BusB: 0,
+ BusC: 0,
+ }
+
+ const seenEventIds = {
+ BusA: new Set(),
+ BusB: new Set(),
+ BusC: new Set(),
+ }
+
+ // Each bus handles the typed event locally.
+ // In a forwarding cycle, loop prevention should keep each bus to one handle.
+ busA.on(ForwardedEvent, (event) => {
+ handleCounts.BusA += 1
+ seenEventIds.BusA.add(event.event_id)
+ console.log(`[BusA] handled ${event.event_id} (count=${handleCounts.BusA})`)
+ })
+
+ busB.on(ForwardedEvent, (event) => {
+ handleCounts.BusB += 1
+ seenEventIds.BusB.add(event.event_id)
+ console.log(`[BusB] handled ${event.event_id} (count=${handleCounts.BusB})`)
+ })
+
+ busC.on(ForwardedEvent, (event) => {
+ handleCounts.BusC += 1
+ seenEventIds.BusC.add(event.event_id)
+ console.log(`[BusC] handled ${event.event_id} (count=${handleCounts.BusC})`)
+ })
+
+ // Forward all events in a ring:
+ // A -> B -> C -> A
+ // Expected for one dispatch from A: event path becomes [A, B, C] and stops.
+ // The C -> A edge is skipped because A is already in event_path.
+ busA.on('*', busB.emit)
+ busB.on('*', busC.emit)
+ busC.on('*', busA.emit)
+
+ console.log('Dispatching ForwardedEvent on BusA with cyclic forwarding A -> B -> C -> A')
+
+ const event = busA.emit(
+ ForwardedEvent({
+ message: 'hello across 3 buses',
+ })
+ )
+
+ // done() waits for handlers on all forwarded buses, not just the origin bus.
+ await event.done()
+ await Promise.all([busA.waitUntilIdle(), busB.waitUntilIdle(), busC.waitUntilIdle()])
+
+ const path = event.event_path
+ const totalHandles = handleCounts.BusA + handleCounts.BusB + handleCounts.BusC
+
+ console.log('\nFinal propagation summary:')
+ console.log(`- event_id: ${event.event_id}`)
+ console.log(`- event_path: ${path.join(' -> ')}`)
+ console.log(`- handle counts: ${JSON.stringify(handleCounts)}`)
+ console.log(`- unique ids seen per bus: A=${seenEventIds.BusA.size}, B=${seenEventIds.BusB.size}, C=${seenEventIds.BusC.size}`)
+ console.log(`- total handles: ${totalHandles}`)
+
+ const handledOncePerBus = handleCounts.BusA === 1 && handleCounts.BusB === 1 && handleCounts.BusC === 1
+ const visitedThreeBuses = path.length === 3
+
+ if (handledOncePerBus && visitedThreeBuses) {
+ console.log('\nLoop prevention confirmed: each bus handled the event at most once.')
+ } else {
+ console.log('\nUnexpected forwarding result. Check handlers/forwarding setup.')
+ }
+
+ console.log('\n=== BusA logTree() ===')
+ console.log(busA.logTree())
+ console.log('\n=== BusB logTree() ===')
+ console.log(busB.logTree())
+ console.log('\n=== BusC logTree() ===')
+ console.log(busC.logTree())
+}
+
+await main()
diff --git a/bubus-ts/examples/immediate_event_processing.ts b/bubus-ts/examples/immediate_event_processing.ts
new file mode 100755
index 0000000..6d52095
--- /dev/null
+++ b/bubus-ts/examples/immediate_event_processing.ts
@@ -0,0 +1,138 @@
+#!/usr/bin/env -S node --import tsx
+// Run: node --import tsx examples/immediate_event_processing.ts
+
+import { z } from 'zod'
+
+import { BaseEvent, EventBus } from '../src/index.js'
+
+// Parent handler runs two scenarios:
+// 1) await child.done() -> immediate queue-jump processing
+// 2) await child.waitForCompletion() -> normal queue processing
+const ParentEvent = BaseEvent.extend('ImmediateProcessingParentEvent', {
+ mode: z.enum(['immediate', 'queued']),
+})
+
+const ChildEvent = BaseEvent.extend('ImmediateProcessingChildEvent', {
+ scenario: z.enum(['immediate', 'queued']),
+})
+
+const SiblingEvent = BaseEvent.extend('ImmediateProcessingSiblingEvent', {
+ scenario: z.enum(['immediate', 'queued']),
+})
+
+const delay = (ms: number): Promise =>
+ new Promise((resolve) => {
+ setTimeout(resolve, ms)
+ })
+
+type Scenario = 'immediate' | 'queued'
+
+async function main(): Promise {
+ // Two buses: bus_a is the source, bus_b is the forward target.
+ const bus_a = new EventBus('QueueJumpDemoA', {
+ event_concurrency: 'bus-serial',
+ event_handler_concurrency: 'serial',
+ })
+ const bus_b = new EventBus('QueueJumpDemoB', {
+ event_concurrency: 'bus-serial',
+ event_handler_concurrency: 'serial',
+ })
+
+ // Simple step counter so ordering is easy to read in stdout.
+ let step = 0
+ const log = (message: string): void => {
+ step += 1
+ console.log(`${String(step).padStart(2, '0')}. ${message}`)
+ }
+
+ // Forwarding setup: both sibling/child events emitted on bus_a are forwarded to bus_b.
+ bus_a.on(ChildEvent, (event) => {
+ log(`[forward] ${event.event_type}(${event.scenario}) bus_a -> bus_b`)
+ bus_b.emit(event)
+ })
+ bus_a.on(SiblingEvent, (event) => {
+ log(`[forward] ${event.event_type}(${event.scenario}) bus_a -> bus_b`)
+ bus_b.emit(event)
+ })
+
+ // Local handlers on bus_a.
+ bus_a.on(ChildEvent, async (event) => {
+ log(`[bus_a] child start (${event.scenario})`)
+ await delay(8)
+ log(`[bus_a] child end (${event.scenario})`)
+ })
+ bus_a.on(SiblingEvent, async (event) => {
+ log(`[bus_a] sibling start (${event.scenario})`)
+ await delay(14)
+ log(`[bus_a] sibling end (${event.scenario})`)
+ })
+
+ // Forwarded handlers on bus_b.
+ bus_b.on(ChildEvent, async (event) => {
+ log(`[bus_b] child start (${event.scenario})`)
+ await delay(4)
+ log(`[bus_b] child end (${event.scenario})`)
+ })
+ bus_b.on(SiblingEvent, async (event) => {
+ log(`[bus_b] sibling start (${event.scenario})`)
+ await delay(6)
+ log(`[bus_b] sibling end (${event.scenario})`)
+ })
+
+ // Parent handler queues sibling first, then child, then compares await behavior.
+ bus_a.on(ParentEvent, async (event) => {
+ log(`[parent:${event.mode}] start`)
+
+ // Queue a sibling first so normal queue order has sibling ahead of child.
+ event.bus?.emit(SiblingEvent({ scenario: event.mode }))
+ log(`[parent:${event.mode}] sibling queued`)
+
+ // Queue child second; this is the event we await in two different ways.
+ const child = event.bus?.emit(ChildEvent({ scenario: event.mode }))!
+ log(`[parent:${event.mode}] child queued`)
+
+ if (event.mode === 'immediate') {
+ // Queue-jump: child processes immediately while still inside parent handler.
+ log(`[parent:${event.mode}] await child.done()`)
+ await child.done()
+ log(`[parent:${event.mode}] child.done() resolved`)
+ } else {
+ // Normal queue wait: child waits its turn behind already-queued sibling work.
+ log(`[parent:${event.mode}] await child.waitForCompletion()`)
+ await child.waitForCompletion()
+ log(`[parent:${event.mode}] child.waitForCompletion() resolved`)
+ }
+
+ log(`[parent:${event.mode}] end`)
+ })
+
+ const runScenario = async (mode: Scenario): Promise => {
+ log(`----- scenario=${mode} -----`)
+
+ // Parent event uses parallel concurrency so waitForCompletion() in handler
+ // can wait safely while other queued events continue to run.
+ const parent = bus_a.emit(
+ ParentEvent({
+ mode,
+ event_concurrency: 'parallel',
+ })
+ )
+
+ await parent.waitForCompletion()
+ await Promise.all([bus_a.waitUntilIdle(), bus_b.waitUntilIdle()])
+ log(`----- done scenario=${mode} -----`)
+ }
+
+ await runScenario('immediate')
+ await runScenario('queued')
+
+ console.log('\nExpected behavior:')
+ console.log('- immediate: child runs before sibling (queue-jump) and parent resumes right after child.')
+ console.log('- queued: sibling runs first, child waits in normal queue order, parent resumes later.')
+ console.log('\n=== bus_a.logTree() ===')
+ console.log(bus_a.logTree())
+ console.log('\n=== bus_b.logTree() ===')
+ console.log(bus_b.logTree())
+}
+
+await main()
diff --git a/bubus-ts/examples/log_tree_demo.ts b/bubus-ts/examples/log_tree_demo.ts
new file mode 100755
index 0000000..2811e08
--- /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.emit(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.emit(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/examples/parent_child_tracking.ts b/bubus-ts/examples/parent_child_tracking.ts
new file mode 100755
index 0000000..6d8d7f8
--- /dev/null
+++ b/bubus-ts/examples/parent_child_tracking.ts
@@ -0,0 +1,130 @@
+#!/usr/bin/env -S node --import tsx
+// Run: node --import tsx examples/parent_child_tracking.ts
+
+import { z } from 'zod'
+
+import { BaseEvent, EventBus } from '../src/index.js'
+
+// Step 1: Define a tiny parent -> child -> grandchild event model.
+const ParentEvent = BaseEvent.extend('ParentEvent', {
+ workflow: z.string(),
+})
+
+const ChildEvent = BaseEvent.extend('ChildEvent', {
+ stage: z.string(),
+})
+
+const GrandchildEvent = BaseEvent.extend('GrandchildEvent', {
+ note: z.string(),
+})
+
+const shortId = (id?: string): string => (id ? id.slice(-8) : 'none')
+
+async function main(): Promise {
+ // Step 2: Create one bus so parent/child linkage is easy to inspect in one history.
+ const bus = new EventBus('ParentChildTrackingBus')
+
+ // Step 3: Child handler dispatches a grandchild through event.bus.
+ // Because this runs inside ChildEvent handling, grandchild gets linked automatically.
+ bus.on(ChildEvent, async (event: InstanceType): Promise => {
+ console.log(`child handler start: ${event.event_type}#${shortId(event.event_id)}`)
+
+ const grandchild = event.bus?.emit(
+ GrandchildEvent({
+ note: `spawned by ${event.stage}`,
+ })
+ )
+
+ if (grandchild) {
+ console.log(
+ ` child dispatched grandchild: ${grandchild.event_type}#${shortId(grandchild.event_id)} parent_id=${shortId(grandchild.event_parent_id)}`
+ )
+
+ // Step 4: Await a nested event so ordering and linkage are explicit in output.
+ await grandchild.done()
+ console.log(` child resumed after grandchild.done(): ${shortId(grandchild.event_id)}`)
+ }
+
+ return `child_completed:${event.stage}`
+ })
+
+ // Step 5: Grandchild handler is simple; it just marks completion with a string result.
+ bus.on(GrandchildEvent, async (event: InstanceType): Promise => {
+ console.log(`grandchild handler: ${event.event_type}#${shortId(event.event_id)} note="${event.note}"`)
+ return `grandchild_completed:${event.note}`
+ })
+
+ // Step 6: Parent handler emits/dispatches child events via event.bus.
+ // One child is awaited with .done() to clearly show queue-jump + linkage behavior.
+ bus.on(ParentEvent, async (event: InstanceType): Promise => {
+ console.log(`parent handler start: ${event.event_type}#${shortId(event.event_id)} workflow="${event.workflow}"`)
+
+ const awaitedChild = event.bus?.emit(ChildEvent({ stage: 'awaited-child' }))
+ if (awaitedChild) {
+ console.log(
+ ` parent emitted child: ${awaitedChild.event_type}#${shortId(awaitedChild.event_id)} parent_id=${shortId(awaitedChild.event_parent_id)}`
+ )
+
+ // Required by this example: await at least one child so parent/child linkage is obvious.
+ await awaitedChild.done()
+ console.log(` parent resumed after awaited child.done(): ${shortId(awaitedChild.event_id)}`)
+ }
+
+ const backgroundChild = event.bus?.emit(ChildEvent({ stage: 'background-child' }))
+ if (backgroundChild) {
+ console.log(
+ ` parent dispatched second child: ${backgroundChild.event_type}#${shortId(backgroundChild.event_id)} parent_id=${shortId(backgroundChild.event_parent_id)}`
+ )
+ }
+
+ // Parent also dispatches a GrandchildEvent type directly via event.bus.
+ // This is still automatically linked to the parent event.
+ const directGrandchild = event.bus?.emit(GrandchildEvent({ note: 'directly from parent' }))
+ if (directGrandchild) {
+ console.log(
+ ` parent dispatched grandchild type directly: ${directGrandchild.event_type}#${shortId(directGrandchild.event_id)} parent_id=${shortId(directGrandchild.event_parent_id)}`
+ )
+ await directGrandchild.done()
+ }
+
+ return 'parent_completed'
+ })
+
+ // Step 7: Dispatch parent and wait for full bus idle so history is complete.
+ const parent = bus.emit(ParentEvent({ workflow: 'demo-parent-child-tracking' }))
+ await parent.done()
+ await bus.waitUntilIdle()
+
+ // Step 8: Print IDs + relationship checks from event history.
+ console.log('\n=== Event History Relationships ===')
+ const history = Array.from(bus.event_history.values()).sort((a, b) => (a.event_created_ts ?? 0) - (b.event_created_ts ?? 0))
+
+ for (const item of history) {
+ const parentEvent = item.event_parent
+ console.log(
+ [
+ `${item.event_type}#${shortId(item.event_id)}`,
+ `parent=${parentEvent ? `${parentEvent.event_type}#${shortId(parentEvent.event_id)}` : 'none'}`,
+ `isChildOfRoot=${bus.eventIsChildOf(item, parent)}`,
+ `rootIsParentOf=${bus.eventIsParentOf(parent, item)}`,
+ ].join(' | ')
+ )
+ }
+
+ const firstChild = history.find((event) => event.event_type === 'ChildEvent')
+ const nestedGrandchild = history.find(
+ (event) => event.event_type === 'GrandchildEvent' && firstChild && event.event_parent_id === firstChild.event_id
+ )
+ if (firstChild && nestedGrandchild) {
+ console.log(
+ `grandchild->child relationship check: ${nestedGrandchild.event_type}#${shortId(nestedGrandchild.event_id)} is child of ${firstChild.event_type}#${shortId(firstChild.event_id)} = ${bus.eventIsChildOf(nestedGrandchild, firstChild)}`
+ )
+ }
+
+ // Step 9: Print the built-in tree view from event history.
+ console.log('\n=== bus.logTree() ===')
+ const tree = bus.logTree()
+ console.log(tree)
+}
+
+await main()
diff --git a/bubus-ts/examples/simple.ts b/bubus-ts/examples/simple.ts
new file mode 100755
index 0000000..5eea6f0
--- /dev/null
+++ b/bubus-ts/examples/simple.ts
@@ -0,0 +1,95 @@
+#!/usr/bin/env -S node --import tsx
+// Run: node --import tsx examples/simple.ts
+
+import { BaseEvent, EventBus } from '../src/index.js'
+import { z } from 'zod'
+
+// 1) Define typed events with BaseEvent.extend(...)
+const RegisterUserEvent = BaseEvent.extend('RegisterUserEvent', {
+ email: z.string().email(),
+ plan: z.enum(['free', 'pro']),
+ // Handler return values for this event are validated against this schema.
+ event_result_schema: z.object({
+ user_id: z.string(),
+ welcome_email_sent: z.boolean(),
+ }),
+})
+
+const AuditEvent = BaseEvent.extend('AuditEvent', {
+ message: z.string(),
+})
+
+async function main(): Promise {
+ const bus = new EventBus('SimpleExampleBus')
+
+ // 2) Register a wildcard handler to observe every event flowing through this bus.
+ bus.on('*', (event: BaseEvent) => {
+ console.log(`[wildcard] ${event.event_type}#${event.event_id.slice(-8)}`)
+ })
+
+ // 3) Register by EventClass/factory (best type inference for payload + return type).
+ bus.on(RegisterUserEvent, async (event) => {
+ console.log(`[class handler] Creating account for ${event.email} (${event.plan})`)
+ return {
+ user_id: `user_${event.email.split('@')[0]}`,
+ welcome_email_sent: true,
+ }
+ })
+
+ // 4) Register by string event type (more dynamic, weaker compile-time checks).
+ bus.on('AuditEvent', (event: InstanceType) => {
+ console.log(`[string handler] Audit log: ${event.message}`)
+ })
+
+ // 5) Intentionally return an invalid result shape.
+ // This compiles because string-based registration is best-effort, but will fail
+ // at runtime because RegisterUserEvent has event_result_schema enforcement.
+ bus.on('RegisterUserEvent', () => {
+ return { user_id: 123, welcome_email_sent: 'yes' } as unknown
+ })
+
+ // Dispatch a simple event handled by a string registration.
+ await bus.emit(AuditEvent({ message: 'Starting simple bubus example' })).done()
+
+ // Dispatch the typed event; one handler returns valid data, one returns invalid data.
+ const register_event = bus.emit(
+ RegisterUserEvent({
+ email: 'ada@example.com',
+ plan: 'pro',
+ })
+ )
+ await register_event.done()
+
+ // 6) Inspect per-handler results (completed vs error) from event.event_results.
+ console.log('\nRegisterUserEvent handler outcomes:')
+ for (const result of register_event.event_results.values()) {
+ if (result.status === 'completed') {
+ console.log(`- ${result.handler_name}: completed -> ${JSON.stringify(result.result)}`)
+ continue
+ }
+ if (result.status === 'error') {
+ const message = result.error instanceof Error ? result.error.message : String(result.error)
+ console.log(`- ${result.handler_name}: error -> ${message}`)
+ console.log(` raw invalid return: ${JSON.stringify(result.raw_value)}`)
+ continue
+ }
+ console.log(`- ${result.handler_name}: ${result.status}`)
+ }
+
+ // 7) Convenience getters for aggregate inspection.
+ console.log('\nFirst valid parsed result:', register_event.first_result)
+ console.log(`Total event errors: ${register_event.event_errors.length}`)
+ for (const [index, error] of register_event.event_errors.entries()) {
+ const message = error instanceof Error ? error.message : String(error)
+ console.log(` ${index + 1}. ${message}`)
+ }
+
+ await bus.waitUntilIdle()
+ console.log('\n=== bus.logTree() ===')
+ console.log(bus.logTree())
+}
+
+main().catch((error) => {
+ console.error('Example failed:', error)
+ process.exitCode = 1
+})
diff --git a/bubus-ts/package.json b/bubus-ts/package.json
new file mode 100644
index 0000000..587da1a
--- /dev/null
+++ b/bubus-ts/package.json
@@ -0,0 +1,70 @@
+{
+ "name": "bubus",
+ "version": "1.8.1",
+ "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 prettier && eslint . && pnpm run typecheck",
+ "prettier": "prettier --write .",
+ "test": "NODE_OPTIONS='--expose-gc' node --expose-gc --test --import tsx tests/**/*.test.ts",
+ "perf": "pnpm run perf:node && pnpm run perf:bun && pnpm run perf:deno && pnpm run perf:browser",
+ "debug:node": "NODE_OPTIONS='--expose-gc' node --expose-gc --import tsx",
+ "debug:bun": "bun --expose-gc run",
+ "debug:deno": "deno run --v8-flags=--expose-gc",
+ "perf:node": "pnpm run build && pnpm run debug:node -- tests/performance.runtime.ts --scenario 50k-events && pnpm run debug:node -- tests/performance.runtime.ts --scenario 500-buses-x-100-events && pnpm run debug:node -- tests/performance.runtime.ts --scenario 1-event-x-50k-parallel-handlers && pnpm run debug:node -- tests/performance.runtime.ts --scenario 50k-one-off-handlers && pnpm run debug:node -- tests/performance.runtime.ts --scenario worst-case-forwarding-timeouts && pnpm run debug:node -- tests/performance.runtime.ts --scenario cleanup-equivalence",
+ "perf:bun": "pnpm run build && pnpm run debug:bun -- tests/performance.runtime.ts --scenario 50k-events && pnpm run debug:bun -- tests/performance.runtime.ts --scenario 500-buses-x-100-events && pnpm run debug:bun -- tests/performance.runtime.ts --scenario 1-event-x-50k-parallel-handlers && pnpm run debug:bun -- tests/performance.runtime.ts --scenario 50k-one-off-handlers && pnpm run debug:bun -- tests/performance.runtime.ts --scenario worst-case-forwarding-timeouts && pnpm run debug:bun -- tests/performance.runtime.ts --scenario cleanup-equivalence",
+ "perf:deno": "pnpm run build && pnpm run debug:deno -- tests/performance.runtime.ts --scenario 50k-events && pnpm run debug:deno -- tests/performance.runtime.ts --scenario 500-buses-x-100-events && pnpm run debug:deno -- tests/performance.runtime.ts --scenario 1-event-x-50k-parallel-handlers && pnpm run debug:deno -- tests/performance.runtime.ts --scenario 50k-one-off-handlers && pnpm run debug:deno -- tests/performance.runtime.ts --scenario worst-case-forwarding-timeouts && pnpm run debug:deno -- tests/performance.runtime.ts --scenario cleanup-equivalence",
+ "perf:browser": "pnpm run build && npx --yes --package=playwright -c 'PW_BIN=\"$(command -v playwright)\"; PW_NODE_MODULES=\"$(cd \"$(dirname \"$PW_BIN\")/..\" && pwd)\"; NODE_PATH=\"$PW_NODE_MODULES\" playwright test tests/performance.browser.spec.cjs --browser=chromium --workers=1 --reporter=line --output=/tmp/bubus-playwright-results'",
+ "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..2c7d82c
--- /dev/null
+++ b/bubus-ts/src/base_event.ts
@@ -0,0 +1,817 @@
+import { z } from 'zod'
+import { v7 as uuidv7 } from 'uuid'
+
+import type { EventBus } from './event_bus.js'
+import type { EventHandler } from './event_handler.js'
+import { EventResult } from './event_result.js'
+import { EventHandlerAbortedError, EventHandlerCancelledError, EventHandlerTimeoutError } from './event_handler.js'
+import type { EventConcurrencyMode, EventHandlerConcurrencyMode, EventHandlerCompletionMode, Deferred } from './lock_manager.js'
+import {
+ AsyncSemaphore,
+ EVENT_CONCURRENCY_MODES,
+ EVENT_HANDLER_CONCURRENCY_MODES,
+ EVENT_HANDLER_COMPLETION_MODES,
+ withResolvers,
+} from './lock_manager.js'
+import { extractZodShape, getStringTypeName, isZodSchema, toJsonSchema } from './types.js'
+import type { EventResultType } 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_handler_timeout: z.number().positive().nullable().optional(),
+ event_handler_slow_timeout: z.number().positive().nullable().optional(),
+ 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(EVENT_CONCURRENCY_MODES).nullable().optional(),
+ event_handler_concurrency: z.enum(EVENT_HANDLER_CONCURRENCY_MODES).nullable().optional(),
+ event_handler_completion: z.enum(EVENT_HANDLER_COMPLETION_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_handler_timeout'
+ | 'event_handler_slow_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'
+ | 'event_handler_completion'
+>
+
+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_handler_timeout?: number | null // optional per-event handler timeout override in seconds
+ event_handler_slow_timeout?: number | null // optional per-event slow handler warning threshold in seconds
+ 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 labels (name#id) 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?: EventConcurrencyMode | null // concurrency mode for the event as a whole in relation to other events
+ event_handler_concurrency?: EventHandlerConcurrencyMode | null // concurrency mode for the handlers within the event
+ event_handler_completion?: EventHandlerCompletionMode // completion strategy: 'all' (default) waits for every handler, 'first' returns earliest non-undefined result and cancels the rest
+
+ 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
+ _event_handler_semaphore: AsyncSemaphore | 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_handler_semaphore = 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_type: this.event_type,
+ event_result_schema: this.event_result_schema ? toJsonSchema(this.event_result_schema) : this.event_result_schema,
+ event_result_type: this.event_result_type,
+
+ // static configuration options
+ event_timeout: this.event_timeout,
+ event_concurrency: this.event_concurrency,
+ event_handler_concurrency: this.event_handler_concurrency,
+ event_handler_completion: this.event_handler_completion,
+ event_handler_slow_timeout: this.event_handler_slow_timeout,
+ event_handler_timeout: this.event_handler_timeout,
+
+ // mutable parent/child/bus tracking runtime state
+ event_parent_id: this.event_parent_id,
+ event_path: this.event_path,
+ event_emitted_by_handler_id: this.event_emitted_by_handler_id,
+ event_pending_bus_count: this.event_pending_bus_count,
+
+ // mutable runtime status and timestamps
+ event_status: this.event_status,
+ event_created_at: this.event_created_at,
+ event_created_ts: this.event_created_ts,
+ 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,
+
+ // mutable result state
+ event_results: Array.from(this.event_results.values()).map((result) => result.toJSON()),
+ }
+ }
+
+ createSlowEventWarningTimer(): ReturnType | null {
+ const event_slow_timeout =
+ (this as { event_slow_timeout?: number | null }).event_slow_timeout ??
+ (this as { slow_timeout?: number | null }).slow_timeout ??
+ this.bus?.event_slow_timeout ??
+ null
+ const event_warn_ms = event_slow_timeout === null ? null : event_slow_timeout * 1000
+ if (event_warn_ms === null) {
+ return null
+ }
+ const name = this.bus?.name ?? 'EventBus'
+ return setTimeout(() => {
+ if (this.event_status === 'completed') {
+ return
+ }
+ const running_handler_count = [...this.event_results.values()].filter((result) => result.status === 'started').length
+ const started_ts = this.event_started_ts ?? this.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: ${name}.on(${this.event_type}#${this.event_id.slice(-4)}, ${running_handler_count} handlers) still running after ${elapsed_seconds}s`
+ )
+ }, event_warn_ms)
+ }
+
+ createPendingHandlerResults(bus: EventBus): Array<{
+ handler: EventHandler
+ result: EventResult
+ }> {
+ const original_event = this._event_original ?? this
+ const scoped_event = bus.getEventProxyScopedToThisBus(original_event)
+ const handlers = bus.getHandlersForEvent(original_event)
+ return handlers.map((entry) => {
+ const handler_id = entry.id
+ const existing = original_event.event_results.get(handler_id)
+ const result = existing ?? new EventResult({ event: scoped_event, handler: entry })
+ if (!existing) {
+ original_event.event_results.set(handler_id, result)
+ } else if (existing.event !== scoped_event) {
+ existing.event = scoped_event
+ }
+ return { handler: entry, result }
+ })
+ }
+
+ // Run all pending handler results for the current bus context.
+ async processEvent(
+ pending_entries?: Array<{
+ handler: EventHandler
+ result: EventResult
+ }>
+ ): Promise {
+ const original = this._event_original ?? this
+ const bus_id = this.bus?.id
+ const pending_results =
+ pending_entries?.map((entry) => entry.result) ??
+ Array.from(original.event_results.values()).filter((result) => !bus_id || result.eventbus_id === bus_id)
+ if (pending_results.length === 0) {
+ return
+ }
+ const handler_promises = pending_results.map((entry) => entry.runHandler())
+ if (original.event_handler_completion === 'first') {
+ let first_found = false
+ const monitored = pending_results.map((entry, i) =>
+ handler_promises[i].then(() => {
+ if (!first_found && entry.status === 'completed' && entry.result !== undefined) {
+ first_found = true
+ original.cancelEventHandlersForFirstMode(entry)
+ }
+ })
+ )
+ await Promise.all(monitored)
+ } else {
+ await Promise.all(handler_promises)
+ }
+ }
+
+ getHandlerSemaphore(default_concurrency?: EventHandlerConcurrencyMode): AsyncSemaphore | null {
+ const original = this._event_original ?? this
+ const resolved =
+ original.event_handler_concurrency ?? default_concurrency ?? original.bus?.event_handler_concurrency_default ?? 'serial'
+ if (resolved === 'parallel') {
+ return null
+ }
+ if (!original._event_handler_semaphore) {
+ original._event_handler_semaphore = new AsyncSemaphore(1)
+ }
+ return original._event_handler_semaphore
+ }
+
+ // 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
+ }
+
+ // force-abort processing of all pending descendants of an event regardless of whether they have already started
+ cancelPendingDescendants(reason: unknown): void {
+ const original = this._event_original ?? this
+ const cancellation_cause =
+ reason instanceof EventHandlerTimeoutError
+ ? reason
+ : reason instanceof EventHandlerCancelledError || reason instanceof EventHandlerAbortedError
+ ? reason.cause instanceof Error
+ ? reason.cause
+ : reason
+ : reason instanceof Error
+ ? reason
+ : new Error(String(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)
+ }
+
+ original_child.markCancelled(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 original.event_children) {
+ cancelChildEvent(child)
+ }
+ }
+
+ // Cancel all handler results for an event except the winner, used by first() mode.
+ // Cancels pending handlers immediately, aborts started handlers via signalAbort(),
+ // and cancels any child events emitted by the losing handlers.
+ cancelEventHandlersForFirstMode(winner: EventResult): void {
+ const cause = new Error('first() resolved: another handler returned a result first')
+ const bus_id = winner.eventbus_id
+
+ for (const result of this.event_results.values()) {
+ if (result === winner) continue
+ if (result.eventbus_id !== bus_id) continue
+
+ if (result.status === 'pending') {
+ result.markError(
+ new EventHandlerCancelledError(`Cancelled: first() resolved`, {
+ event_result: result,
+ cause,
+ })
+ )
+ } else if (result.status === 'started') {
+ // Cancel child events emitted by this handler before aborting it
+ for (const child of result.event_children ?? []) {
+ const original_child = child._event_original ?? child
+ original_child.cancelPendingDescendants(cause)
+ original_child.markCancelled(cause)
+ }
+
+ // Abort the handler itself
+ result._lock?.exitHandlerRun()
+ const aborted_error = new EventHandlerAbortedError(`Aborted: first() resolved`, {
+ event_result: result,
+ cause,
+ })
+ result.markError(aborted_error)
+ result.signalAbort(aborted_error)
+ }
+ }
+ }
+
+ // force-abort processing of this event regardless of whether it is pending or has already started
+ markCancelled(cause: Error): void {
+ const original = this._event_original ?? this
+ const registry = this.bus!._all_instances
+ const path = Array.isArray(original.event_path) ? original.event_path : []
+ const buses_to_cancel = new Set(path)
+ for (const bus of registry as Iterable<{
+ name?: string
+ label?: string
+ pending_event_queue?: BaseEvent[]
+ in_flight_event_ids?: Set
+ createPendingHandlerResults?: (event: BaseEvent) => Array<{ result: EventResult }>
+ getHandlersForEvent?: (event: BaseEvent) => unknown
+ }>) {
+ if (!bus?.label || !buses_to_cancel.has(bus.label)) {
+ continue
+ }
+
+ const handler_entries = original.createPendingHandlerResults(bus as unknown as EventBus)
+ 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') {
+ 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 (Array.isArray(bus.pending_event_queue) && bus.pending_event_queue.length > 0) {
+ const before_len = bus.pending_event_queue.length
+ bus.pending_event_queue = bus.pending_event_queue.filter(
+ (queued) => (queued._event_original ?? queued).event_id !== original.event_id
+ )
+ removed = before_len - bus.pending_event_queue.length
+ }
+
+ if (removed > 0 && !bus.in_flight_event_ids?.has(original.event_id)) {
+ original.event_pending_bus_count = Math.max(0, original.event_pending_bus_count - 1)
+ }
+
+ if (updated || removed > 0) {
+ original.markCompleted(false)
+ }
+ }
+
+ if (original.event_status !== 'completed') {
+ original.markCompleted()
+ }
+ }
+
+ notifyEventParentsOfCompletion(): void {
+ const original = this._event_original ?? this
+ const registry = this.bus!._all_instances as { findEventById: (id: string) => BaseEvent | null }
+ const visited = new Set()
+ let parent_id = original.event_parent_id
+ while (parent_id && !visited.has(parent_id)) {
+ visited.add(parent_id)
+ const parent = registry.findEventById(parent_id)
+ if (!parent) {
+ break
+ }
+ parent.markCompleted(false, false)
+ if (parent.event_status !== 'completed') {
+ break
+ }
+ parent_id = parent.event_parent_id
+ }
+ }
+
+ // 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()
+ }
+
+ // returns the first non-undefined handler result value, cancelling remaining handlers
+ // when any handler completes. Works with all event_handler_concurrency modes:
+ // parallel: races all handlers, returns first non-undefined, aborts the rest
+ // serial: runs handlers sequentially, returns first non-undefined, skips remaining
+ first(): Promise | undefined> {
+ if (!this.bus) {
+ return Promise.reject(new Error('event has no bus attached'))
+ }
+ const original = this._event_original ?? this
+ original.event_handler_completion = 'first'
+ return this.done().then((completed_event) => {
+ const orig = completed_event._event_original ?? completed_event
+ return orig.first_result as EventResultType | undefined
+ })
+ }
+
+ // 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, notify_parents: 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
+ if (notify_parents && this.bus) {
+ this.notifyEventParentsOfCompletion()
+ }
+ }
+
+ 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
+ return (
+ Array.from(this.event_results.values())
+ // filter for events that have completed + have non-undefined error values
+ .filter((event_result) => event_result.error !== undefined && event_result.completed_ts !== undefined)
+ // sort by completion time
+ .sort((event_result_a, event_result_b) => (event_result_a.completed_ts ?? 0) - (event_result_b.completed_ts ?? 0))
+ // assemble array of flat error values
+ .map((event_result) => event_result.error)
+ )
+ }
+
+ // all non-undefined handler result values in completion order
+ get all_results(): EventResultType[] {
+ return (
+ Array.from(this.event_results.values())
+ // only events that have completed + have non-undefined result values
+ .filter((event_result) => event_result.completed_ts !== undefined && event_result.result !== undefined)
+ // sort by completion time
+ .sort((event_result_a, event_result_b) => (event_result_a.completed_ts ?? 0) - (event_result_b.completed_ts ?? 0))
+ // assemble array of flat parsed handler return values
+ .map((event_result) => event_result.result as EventResultType)
+ )
+ }
+
+ // Returns the first non-undefined completed handler result, sorted by completion time.
+ // Useful after first() or done() to get the winning result value.
+ get first_result(): EventResultType | undefined {
+ return this.all_results.at(0)
+ }
+
+ // Returns the last non-undefined completed handler result, sorted by completion time.
+ // Useful after first() or done() to get the winning result value.
+ get last_result(): EventResultType | undefined {
+ return this.all_results.at(-1)
+ }
+
+ 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
+ this._event_handler_semaphore = null
+ for (const result of this.event_results.values()) {
+ result.event_children = undefined
+ }
+ 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)
+ 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..f76dc4a
--- /dev/null
+++ b/bubus-ts/src/event_bus.ts
@@ -0,0 +1,854 @@
+import { BaseEvent } from './base_event.js'
+import { EventResult } from './event_result.js'
+import { captureAsyncContext } from './async_context.js'
+import {
+ AsyncSemaphore,
+ type EventConcurrencyMode,
+ type EventHandlerConcurrencyMode,
+ type EventHandlerCompletionMode,
+ LockManager,
+ runWithSemaphore,
+} from './lock_manager.js'
+import { EventHandler, type EphemeralFindEventHandler } from './event_handler.js'
+import { logTree } from './logging.js'
+import { v7 as uuidv7 } from 'uuid'
+
+import type { EventClass, EventHandlerFunction, EventKey, FindOptions, UntypedEventHandlerFunction } from './types.js'
+
+type EventBusOptions = {
+ id?: string
+ max_history_size?: number | null
+
+ // per-event options
+ event_concurrency?: EventConcurrencyMode | null
+ event_timeout?: number | null // default handler timeout in seconds, applied when event.event_timeout is undefined
+ event_slow_timeout?: number | null // threshold before a warning is logged about slow event processing
+
+ // per-event-handler options
+ event_handler_concurrency?: EventHandlerConcurrencyMode | null
+ event_handler_completion?: EventHandlerCompletionMode
+ event_handler_slow_timeout?: number | null // threshold before a warning is logged about slow handler execution
+ event_handler_detect_file_paths?: boolean // autodetect source code file and lineno where handlers are defined for better logs (slightly slower because Error().stack introspection to fine files is expensive)
+}
+
+// Global registry of all EventBus instances to allow for cross-bus coordination when global-serial concurrency mode is used
+class GlobalEventBusInstanceRegistry {
+ private _event_buses = new Set>()
+
+ add(bus: EventBus): void {
+ const ref = new WeakRef(bus)
+ this._event_buses.add(ref)
+ }
+
+ delete(bus: EventBus): void {
+ for (const ref of this._event_buses) {
+ const current = ref.deref()
+ if (!current || current === bus) {
+ this._event_buses.delete(ref)
+ }
+ }
+ }
+
+ has(bus: EventBus): boolean {
+ for (const ref of this._event_buses) {
+ const current = ref.deref()
+ if (!current) {
+ this._event_buses.delete(ref)
+ continue
+ }
+ if (current === bus) {
+ return true
+ }
+ }
+ return false
+ }
+
+ get size(): number {
+ let n = 0
+ for (const ref of this._event_buses) ref.deref() ? n++ : this._event_buses.delete(ref)
+ return n
+ }
+
+ *[Symbol.iterator](): Iterator {
+ for (const ref of this._event_buses) {
+ const bus = ref.deref()
+ if (bus) yield bus
+ else this._event_buses.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()
+ get _all_instances(): GlobalEventBusInstanceRegistry {
+ return EventBus._all_instances
+ }
+
+ id: string // unique uuidv7 identifier for the event bus
+ 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_timeout_default: number | null
+ event_concurrency_default: EventConcurrencyMode
+ event_handler_concurrency_default: EventHandlerConcurrencyMode
+ event_handler_completion_default: EventHandlerCompletionMode
+ event_handler_detect_file_paths: boolean
+
+ // slow processing warning timeout settings
+ event_handler_slow_timeout: number | null
+ event_slow_timeout: number | null
+
+ // public runtime state
+ handlers: Map // map of handler uuidv5 ids to EventHandler objects
+ handlers_by_key: Map // map of normalized event_key to ordered handler ids
+ 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 EphemeralFindEventHandler objects that are waiting for a matching future event
+
+ constructor(name: string = 'EventBus', options: EventBusOptions = {}) {
+ this.id = options.id ?? uuidv7()
+ 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 ?? 'serial'
+ this.event_handler_completion_default = options.event_handler_completion ?? 'all'
+ this.event_handler_detect_file_paths = options.event_handler_detect_file_paths ?? true
+ 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.runloop_running = false
+ this.handlers = new Map()
+ this.handlers_by_key = new Map()
+ this.find_waiters = new Set()
+ this.event_history = new Map()
+ this.pending_event_queue = []
+ this.in_flight_event_ids = new Set()
+ this.locks = new LockManager(this)
+
+ EventBus._all_instances.add(this)
+
+ this.dispatch = this.dispatch.bind(this)
+ this.emit = this.emit.bind(this)
+ }
+
+ toString(): string {
+ return `${this.name}#${this.id.slice(-4)}`
+ }
+
+ get label(): string {
+ return `${this.name}#${this.id.slice(-4)}`
+ }
+
+ // destroy the event bus and all its state to allow for garbage collection
+ destroy(): void {
+ EventBus._all_instances.delete(this)
+ this.handlers.clear()
+ this.handlers_by_key.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?: Partial): EventHandler
+ on(event_key: string | '*', handler: UntypedEventHandlerFunction, options?: Partial): EventHandler
+ on(
+ event_key: EventKey | '*',
+ handler: EventHandlerFunction | UntypedEventHandlerFunction,
+ options: Partial = {}
+ ): 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_entry = new EventHandler({
+ handler: handler as EventHandlerFunction,
+ handler_name,
+ handler_registered_at,
+ handler_registered_ts,
+ event_key: normalized_key,
+ eventbus_name: this.name,
+ eventbus_id: this.id,
+ ...options,
+ })
+ if (this.event_handler_detect_file_paths) {
+ // optionally peform (expensive) file path detection for the handler using Error().stack introspection
+ // makes logs much more useful for debugging, but is expensive to do if not needed
+ handler_entry.detectHandlerFilePath()
+ }
+
+ this.handlers.set(handler_entry.id, handler_entry)
+ const ids = this.handlers_by_key.get(handler_entry.event_key)
+ if (ids) ids.push(handler_entry.id)
+ else this.handlers_by_key.set(handler_entry.event_key, [handler_entry.id])
+ 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)
+ this.removeIndexedHandler(entry.event_key, 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_handler_completion === undefined) {
+ original_event.event_handler_completion = this.event_handler_completion_default
+ }
+
+ if (original_event.event_path.includes(this.label) || this.hasProcessedEvent(original_event)) {
+ return this.getEventProxyScopedToThisBus(original_event) as T
+ }
+
+ if (!original_event.event_path.includes(this.label)) {
+ original_event.event_path.push(this.label)
+ }
+
+ 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: EphemeralFindEventHandler = {
+ 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)
+ })
+ }
+
+ 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_id !== this.id) {
+ 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
+ }
+
+ 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
+ // optionally using a pre-acquired semaphore if we're inside handling of a parent event
+ private async processEvent(
+ event: BaseEvent,
+ options: {
+ bypass_event_semaphores?: boolean
+ pre_acquired_semaphore?: AsyncSemaphore | null
+ } = {}
+ ): Promise {
+ try {
+ if (this.hasProcessedEvent(event)) {
+ return
+ }
+ event.markStarted()
+ this.notifyFindListeners(event)
+ const slow_event_warning_timer = event.createSlowEventWarningTimer()
+ const semaphore = options.bypass_event_semaphores ? null : this.locks.getSemaphoreForEvent(event)
+ const pre_acquired_semaphore = options.pre_acquired_semaphore ?? null
+ try {
+ if (pre_acquired_semaphore) {
+ const pending_entries = event.createPendingHandlerResults(this)
+ await this.getEventProxyScopedToThisBus(event).processEvent(pending_entries)
+ } else {
+ await runWithSemaphore(semaphore, async () => {
+ const pending_entries = event.createPendingHandlerResults(this)
+ await this.getEventProxyScopedToThisBus(event).processEvent(pending_entries)
+ })
+ }
+ event.event_pending_bus_count = Math.max(0, event.event_pending_bus_count - 1)
+ event.markCompleted(false)
+ } finally {
+ if (slow_event_warning_timer) {
+ clearTimeout(slow_event_warning_timer)
+ }
+ }
+ } finally {
+ if (options.pre_acquired_semaphore) {
+ options.pre_acquired_semaphore.release()
+ }
+ this.in_flight_event_ids.delete(event.event_id)
+ this.locks.notifyIdleListeners()
+ }
+ }
+
+ // 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 serial handler mode). 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.processEvent(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 bus runloop pauses and (will resume when the handler exits)
+ currently_active_event_result.ensureQueueJumpPause(this)
+ if (original_event.event_status === 'completed') {
+ return event
+ }
+
+ // re-endter event-level handler lock if needed
+ if (currently_active_event_result._lock) {
+ await currently_active_event_result._lock.runQueueJump(this.processEventImmediatelyAcrossBuses.bind(this, original_event))
+ return event
+ }
+
+ await this.processEventImmediatelyAcrossBuses(original_event)
+ return event
+ }
+
+ // Processes a queue-jumped event across all buses that have it dispatched.
+ // Called from processEventImmediately after the parent handler's semaphore has been yielded.
+ private async processEventImmediatelyAcrossBuses(event: BaseEvent): Promise {
+ // Use event_path ordering to pick candidate buses and filter out buses that
+ // haven't seen the event or already processed it.
+ const ordered: EventBus[] = []
+ const seen = new Set()
+ const event_path = Array.isArray(event.event_path) ? event.event_path : []
+ for (const label of event_path) {
+ for (const bus of EventBus._all_instances) {
+ if (bus.label !== label) {
+ 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)
+ }
+ if (ordered.length === 0) {
+ await event.waitForCompletion()
+ return
+ }
+
+ // 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)
+ const pause_releases: Array<() => void> = []
+
+ try {
+ for (const bus of ordered) {
+ if (bus !== this) {
+ pause_releases.push(bus.locks.requestRunloopPause())
+ }
+ }
+
+ for (const bus of ordered) {
+ 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.processEvent(event, {
+ bypass_event_semaphores: should_bypass_event_semaphore,
+ })
+ }
+
+ if (event.event_status !== 'completed') {
+ await event.waitForCompletion()
+ }
+ } finally {
+ for (const release of pause_releases) {
+ release()
+ }
+ }
+ }
+
+ 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.processEvent(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
+ }
+ }
+
+ // 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_id === this.id)
+ if (results.length === 0) {
+ return false
+ }
+ return results.every((result) => result.status === 'completed' || result.status === 'error')
+ }
+
+ // 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
+ }
+
+ 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)
+ }
+ }
+
+ getHandlersForEvent(event: BaseEvent): EventHandler[] {
+ const handlers: EventHandler[] = []
+ for (const key of [event.event_type, '*']) {
+ const ids = this.handlers_by_key.get(key)
+ if (!ids) continue
+ for (const id of ids) {
+ const entry = this.handlers.get(id)
+ if (entry) handlers.push(entry)
+ }
+ }
+ return handlers
+ }
+
+ private removeIndexedHandler(event_key: string | '*', handler_id: string): void {
+ const ids = this.handlers_by_key.get(event_key)
+ if (!ids) return
+ const idx = ids.indexOf(handler_id)
+ if (idx >= 0) ids.splice(idx, 1)
+ if (ids.length === 0) this.handlers_by_key.delete(event_key)
+ }
+
+ 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..cf2bcd4
--- /dev/null
+++ b/bubus-ts/src/event_handler.ts
@@ -0,0 +1,255 @@
+import { z } from 'zod'
+import { v5 as uuidv5 } from 'uuid'
+
+import type { EventHandlerFunction, EventKey } from './types.js'
+import { BaseEvent } from './base_event.js'
+import type { EventResult } from './event_result.js'
+
+const HANDLER_ID_NAMESPACE = uuidv5('bubus-handler', uuidv5.DNS)
+
+export type EphemeralFindEventHandler = {
+ // Similar to a handler, except it's for .find() calls.
+ // Resolved on dispatch, ephemeral, and never shows up in the processing tree.
+ event_key: EventKey
+ matches: (event: BaseEvent) => boolean
+ resolve: (event: BaseEvent) => void
+ timeout_id?: ReturnType
+}
+
+export const EventHandlerJSONSchema = z
+ .object({
+ id: z.string(),
+ eventbus_name: z.string(),
+ eventbus_id: z.string().uuid(),
+ event_key: z.union([z.string(), z.literal('*')]),
+ handler_name: z.string(),
+ handler_file_path: z.string().optional(),
+ handler_timeout: z.number().nullable().optional(),
+ handler_slow_timeout: z.number().nullable().optional(),
+ handler_registered_at: z.string(),
+ handler_registered_ts: z.number(),
+ })
+ .strict()
+
+export type EventHandlerJSON = z.infer
+
+// 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, resolved at runtime if not set
+ handler_slow_timeout?: number | null // warning threshold in seconds for slow handler execution
+ 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
+ eventbus_id: string // uuidv7 identifier 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
+ handler_slow_timeout?: number | null
+ handler_registered_at: string
+ handler_registered_ts: number
+ event_key: string | '*'
+ eventbus_name: string
+ eventbus_id: string
+ }) {
+ this.id =
+ params.id ??
+ EventHandler.computeHandlerId({
+ eventbus_id: params.eventbus_id,
+ handler_name: params.handler_name,
+ handler_file_path: params.handler_file_path,
+ handler_registered_at: params.handler_registered_at,
+ handler_registered_ts: params.handler_registered_ts,
+ event_key: params.event_key,
+ })
+ this.handler = params.handler
+ this.handler_name = params.handler_name
+ this.handler_file_path = params.handler_file_path
+ this.handler_timeout = params.handler_timeout
+ this.handler_slow_timeout = params.handler_slow_timeout
+ 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
+ this.eventbus_id = params.eventbus_id
+ }
+
+ // 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_id: string
+ handler_name: string
+ handler_file_path?: string
+ handler_registered_at: string
+ handler_registered_ts: number
+ event_key: string | '*'
+ }): string {
+ const file_path = params.handler_file_path ?? 'unknown'
+ const seed = `${params.eventbus_id}|${params.handler_name}|${file_path}|${params.handler_registered_at}|${params.handler_registered_ts}|${params.event_key}`
+ return uuidv5(seed, HANDLER_ID_NAMESPACE)
+ }
+
+ // "someHandlerName() (~/path/to/source/file.ts:123)" <- best case when file path is available and its a named function
+ // "function#1234()" <- worst case when no file path is available and its an anonymous/arrow function defined inline
+ toString(): string {
+ const label = this.handler_name && this.handler_name !== 'anonymous' ? `${this.handler_name}()` : `function#${this.id.slice(-4)}()`
+ return this.handler_file_path ? `${label} (${this.handler_file_path})` : label
+ }
+
+ // autodetect the path/to/source/file.ts:lineno where the handler is defined for better logs
+ // optional (controlled by EventBus.event_handler_detect_file_paths) because it can slow down performance to introspect stack traces and find file paths
+ detectHandlerFilePath(): void {
+ const line = new Error().stack
+ ?.split('\n')
+ .map((l) => l.trim())
+ .filter(Boolean)[4]
+ if (!line) return
+ const resolved_path =
+ line.trim().match(/\(([^)]+)\)$/)?.[1] ??
+ line.trim().match(/^\s*at\s+(.+)$/)?.[1] ??
+ line.trim().match(/^[^@]+@(.+)$/)?.[1] ??
+ line.trim()
+ 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, '~/')
+ this.handler_file_path = line_number ? `${normalized}:${line_number}` : normalized
+ }
+
+ toJSON(): EventHandlerJSON {
+ return {
+ id: this.id,
+ eventbus_name: this.eventbus_name,
+ eventbus_id: this.eventbus_id,
+ event_key: this.event_key,
+ handler_name: this.handler_name,
+ handler_file_path: this.handler_file_path,
+ handler_timeout: this.handler_timeout,
+ handler_slow_timeout: this.handler_slow_timeout,
+ handler_registered_at: this.handler_registered_at,
+ handler_registered_ts: this.handler_registered_ts,
+ }
+ }
+
+ static fromJSON(data: unknown, handler?: EventHandlerFunction): EventHandler {
+ const record = EventHandlerJSONSchema.parse(data)
+ const handler_fn = handler ?? ((() => undefined) as EventHandlerFunction)
+ const handler_name = record.handler_name || handler_fn.name || 'anonymous' // 'anonymous' is the default name for anonymous/arrow functions
+ return new EventHandler({
+ id: record.id,
+ handler: handler_fn,
+ handler_name,
+ handler_file_path: record.handler_file_path,
+ handler_timeout: record.handler_timeout,
+ handler_slow_timeout: record.handler_slow_timeout,
+ handler_registered_at: record.handler_registered_at,
+ handler_registered_ts: record.handler_registered_ts,
+ event_key: record.event_key,
+ eventbus_name: record.eventbus_name,
+ eventbus_id: record.eventbus_id,
+ })
+ }
+}
+
+// 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
+ }
+
+ get expected_schema(): any {
+ return this.event_result.event.event_result_schema
+ }
+}
diff --git a/bubus-ts/src/event_result.ts b/bubus-ts/src/event_result.ts
new file mode 100644
index 0000000..fd12d23
--- /dev/null
+++ b/bubus-ts/src/event_result.ts
@@ -0,0 +1,429 @@
+import { v7 as uuidv7 } from 'uuid'
+
+import { z } from 'zod'
+
+import { BaseEvent } from './base_event.js'
+import type { EventBus } from './event_bus.js'
+import {
+ EventHandler,
+ EventHandlerCancelledError,
+ EventHandlerJSONSchema,
+ EventHandlerResultSchemaError,
+ EventHandlerTimeoutError,
+} from './event_handler.js'
+import { HandlerLock, withResolvers } from './lock_manager.js'
+import type { Deferred } from './lock_manager.js'
+import type { EventHandlerFunction, EventResultType } from './types.js'
+import { runWithAsyncContext } from './async_context.js'
+import { RetryTimeoutError } from './retry.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 const EventResultJSONSchema = z
+ .object({
+ id: z.string(),
+ status: z.enum(['pending', 'started', 'completed', 'error']),
+ event_id: z.string(),
+ handler: EventHandlerJSONSchema,
+ started_at: z.string().optional(),
+ started_ts: z.number().optional(),
+ completed_at: z.string().optional(),
+ completed_ts: z.number().optional(),
+ result: z.unknown().optional(),
+ error: z.unknown().optional(),
+ event_children: z.array(z.string()).optional(),
+ })
+ .strict()
+
+export type EventResultJSON = z.infer
+
+// 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[] | undefined // lazily allocated list of emitted child events
+
+ // Abort signal: created when handler starts, rejected by signalAbort() to
+ // interrupt runHandler's await via Promise.race.
+ _abort: Deferred | null
+ // Handler lock: tracks ownership of the handler concurrency semaphore
+ // during handler execution. Set by runHandler(), used by
+ // processEventImmediately for yield-and-reacquire during queue-jumps.
+ _lock: HandlerLock | null
+ // Runloop pause releases keyed by bus for queue-jump; released when handler exits.
+ _queue_jump_pause_releases: Map void> | null
+
+ constructor(params: { event: TEvent; handler: EventHandler }) {
+ this.id = uuidv7()
+ this.status = 'pending'
+ this.event = params.event
+ this.handler = params.handler
+ this.result = undefined
+ this.error = undefined
+ this._abort = null
+ this._lock = null
+ this._queue_jump_pause_releases = null
+ }
+
+ toString(): string {
+ return `${this.result ?? 'null'} (${this.status})`
+ }
+
+ get event_id(): string {
+ return this.event.event_id
+ }
+
+ get bus(): EventBus {
+ return this.event.bus!
+ }
+
+ 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 eventbus_name(): string {
+ return this.handler.eventbus_name
+ }
+
+ get eventbus_id(): string {
+ return this.handler.eventbus_id
+ }
+
+ get eventbus_label(): string {
+ return `${this.handler.eventbus_name}#${this.handler.eventbus_id.slice(-4)}`
+ }
+
+ // 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
+ }
+ // Performance: most handlers emit no children, so keep this undefined until first use.
+ const children = this.event_children ?? (this.event_children = [])
+ if (!children.some((child) => child.event_id === original_child.event_id)) {
+ 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
+ }
+
+ // Resolve handler timeout in seconds using precedence: handler -> event -> bus defaults.
+ get handler_timeout(): number | null {
+ const original = this.event._event_original ?? this.event
+ const bus = this.bus
+ const resolved_handler_timeout =
+ this.handler.handler_timeout !== undefined
+ ? this.handler.handler_timeout
+ : original.event_handler_timeout !== undefined
+ ? original.event_handler_timeout
+ : (bus?.event_timeout_default ?? null)
+ const resolved_event_timeout = original.event_timeout ?? null
+ if (resolved_handler_timeout === null && resolved_event_timeout === null) {
+ return null
+ }
+ if (resolved_handler_timeout === null) {
+ return resolved_event_timeout
+ }
+ if (resolved_event_timeout === null) {
+ return resolved_handler_timeout
+ }
+ return Math.min(resolved_handler_timeout, resolved_event_timeout)
+ }
+
+ // Resolve slow handler warning threshold in seconds using precedence: handler -> event -> bus defaults.
+ get handler_slow_timeout(): number | null {
+ const original = this.event._event_original ?? this.event
+ const bus = this.bus
+
+ if (this.handler.handler_slow_timeout !== undefined) {
+ return this.handler.handler_slow_timeout
+ }
+ if (original.event_handler_slow_timeout !== undefined) {
+ return original.event_handler_slow_timeout
+ }
+ const event_slow_timeout = (original as { event_slow_timeout?: number | null }).event_slow_timeout
+ if (event_slow_timeout !== undefined) {
+ return event_slow_timeout
+ }
+ const slow_timeout = (original as { slow_timeout?: number | null }).slow_timeout
+ if (slow_timeout !== undefined) {
+ return slow_timeout
+ }
+ if (bus?.event_handler_slow_timeout !== undefined) {
+ return bus.event_handler_slow_timeout
+ }
+ return bus?.event_slow_timeout ?? null
+ }
+
+ // Create a slow-handler warning timer that logs if the handler runs too long.
+ createSlowHandlerWarningTimer(effective_timeout: number | null): ReturnType | null {
+ const handler_warn_timeout = this.handler_slow_timeout
+ const warn_ms = handler_warn_timeout === null ? null : handler_warn_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 event = this.event._event_original ?? this.event
+ const bus_name = this.handler.eventbus_name
+ const started_at_ms = performance.now()
+ return setTimeout(() => {
+ if (this.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: ${bus_name}.on(${event.toString()}, ${this.handler.toString()}) still running after ${elapsed_seconds}s`
+ )
+ }, warn_ms)
+ }
+
+ ensureQueueJumpPause(bus: EventBus): void {
+ if (!this._queue_jump_pause_releases) {
+ this._queue_jump_pause_releases = new Map()
+ }
+ if (this._queue_jump_pause_releases.has(bus)) {
+ return
+ }
+ this._queue_jump_pause_releases.set(bus, bus.locks.requestRunloopPause())
+ }
+
+ releaseQueueJumpPauses(): void {
+ if (!this._queue_jump_pause_releases) {
+ return
+ }
+ for (const release of this._queue_jump_pause_releases.values()) {
+ release()
+ }
+ this._queue_jump_pause_releases.clear()
+ }
+
+ // Run the handler end-to-end, including concurrency locks, timeouts, and result tracking.
+ async runHandler(): Promise {
+ if (this.status === 'error' && this.error instanceof EventHandlerCancelledError) {
+ return
+ }
+
+ const event = this.event._event_original ?? this.event
+ const bus = this.bus
+ const handler_event = bus ? bus.getEventProxyScopedToThisBus(event, this) : event
+ const semaphore = event.getHandlerSemaphore(bus?.event_handler_concurrency_default)
+
+ if (semaphore) {
+ await semaphore.acquire()
+ }
+
+ // if the result is already in an error or completed state, release the semaphore immediately and return
+ if (this.status === 'error' || this.status === 'completed') {
+ if (semaphore) semaphore.release()
+ return
+ }
+
+ // exit the handler lock if it is already held
+ if (this._lock) this._lock.exitHandlerRun()
+ // create a new handler lock to track ownership of the semaphore during handler execution
+ // Performance: skip HandlerLock allocation when no semaphore is active.
+ this._lock = semaphore ? new HandlerLock(semaphore) : null
+ if (bus) {
+ bus.locks.enterActiveHandlerContext(this)
+ }
+
+ // resolve the effective timeout by combining the event timeout and the handler timeout
+ const effective_timeout = this.handler_timeout
+ const slow_handler_warning_timer = this.createSlowHandlerWarningTimer(effective_timeout)
+
+ const run_handler = () =>
+ Promise.resolve().then(() => runWithAsyncContext(event._event_dispatch_context ?? null, () => this.handler.handler(handler_event)))
+
+ try {
+ const abort_signal = this.markStarted()
+ let handler_result: unknown
+
+ if (effective_timeout === null) {
+ handler_result = await Promise.race([run_handler(), abort_signal])
+ } else {
+ const timeout_seconds = effective_timeout
+ const timeout_ms = timeout_seconds * 1000
+
+ const { promise, resolve, reject } = withResolvers()
+ let settled = false
+
+ const finalize = (fn: (value?: unknown) => void) => {
+ return (value?: unknown) => {
+ if (settled) {
+ return
+ }
+ settled = true
+ clearTimeout(timer)
+ fn(value)
+ }
+ }
+
+ const bus_label = bus?.toString() ?? this.eventbus_label
+ const timer = setTimeout(() => {
+ finalize(reject)(
+ new EventHandlerTimeoutError(
+ `${bus_label}.on(${event.toString()}, ${this.handler.toString()}) timed out after ${timeout_seconds}s`,
+ {
+ event_result: this,
+ timeout_seconds,
+ }
+ )
+ )
+ }, timeout_ms)
+
+ run_handler().then(finalize(resolve)).catch(finalize(reject))
+
+ handler_result = await Promise.race([promise, abort_signal])
+ }
+
+ if (event.event_result_schema && handler_result !== undefined) {
+ const parsed = event.event_result_schema.safeParse(handler_result)
+ if (parsed.success) {
+ this.markCompleted(parsed.data as EventResultType)
+ } else {
+ const bus_label = bus?.toString() ?? this.eventbus_label
+ const error = new EventHandlerResultSchemaError(
+ `${bus_label}.on(${event.toString()}, ${this.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: this, cause: parsed.error, raw_value: handler_result }
+ )
+ this.markError(error)
+ }
+ } else {
+ this.markCompleted(handler_result as EventResultType | undefined)
+ }
+ } catch (error) {
+ const normalized_error =
+ error instanceof RetryTimeoutError
+ ? new EventHandlerTimeoutError(error.message, { event_result: this, timeout_seconds: error.timeout_seconds, cause: error })
+ : error
+ if (normalized_error instanceof EventHandlerTimeoutError) {
+ this.markError(normalized_error)
+ event.cancelPendingDescendants(normalized_error)
+ } else {
+ this.markError(normalized_error)
+ }
+ } finally {
+ this._abort = null
+ this._lock?.exitHandlerRun()
+ if (bus) {
+ bus.locks.exitActiveHandlerContext(this)
+ this.releaseQueueJumpPauses()
+ }
+ if (slow_handler_warning_timer) {
+ clearTimeout(slow_handler_warning_timer)
+ }
+ }
+ }
+
+ // Reject the abort promise, causing runHandler'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(): EventResultJSON {
+ return {
+ id: this.id,
+ status: this.status,
+ event_id: this.event.event_id,
+ handler: this.handler.toJSON(),
+ 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 {
+ const record = EventResultJSONSchema.parse(data)
+ const handler_stub = EventHandler.fromJSON(record.handler, (() => undefined) as EventHandlerFunction)
+
+ const result = new EventResult({ event, handler: handler_stub })
+ result.id = record.id
+ result.status = record.status
+ result.started_at = record.started_at
+ result.started_ts = record.started_ts
+ result.completed_at = record.completed_at
+ 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 = undefined
+ return result
+ }
+}
diff --git a/bubus-ts/src/index.ts b/bubus-ts/src/index.ts
new file mode 100644
index 0000000..bf31edb
--- /dev/null
+++ b/bubus-ts/src/index.ts
@@ -0,0 +1,18 @@
+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 {
+ EventConcurrencyMode,
+ EventHandlerConcurrencyMode,
+ EventHandlerCompletionMode,
+ 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..004948a
--- /dev/null
+++ b/bubus-ts/src/lock_manager.ts
@@ -0,0 +1,321 @@
+import type { BaseEvent } from './base_event.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 EVENT_CONCURRENCY_MODES = ['global-serial', 'bus-serial', 'parallel'] as const
+export type EventConcurrencyMode = (typeof EVENT_CONCURRENCY_MODES)[number]
+
+export const EVENT_HANDLER_CONCURRENCY_MODES = ['serial', 'parallel'] as const
+export type EventHandlerConcurrencyMode = (typeof EVENT_HANDLER_CONCURRENCY_MODES)[number]
+
+export const EVENT_HANDLER_COMPLETION_MODES = ['all', 'first'] as const
+export type EventHandlerCompletionMode = (typeof EVENT_HANDLER_COMPLETION_MODES)[number]
+
+// ─── 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 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 EventResult.runHandler 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: EventConcurrencyMode
+}
+
+// The LockManager is responsible for managing the concurrency of events and handlers
+export class LockManager {
+ private bus: EventBusInterfaceForLockManager // Live bus reference; used to read defaults and idle state.
+
+ static global_event_semaphore = new AsyncSemaphore(1) // used for the global-serial concurrency mode
+ readonly bus_event_semaphore: AsyncSemaphore // Per-bus event semaphore; created with LockManager and never swapped.
+ private pause_depth: number // Re-entrant pause counter; increments on requestRunloopPause, decrements on release.
+ private pause_waiters: Array<() => void> // Resolvers for waitUntilRunloopResumed; drained when pause_depth hits 0.
+ 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.pause_depth = 0
+ this.pause_waiters = []
+ 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 during queue-jump across buses.
+ requestRunloopPause(): () => 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
+ }
+
+ waitForIdle(): Promise {
+ 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()
+ }
+ }
+
+ // get the bus-level semaphore that prevents/allows multiple events to be processed concurrently on the same bus
+ getSemaphoreForEvent(event: BaseEvent): AsyncSemaphore | null {
+ const resolved = event.event_concurrency ?? this.bus.event_concurrency_default
+ if (resolved === 'parallel') {
+ return null
+ }
+ if (resolved === 'global-serial') {
+ return LockManager.global_event_semaphore
+ }
+ return this.bus_event_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.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..0238bcd
--- /dev/null
+++ b/bubus-ts/src/logging.ts
@@ -0,0 +1,244 @@
+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
+ toString?: () => string
+}
+
+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[] = []
+ const bus_label = typeof bus.toString === 'function' ? bus.toString() : bus.name
+ lines.push(`📊 Event History Tree for ${bus_label}`)
+ 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_label}.${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
+
+ const direct_children = result.event_children ?? []
+ if (direct_children.length === 0) {
+ return line
+ }
+
+ const child_lines: string[] = []
+ 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..fed3c1c
--- /dev/null
+++ b/bubus-ts/src/retry.ts
@@ -0,0 +1,344 @@
+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.
+ * If a function is provided, it receives the same arguments as the wrapped function. */
+ semaphore_name?: string | ((...args: any[]) => 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