@bluelibs/runner 6.3.0 → 6.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,947 @@
1
+ # Runner Remote Lanes
2
+
3
+ ← [Back to main README](../README.md) | [Full guide](./FULL_GUIDE.md)
4
+
5
+ ---
6
+
7
+ When your Runner system grows beyond a single process — separate workers for email, a billing service on its own box, event propagation across microservices — you need a routing layer that doesn't rewrite your domain model. Remote Lanes are that layer.
8
+
9
+ - **Event Lanes**: async, queue-backed event delivery
10
+ - **RPC Lanes**: sync RPC calls for lane-assigned tasks/events
11
+
12
+ Both are **topology-driven** and implemented as Node runtime resources. Topology means you declare which runtime profiles consume or serve which lanes, and which infrastructure (queues or communicators) backs each lane.
13
+
14
+ ## Start Here: Event Lane or RPC Lane?
15
+
16
+ Use this table when you're choosing a lane system for a new flow.
17
+
18
+ | Concern | Event Lanes | RPC Lanes |
19
+ | ----------------- | --------------------------------- | ------------------------------------- |
20
+ | Latency model | asynchronous | synchronous |
21
+ | Delivery model | queue-driven delivery and retries | request/response call path |
22
+ | Caller experience | emit and continue | await remote result |
23
+ | Coupling | lower temporal coupling | tighter temporal coupling |
24
+ | Failure surface | enqueue/consume and retry policy | remote call and communicator contract |
25
+
26
+ **TL;DR:** Use **RPC Lanes for request/response**, and **Event Lanes for async propagation**.
27
+
28
+ A common architecture combines both: issue a command via RPC Lane, then propagate the domain result via Event Lane. For example, the API calls `billing.tasks.chargeCard` over RPC, and the billing service emits `billing.events.cardCharged` over an Event Lane for downstream projections.
29
+
30
+ ## How Lanes Plug Into Runner Core
31
+
32
+ Remote Lanes work through runtime interception and decoration — they never touch your core domain definitions.
33
+
34
+ - Event Lanes register an event emission interceptor. Only lane-assigned events are intercepted; everything else passes through unchanged.
35
+ - RPC Lanes decorate lane-assigned task execution at runtime and route lane-assigned events through an event interceptor.
36
+ - Non-lane-assigned tasks and events continue through normal Runner behavior.
37
+
38
+ Your task and event definitions stay exactly the same. Lane routing is attached purely by resource configuration.
39
+
40
+ ### Design Boundary: Lanes Route Work, Hooks Decide Side Effects
41
+
42
+ Remote lanes (`lane` / `profile` / `binding`) are infrastructure controls: routing, delivery mode, reliability, and scale.
43
+
44
+ - Use lanes/profiles to decide **where and how** work runs.
45
+ - Use hook/task logic (feature flags, business rules, tenant/region policy) to decide **what should happen**.
46
+
47
+ Runner intentionally does **not** provide lane/profile-level hook allow/deny gating. We want to avoid coupling transport topology to domain behavior, because that creates hidden behavior and a larger config/test matrix.
48
+
49
+ In practice:
50
+
51
+ - If you need throughput/locality/fault-isolation changes, adjust lane topology.
52
+ - If you need to enable/disable a side effect, do it in hook business logic.
53
+ - If semantics truly differ, split events instead of transport-filtering hooks.
54
+
55
+ Related transactional boundary: transactional events are in-process rollback semantics, so `transactional + eventLane` is invalid by design.
56
+
57
+ ### Event Lane Data Flow
58
+
59
+ ```mermaid
60
+ graph LR
61
+ E[emit event] --> I{Lane-assigned?}
62
+ I -->|Yes| Q[Serialize and enqueue]
63
+ I -->|No| L[Normal local pipeline]
64
+ Q --> C[Consumer dequeues]
65
+ C --> D[Deserialize and relay-emit]
66
+ D --> H[Hooks run locally]
67
+
68
+ style E fill:#FF9800,color:#fff
69
+ style Q fill:#2196F3,color:#fff
70
+ style C fill:#2196F3,color:#fff
71
+ style D fill:#2196F3,color:#fff
72
+ style H fill:#4CAF50,color:#fff
73
+ style L fill:#4CAF50,color:#fff
74
+ ```
75
+
76
+ ### RPC Lane Routing
77
+
78
+ ```mermaid
79
+ graph LR
80
+ T[runTask] --> R{Lane-assigned?}
81
+ R -->|Yes| S{In profile serve?}
82
+ S -->|Yes| LE[Execute locally]
83
+ S -->|No| RE[Route via communicator]
84
+ R -->|No| LE
85
+
86
+ style T fill:#4CAF50,color:#fff
87
+ style LE fill:#4CAF50,color:#fff
88
+ style RE fill:#2196F3,color:#fff
89
+ ```
90
+
91
+ ## Local Development Without Extra Microservices
92
+
93
+ You don't need RabbitMQ or a separate RPC service running locally to develop with lanes. Here are three paths, ordered from fastest feedback to most realistic.
94
+
95
+ ### Path 1: `transparent` Mode (Fast Smoke Test)
96
+
97
+ Use when you want lane assignments present but transport bypassed entirely.
98
+
99
+ - **Pros**: fastest local loop, zero queue/communicator setup
100
+ - **Cons**: does not exercise transport boundaries
101
+
102
+ ```typescript
103
+ import { r, tags } from "@bluelibs/runner";
104
+ import { eventLanesResource, rpcLanesResource } from "@bluelibs/runner/node";
105
+
106
+ const lane = r.eventLane("notifications-lane").build();
107
+ const rpc = r.rpcLane("billing-lane").build();
108
+
109
+ const topologyEvents = r.eventLane.topology({
110
+ profiles: { local: { consume: [] } },
111
+ bindings: [],
112
+ });
113
+
114
+ const topologyRpc = r.rpcLane.topology({
115
+ profiles: { local: { serve: [] } },
116
+ bindings: [],
117
+ });
118
+
119
+ const app = r
120
+ .resource("app")
121
+ .register([
122
+ eventLanesResource.with({
123
+ profile: "local",
124
+ topology: topologyEvents,
125
+ mode: "transparent",
126
+ }),
127
+ rpcLanesResource.with({
128
+ profile: "local",
129
+ topology: topologyRpc,
130
+ mode: "transparent",
131
+ }),
132
+ ])
133
+ .build();
134
+ ```
135
+
136
+ ### Path 2: `local-simulated` Mode (Serializer Boundary Test)
137
+
138
+ Use when you want local execution with transport-like serialization behavior.
139
+
140
+ - **Pros**: catches serializer boundary issues early
141
+ - **Cons**: still not a true broker/network failure surface
142
+
143
+ ```typescript
144
+ import { r } from "@bluelibs/runner";
145
+ import { eventLanesResource, rpcLanesResource } from "@bluelibs/runner/node";
146
+
147
+ const eventLane = r.eventLane("audit-lane").build();
148
+ const rpcLane = r.rpcLane("users-lane").build();
149
+
150
+ const app = r
151
+ .resource("app")
152
+ .register([
153
+ eventLanesResource.with({
154
+ profile: "local",
155
+ mode: "local-simulated",
156
+ topology: r.eventLane.topology({
157
+ profiles: { local: { consume: [] } },
158
+ bindings: [],
159
+ }),
160
+ }),
161
+ rpcLanesResource.with({
162
+ profile: "local",
163
+ mode: "local-simulated",
164
+ topology: r.rpcLane.topology({
165
+ profiles: { local: { serve: [] } },
166
+ bindings: [],
167
+ }),
168
+ }),
169
+ ])
170
+ .build();
171
+ ```
172
+
173
+ ### Path 3: Two Local Runtimes in One Process (Profile Topology Test)
174
+
175
+ Use when you want to emulate producer/consumer separation without deploying extra services.
176
+
177
+ - **Pros**: validates profile routing and worker startup behavior
178
+ - **Cons**: still single-process reliability characteristics
179
+
180
+ ```typescript
181
+ import { r, run } from "@bluelibs/runner";
182
+ import {
183
+ eventLanesResource,
184
+ MemoryEventLaneQueue,
185
+ } from "@bluelibs/runner/node";
186
+
187
+ const lane = r.eventLane("notifications-lane").build();
188
+ const queue = new MemoryEventLaneQueue();
189
+
190
+ const topology = r.eventLane.topology({
191
+ profiles: {
192
+ api: { consume: [] },
193
+ worker: { consume: [{ lane: lane }] },
194
+ },
195
+ bindings: [{ lane, queue }],
196
+ });
197
+
198
+ const apiApp = r
199
+ .resource("app.api")
200
+ .register([
201
+ eventLanesResource.with({ profile: "api", topology, mode: "network" }),
202
+ ])
203
+ .build();
204
+
205
+ const workerApp = r
206
+ .resource("app.worker")
207
+ .register([
208
+ eventLanesResource.with({ profile: "worker", topology, mode: "network" }),
209
+ ])
210
+ .build();
211
+
212
+ const apiRuntime = await run(apiApp);
213
+ const workerRuntime = await run(workerApp);
214
+ ```
215
+
216
+ > **runtime:** "Three modes of local development. Because 'it works on my machine' is not a deployment strategy."
217
+
218
+ ## Event Lanes in Network Mode
219
+
220
+ Use Event Lanes for fire-and-forget queue semantics and decoupled worker consumption. The producer emits and moves on; a consumer dequeues, deserializes, and re-emits locally so hooks run on the worker side.
221
+
222
+ ### Quick Start
223
+
224
+ ```typescript
225
+ import { r } from "@bluelibs/runner";
226
+ import {
227
+ eventLanesResource,
228
+ MemoryEventLaneQueue,
229
+ } from "@bluelibs/runner/node";
230
+
231
+ // 1. Define a lane — a logical routing channel
232
+ const notificationRequested = r
233
+ .event<{ userId: string; channel: "email" | "sms" }>(
234
+ "app.events.notificationRequested",
235
+ )
236
+ .build();
237
+
238
+ // 2. Assign the event to a lane
239
+ const notificationsLane = r
240
+ .eventLane("notifications-lane")
241
+ .applyTo([notificationRequested])
242
+ .build();
243
+
244
+ // 3. Hook runs on the consumer side after relay
245
+ // Assuming: deliverNotification is defined elsewhere
246
+ const sendNotification = r
247
+ .hook("app.hooks.sendNotification")
248
+ .on(notificationRequested)
249
+ .run(async (event) => {
250
+ await deliverNotification(event.data);
251
+ })
252
+ .build();
253
+
254
+ // 4. Wire topology: who consumes what, and which queue backs each lane
255
+ const topology = r.eventLane.topology({
256
+ profiles: {
257
+ api: { consume: [] },
258
+ worker: { consume: [{ lane: notificationsLane }] },
259
+ },
260
+ bindings: [
261
+ {
262
+ lane: notificationsLane,
263
+ queue: new MemoryEventLaneQueue(),
264
+ prefetch: 8,
265
+ maxAttempts: 3,
266
+ retryDelayMs: 250,
267
+ },
268
+ ],
269
+ });
270
+
271
+ // 5. Register and run
272
+ const app = r
273
+ .resource("app")
274
+ .register([
275
+ notificationRequested,
276
+ sendNotification,
277
+ eventLanesResource.with({
278
+ profile: process.env.RUNNER_PROFILE || "worker",
279
+ topology,
280
+ mode: "network",
281
+ }),
282
+ ])
283
+ .build();
284
+ ```
285
+
286
+ **What you just learned**: Lane definition, lane-side event assignment, topology wiring, and profile-based consumer routing — the full Event Lane pattern.
287
+
288
+ ### Consumer Hook Policy
289
+
290
+ When a consumer runtime can handle multiple event lanes, topology can narrow which hooks run for one lane's relay deliveries:
291
+
292
+ ```typescript
293
+ const auditNotification = r
294
+ .hook("app.hooks.auditNotification")
295
+ .on(notificationRequested)
296
+ .run(async (event) => {
297
+ await auditLog(event.data);
298
+ })
299
+ .build();
300
+
301
+ const topology = r.eventLane.topology({
302
+ profiles: {
303
+ worker: {
304
+ consume: [
305
+ {
306
+ lane: notificationsLane,
307
+ hooks: { only: [sendNotification] },
308
+ },
309
+ ],
310
+ },
311
+ },
312
+ bindings: [{ lane: notificationsLane, queue: new MemoryEventLaneQueue() }],
313
+ });
314
+ ```
315
+
316
+ Behavior:
317
+
318
+ - `sendNotification` runs for relay deliveries consumed from `notificationsLane`.
319
+ - `auditNotification` is skipped on that relay path because it is not in `hooks.only`.
320
+ - Local in-process emissions are unaffected by `hooks.only`; the allowlist matters only on relay re-emits.
321
+
322
+ Why: a single consumer runtime can process more than one lane, so topology-owned hook policy keeps relay behavior explicit in the same place that declares lane consumption.
323
+
324
+ ### Event Lane Network Lifecycle (Auth + Serialization)
325
+
326
+ ```mermaid
327
+ sequenceDiagram
328
+ participant P as Producer Runtime
329
+ participant EI as EventLane Interceptor
330
+ participant Q as Queue (Binding)
331
+ participant C as Consumer Runtime
332
+ participant EM as EventManager/Hooks
333
+
334
+ P->>EI: emit(event, payload)
335
+ EI->>EI: resolve lane + binding
336
+ EI->>EI: issue lane JWT (binding.auth signer)
337
+ EI->>EI: serialize payload
338
+ EI->>Q: enqueue { laneId, eventId, payload, authToken, attempts }
339
+
340
+ Q->>C: consume(message)
341
+ C->>C: verify lane JWT (binding.auth verifier)
342
+ C->>C: deserialize payload
343
+ C->>EM: relay emit(event, payload, relay source)
344
+ EM-->>C: hooks execute locally
345
+ C->>Q: ack(message)
346
+ ```
347
+
348
+ ### Event Lane Message Envelope (What Actually Travels)
349
+
350
+ When an event is routed through an Event Lane in `mode: "network"`, Runner wraps it in an internal transport envelope.
351
+
352
+ Wire payload (simplified):
353
+
354
+ ```json
355
+ {
356
+ "id": "uuid",
357
+ "laneId": "app.lanes.notifications",
358
+ "eventId": "app.events.notificationRequested",
359
+ "payload": "{\"userId\":\"u1\",\"channel\":\"email\"}",
360
+ "source": { "kind": "runtime", "id": "app" },
361
+ "createdAt": "2026-02-28T12:00:00.000Z",
362
+ "attempts": 0,
363
+ "maxAttempts": 3
364
+ }
365
+ ```
366
+
367
+ Field intent:
368
+
369
+ - `payload`: serialized event data string (not raw object)
370
+ - `attempts`: transport-managed retry counter
371
+ - `maxAttempts`: retry budget from lane binding
372
+ - `laneId` + `eventId`: routing and relay target
373
+ - `source`: provenance for diagnostics/behavior
374
+
375
+ Delivery lifecycle:
376
+
377
+ 1. Producer emits event -> Runner intercepts and enqueues envelope with `attempts: 0`.
378
+ 2. Consumer dequeues -> queue adapter increments to current delivery attempt (`attempts + 1`) before handler path.
379
+ 3. On failure with retries left -> message is requeued with updated `attempts`.
380
+ 4. On final failure (`attempts >= maxAttempts`) -> `nack(false)` and broker policy (for example DLQ) decides final settlement.
381
+
382
+ Important boundary:
383
+
384
+ - `attempts` is transport metadata, not business payload. Application code should not set or depend on it directly.
385
+
386
+ ### RabbitMQ Notes and Operational Knobs
387
+
388
+ For production, swap `MemoryEventLaneQueue` for `RabbitMQEventLaneQueue`. It supports practical operational controls:
389
+
390
+ - `prefetch`: consumer back-pressure per worker
391
+ - `maxAttempts` + `retryDelayMs`: retry policy at lane binding level
392
+ - `publishConfirm`: wait for broker publish confirmations (recommended for durability)
393
+ - `reconnect`: connection/channel recovery policy for broker drops
394
+ - `queue.deadLetter`: dead-letter policy wiring on queue declaration
395
+
396
+ ```typescript
397
+ import { RabbitMQEventLaneQueue } from "@bluelibs/runner/node";
398
+
399
+ new RabbitMQEventLaneQueue({
400
+ url: process.env.RABBITMQ_URL,
401
+ queue: {
402
+ name: "runner.notifications",
403
+ durable: true,
404
+ deadLetter: {
405
+ queue: "runner.notifications.dlq",
406
+ exchange: "",
407
+ routingKey: "runner.notifications.dlq",
408
+ },
409
+ },
410
+ publishConfirm: true,
411
+ reconnect: {
412
+ enabled: true,
413
+ maxAttempts: 10,
414
+ initialDelayMs: 200,
415
+ maxDelayMs: 2000,
416
+ },
417
+ });
418
+ ```
419
+
420
+ > **runtime:** "publishConfirm: true. Because 'the broker probably got it' is not a delivery guarantee."
421
+
422
+ ## RPC Lanes in Network Mode
423
+
424
+ Use RPC Lanes when one Runner needs to call another Runner and wait for the result. The caller awaits a response; the routing decision (local vs. remote) is made by the active profile's `serve` list.
425
+
426
+ HTTP client transport options:
427
+
428
+ - `client: "fetch"` uses the universal `createHttpClient` path and works in any `fetch` runtime.
429
+ - `client: "mixed"` and `client: "smart"` are Node-only presets from `@bluelibs/runner/node`, intended for streaming and multipart flows.
430
+ - Runner no longer exposes a global `resources.httpClientFactory`; create clients explicitly or use `r.rpcLane.httpClient(...)` for communicator resources.
431
+
432
+ ### Quick Start
433
+
434
+ ```typescript
435
+ import { r, tags } from "@bluelibs/runner";
436
+ import { rpcLanesResource } from "@bluelibs/runner/node";
437
+
438
+ // 1. Define a lane
439
+ const billingLane = r.rpcLane("billing-lane").build();
440
+
441
+ // 2. Tag the task for lane routing
442
+ const chargeCard = r
443
+ .task("billing.tasks.chargeCard")
444
+ .tags([tags.rpcLane.with({ lane: billingLane })])
445
+ .run(async (input: { amount: number }) => ({
446
+ ok: true,
447
+ amount: input.amount,
448
+ }))
449
+ .build();
450
+
451
+ // 3. Create a communicator for the remote side
452
+ const billingCommunicator = r
453
+ .resource("app.resources.billingCommunicator")
454
+ .init(
455
+ r.rpcLane.httpClient({
456
+ client: "mixed",
457
+ baseUrl: process.env.BILLING_RPC_URL as string,
458
+ auth: { token: process.env.RUNNER_RPC_TOKEN as string }, // exposure HTTP auth
459
+ }),
460
+ )
461
+ .build();
462
+
463
+ // 4. Wire topology: who serves what, and which communicator reaches each lane
464
+ const topology = r.rpcLane.topology({
465
+ profiles: {
466
+ api: { serve: [] },
467
+ billing: { serve: [billingLane] },
468
+ },
469
+ bindings: [{ lane: billingLane, communicator: billingCommunicator }],
470
+ });
471
+
472
+ // 5. Register and run
473
+ const app = r
474
+ .resource("app")
475
+ .register([
476
+ chargeCard,
477
+ billingCommunicator,
478
+ rpcLanesResource.with({
479
+ profile: "api",
480
+ topology,
481
+ mode: "network",
482
+ }),
483
+ ])
484
+ .build();
485
+ ```
486
+
487
+ **What you just learned**: RPC lane definition, task tagging, communicator wiring, and profile-based serve routing — the full RPC Lane pattern.
488
+
489
+ ### Routing Branches (`mode: "network"`)
490
+
491
+ | Condition | Result |
492
+ | ------------------------------------- | -------------------------------------- |
493
+ | lane is in active profile `serve` | execute locally |
494
+ | lane is not in active profile `serve` | execute remotely via lane communicator |
495
+ | task/event is not lane-assigned | use normal local Runner path |
496
+
497
+ > **runtime:** "Serve it or ship it. There is no 'maybe call the other service.'"
498
+
499
+ ### RPC Lane Network Lifecycle (Routing + Exposure + Lane Auth)
500
+
501
+ ```mermaid
502
+ sequenceDiagram
503
+ participant CR as Caller Runtime
504
+ participant RL as RpcLane Router
505
+ participant CM as Communicator
506
+ participant EX as RPC Exposure Server
507
+ participant SR as Serving Runtime
508
+
509
+ CR->>RL: runTask/runEvent (lane-assigned)
510
+ RL->>RL: is lane in active profile serve?
511
+ alt Served locally
512
+ RL->>SR: execute locally
513
+ SR-->>CR: result
514
+ else Routed remotely
515
+ RL->>RL: build headers (lane JWT + allowlisted async contexts)
516
+ RL->>CM: communicator.task/event(...)
517
+ CM->>EX: HTTP request to /__runner/*
518
+ EX->>EX: exposure auth + allow-list check
519
+ EX->>EX: verify lane JWT (binding.auth verifier)
520
+ EX->>SR: execute task/event
521
+ SR-->>EX: serialized result
522
+ EX-->>CM: HTTP response
523
+ CM-->>CR: result
524
+ end
525
+ ```
526
+
527
+ ## Common Patterns
528
+
529
+ ### Command via RPC, then Propagate via Event Lane
530
+
531
+ A typical microservice boundary: the API calls billing synchronously (RPC Lane), and billing broadcasts the result asynchronously (Event Lane) for downstream projections.
532
+
533
+ ```typescript
534
+ // API service: calls billing via RPC Lane, waits for result
535
+ const result = await runTask(chargeCard, { amount: 42 });
536
+
537
+ // Billing service hook: after charging, emits domain event via Event Lane
538
+ // -> order service, analytics workers, and audit consumers pick it up asynchronously
539
+ const onCardCharged = r
540
+ .hook("billing.hooks.onCardCharged")
541
+ .on(chargeCardCompleted)
542
+ .run(async (event) => {
543
+ await emitEvent(cardCharged, {
544
+ orderId: event.data.orderId,
545
+ amount: event.data.amount,
546
+ });
547
+ })
548
+ .build();
549
+ ```
550
+
551
+ ### Multi-Worker Prefetch Topology
552
+
553
+ Multiple worker processes consume from the same queue. Each worker gets `prefetch` messages at a time, providing natural back-pressure.
554
+
555
+ ```typescript
556
+ const topology = r.eventLane.topology({
557
+ profiles: {
558
+ api: { consume: [] },
559
+ worker: { consume: [{ lane: emailLane }, { lane: smsLane }] },
560
+ },
561
+ bindings: [
562
+ { lane: emailLane, queue: emailQueue, prefetch: 16 },
563
+ { lane: smsLane, queue: smsQueue, prefetch: 4 },
564
+ ],
565
+ });
566
+ // Deploy N worker instances — each gets its own prefetch window
567
+ ```
568
+
569
+ ### Progressive Profile Expansion
570
+
571
+ Start with one profile that does everything, then split as traffic grows. Bindings stay the same — only the profile assignment changes per deployment.
572
+
573
+ ```typescript
574
+ // Phase 1: monolith — one profile consumes all lanes
575
+ const topology = r.eventLane.topology({
576
+ profiles: {
577
+ mono: { consume: [{ lane: emailLane }, { lane: analyticsLane }] },
578
+ },
579
+ bindings: [
580
+ { lane: emailLane, queue: emailQueue },
581
+ { lane: analyticsLane, queue: analyticsQueue },
582
+ ],
583
+ });
584
+
585
+ // Phase 2: split workers — add profiles, same bindings
586
+ const topology = r.eventLane.topology({
587
+ profiles: {
588
+ emailWorker: { consume: [{ lane: emailLane }] },
589
+ analyticsWorker: { consume: [{ lane: analyticsLane }] },
590
+ },
591
+ bindings: [
592
+ { lane: emailLane, queue: emailQueue },
593
+ { lane: analyticsLane, queue: analyticsQueue },
594
+ ],
595
+ });
596
+ ```
597
+
598
+ ## DLQ, Retries, and Failure Ownership
599
+
600
+ Keep responsibilities clearly separated:
601
+
602
+ **Transport-level (lane binding + broker config):**
603
+
604
+ - `maxAttempts` + `retryDelayMs` at the lane binding level control retry budget before final failure
605
+ - DLQ behavior is **broker/queue-policy owned**
606
+ - Runner settles final consumer failure with `nack(false)` — it does **not** manually publish to a DLQ queue
607
+ - If your queue has no dead-letter configuration, a final `nack(false)` discards the message per broker behavior
608
+
609
+ **Business-level (task/hook middleware + domain logic):**
610
+
611
+ - Use task middleware (`retry`, `circuitBreaker`, `fallback`) for business-level resilience
612
+ - Use domain compensation patterns for recovery flows
613
+
614
+ > **runtime:** "Retry at the transport layer. Compensate at the business layer. Panic at the ops layer."
615
+
616
+ ## Testing Lanes
617
+
618
+ ### Unit Tests: `transparent` Mode
619
+
620
+ Use `transparent` mode when you want to test business logic without transport noise. Lane assignments are present but transport is completely bypassed — hooks run locally as if no lane existed.
621
+
622
+ ```typescript
623
+ import { r, run } from "@bluelibs/runner";
624
+ import { eventLanesResource } from "@bluelibs/runner/node";
625
+
626
+ // Assuming: myEvent and myHook are defined elsewhere
627
+ const app = r
628
+ .resource("app")
629
+ .register([
630
+ myEvent,
631
+ myHook,
632
+ eventLanesResource.with({
633
+ profile: "test",
634
+ mode: "transparent",
635
+ topology: r.eventLane.topology({
636
+ profiles: { test: { consume: [] } },
637
+ bindings: [],
638
+ }),
639
+ }),
640
+ ])
641
+ .build();
642
+
643
+ const { emitEvent, dispose } = await run(app);
644
+ await emitEvent(myEvent, { userId: "u1" }); // hooks run locally, no queue involved
645
+ await dispose();
646
+ ```
647
+
648
+ ### Boundary Tests: `local-simulated` Mode
649
+
650
+ Use `local-simulated` when you want to verify that your event payloads survive serialization. This catches issues with Dates, RegExp, class instances, and other non-JSON-safe types before they hit production.
651
+
652
+ When binding auth is configured (`binding.auth`), `local-simulated` also enforces JWT signing+verification so local simulation tests both payload shape and lane security behavior.
653
+
654
+ ```typescript
655
+ eventLanesResource.with({
656
+ profile: "test",
657
+ mode: "local-simulated",
658
+ topology: r.eventLane.topology({
659
+ profiles: { test: { consume: [] } },
660
+ bindings: [],
661
+ }),
662
+ });
663
+ ```
664
+
665
+ ### Integration Tests: `MemoryEventLaneQueue`
666
+
667
+ Use `MemoryEventLaneQueue` for full lane routing tests without external infrastructure. This exercises the real enqueue/consume/relay path in-process.
668
+
669
+ ```typescript
670
+ import { r, run } from "@bluelibs/runner";
671
+ import {
672
+ eventLanesResource,
673
+ MemoryEventLaneQueue,
674
+ } from "@bluelibs/runner/node";
675
+
676
+ const lane = r.eventLane("test-lane").build();
677
+ const queue = new MemoryEventLaneQueue();
678
+
679
+ const topology = r.eventLane.topology({
680
+ profiles: { test: { consume: [{ lane: lane }] } },
681
+ bindings: [{ lane, queue }],
682
+ });
683
+
684
+ // Assuming: myEvent and myHook are defined elsewhere
685
+ const app = r
686
+ .resource("app")
687
+ .register([
688
+ myEvent,
689
+ myHook,
690
+ eventLanesResource.with({ profile: "test", topology, mode: "network" }),
691
+ ])
692
+ .build();
693
+
694
+ const { emitEvent, dispose } = await run(app);
695
+ await emitEvent(myEvent, { userId: "u1" }); // enqueues, consumes, relays, hooks run
696
+ await dispose();
697
+ ```
698
+
699
+ ## Debugging
700
+
701
+ When things go sideways, start with verbose mode:
702
+
703
+ ```typescript
704
+ const runtime = await run(app, { debug: "verbose" });
705
+ ```
706
+
707
+ This enables Event Lanes routing diagnostics. Look for these entries:
708
+
709
+ | Diagnostic | Meaning |
710
+ | -------------------------------- | ------------------------------------------------------------------------ |
711
+ | `event-lanes.enqueue` | Event was intercepted and enqueued to the lane's queue |
712
+ | `event-lanes.relay-emit` | Consumer dequeued and re-emitted the event locally |
713
+ | `event-lanes.skip-inactive-lane` | Event's lane is not consumed by the active profile (nacked with requeue) |
714
+
715
+ If you see `skip-inactive-lane`, your active profile likely doesn't include the lane in its `consume` list.
716
+
717
+ ## Pros and Cons by Mode
718
+
719
+ | Mode | Pros | Cons | Misuse Warning |
720
+ | ----------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------- |
721
+ | `network` | true transport behavior, real queue/communicator integration, realistic failure surfaces | infra required, slower local loop | Do not skip this mode before production rollout |
722
+ | `transparent` | fastest local feedback, zero transport dependencies, queues/communicators not required to resolve | hides serialization and network effects | Do not treat passing transparent tests as transport validation |
723
+ | `local-simulated` | tests serializer boundary and relay paths locally, queues/communicators not required to resolve | no real network or broker behavior | Do not assume retry/DLQ behavior from local simulation |
724
+
725
+ Key rules:
726
+
727
+ - `mode` is authoritative and sits above topology/profile routing.
728
+ - In `transparent` and `local-simulated`, profile routing (`consume`/`serve`) does not start network consumers/servers, but those entries can still declare lane presence and relay hook policy.
729
+ - Only `network` uses queue/communicator bindings for remote routing.
730
+
731
+ ## Security and Exposure
732
+
733
+ RPC lane HTTP exposure is available through `rpcLanesResource.with({ exposure: { http: ... } })` in `mode: "network"` only. Attempting to use `exposure.http` in other modes fails fast at startup. Exposure HTTP starts only when the active profile resolves at least one served RPC task/event endpoint; if `exposure.http` is configured but nothing is served, startup skips exposure and logs `rpc-lanes.exposure.skipped`.
734
+
735
+ Security defaults:
736
+
737
+ - Exposure remains **fail-closed** unless you explicitly configure otherwise
738
+ - Always configure `http.auth` with a token or a validator task (edge gate)
739
+ - Run behind trusted network boundaries and infrastructure gateway controls (rate-limits, network policy)
740
+ - Keep anonymous exposure disabled unless explicitly needed
741
+ - Exposure HTTP bootstrap is `listen`-based; custom `http.Server` injection is not part of the contract
742
+
743
+ There are two independent security layers:
744
+
745
+ 1. **Exposure HTTP auth** (`exposure.http.auth`) controls who can hit `POST /__runner/task/...` and `POST /__runner/event/...`.
746
+ 2. **Lane JWT auth** (`binding.auth`) controls lane-authorized produce/consume behavior.
747
+
748
+ You usually want both.
749
+
750
+ ```typescript
751
+ const activeProfile = (process.env.RUNNER_PROFILE as "api" | "billing") ?? "api";
752
+ const billingLane = r.rpcLane("billing-lane").build();
753
+ const topology = r.rpcLane.topology({
754
+ profiles: {
755
+ api: { serve: [] },
756
+ billing: { serve: [billingLane] },
757
+ },
758
+ bindings: [
759
+ {
760
+ lane: billingLane,
761
+ communicator: billingCommunicator,
762
+ auth: {
763
+ mode: "jwt_asymmetric",
764
+ algorithm: "EdDSA",
765
+ privateKey: process.env.BILLING_PRIVATE_KEY as string,
766
+ publicKey: process.env.BILLING_PUBLIC_KEY as string,
767
+ },
768
+ },
769
+ ],
770
+ });
771
+
772
+ rpcLanesResource.with({
773
+ profile: activeProfile,
774
+ topology,
775
+ mode: "network",
776
+ exposure: {
777
+ http: {
778
+ basePath: "/__runner",
779
+ listen: { port: 7070 },
780
+ auth: { token: process.env.RUNNER_RPC_TOKEN as string }, // HTTP edge gate
781
+ },
782
+ },
783
+ });
784
+ ```
785
+
786
+ Binding auth (JWT):
787
+
788
+ - JWT mode is configured only at `binding.auth`.
789
+ - Supported modes: `none`, `jwt_hmac`, `jwt_asymmetric`.
790
+ - `local-simulated` enforces auth when configured (it does not bypass lane JWT checks).
791
+ - For asymmetric mode (`jwt_asymmetric`), producers sign with **private keys**, consumers verify with **public keys**.
792
+ - This principle is identical for RPC and Event Lanes.
793
+
794
+ Asymmetric JWT should prove these two properties in your setup:
795
+
796
+ 1. **Producer cannot produce with only public key material**.
797
+ 2. **Consumer cannot verify with only private key material**.
798
+
799
+ ```typescript
800
+ // Producer profile (not serving lane): needs signer material (private key)
801
+ const producerTopology = r.rpcLane.topology({
802
+ profiles: { api: { serve: [] } },
803
+ bindings: [
804
+ {
805
+ lane: billingLane,
806
+ communicator: billingCommunicator,
807
+ auth: {
808
+ mode: "jwt_asymmetric",
809
+ privateKey: process.env.BILLING_PRIVATE_KEY as string,
810
+ },
811
+ },
812
+ ],
813
+ });
814
+
815
+ // Consumer profile (serving lane): needs verifier material (public key)
816
+ const consumerTopology = r.rpcLane.topology({
817
+ profiles: { billing: { serve: [billingLane] } },
818
+ bindings: [
819
+ {
820
+ lane: billingLane,
821
+ communicator: billingCommunicator,
822
+ auth: {
823
+ mode: "jwt_asymmetric",
824
+ publicKey: process.env.BILLING_PUBLIC_KEY as string,
825
+ },
826
+ },
827
+ ],
828
+ });
829
+ ```
830
+
831
+ Fail-fast proof snippets (recommended in integration tests):
832
+
833
+ ```typescript
834
+ // 1) Producer with only public key -> signer missing (cannot mint lane token)
835
+ await expect(run(producerAppWithPublicKeyOnly)).rejects.toMatchObject({
836
+ name: "remoteLanes-auth-signerMissing",
837
+ });
838
+
839
+ // 2) Consumer with only private key -> verifier missing (cannot verify lane token)
840
+ await expect(run(consumerAppWithPrivateKeyOnly)).rejects.toMatchObject({
841
+ name: "remoteLanes-auth-verifierMissing",
842
+ });
843
+ ```
844
+
845
+ Event Lane parity example (same asymmetric role split):
846
+
847
+ ```typescript
848
+ const activeProfile = (process.env.RUNNER_PROFILE as "api" | "worker") ?? "api";
849
+ const notificationsLane = r.eventLane("notifications-lane").build();
850
+ const topology = r.eventLane.topology({
851
+ profiles: {
852
+ api: { consume: [] }, // producer profile
853
+ worker: { consume: [{ lane: notificationsLane }] }, // consumer profile
854
+ },
855
+ bindings: [
856
+ {
857
+ lane: notificationsLane,
858
+ queue: new MemoryEventLaneQueue(),
859
+ auth: {
860
+ mode: "jwt_asymmetric",
861
+ privateKey: process.env.EVENTS_PRIVATE_KEY as string, // producer path
862
+ publicKey: process.env.EVENTS_PUBLIC_KEY as string, // consumer path
863
+ },
864
+ },
865
+ ],
866
+ });
867
+
868
+ eventLanesResource.with({
869
+ profile: activeProfile,
870
+ topology,
871
+ mode: "network",
872
+ });
873
+ ```
874
+
875
+ `local-simulated` note:
876
+
877
+ - Auth is still enforced.
878
+ - For `jwt_asymmetric`, the same runtime signs and verifies during simulation, so binding auth must provide both sides of material.
879
+ - Event lane `asyncContexts` allowlist still applies in `local-simulated` (default `[]`, so no implicit forwarding).
880
+ - RPC lane `asyncContexts` allowlist still applies in `local-simulated` (default `[]`, so no implicit forwarding).
881
+
882
+ ## Migration Notes (v6)
883
+
884
+ Legacy pre-lane event routing (`events` + `emit` + `eventDeliveryMode`) is removed in v6. Here's what replaces it:
885
+
886
+ | Legacy pre-lane pattern | v6 replacement |
887
+ | ---------------------------------------- | ------------------------------------------------------- |
888
+ | `remoteResource.with({ events: [...] })` | `eventLanesResource.with({ topology, profile })` |
889
+ | `remoteResource.with({ emit: [...] })` | Assign events with `r.eventLane(...).applyTo([...])` |
890
+ | `eventDeliveryMode: "queue"` | Event Lanes `mode: "network"` with queue binding |
891
+ | Sync remote task calls | RPC Lanes `mode: "network"` with communicator binding |
892
+
893
+ Transport/wire details for HTTP RPC are in [REMOTE_LANES_HTTP_POLICY.md](./REMOTE_LANES_HTTP_POLICY.md).
894
+
895
+ ## Troubleshooting Checklist
896
+
897
+ When routing does not behave as expected, check in this order:
898
+
899
+ 1. **Lane binding exists** for each lane-assigned definition used in `network` mode.
900
+ 2. **Active profile includes expected lanes** — `consume` for event workers, `serve` for RPC servers.
901
+ 3. **Queue/communicator dependencies resolve** in the runtime container.
902
+ 4. **Runtime mode is what you think it is** — `transparent` and `local-simulated` bypass network routing entirely.
903
+ 5. **Queue dead-letter policy is configured** if you expect failed messages in a DLQ.
904
+ 6. **Debug mode is on** — `run(app, { debug: "verbose" })` shows lane routing diagnostics.
905
+
906
+ ## Reference Contracts
907
+
908
+ ### Core Guard Rails
909
+
910
+ - Lane ids must be non-empty strings.
911
+ - Event definitions must not end up routed through both lane systems (`eventLane` + `rpcLane`).
912
+ - A definition cannot be assigned to two different lanes in the same lane system.
913
+ - `applyTo` supports either:
914
+ - A list of explicit targets (definitions or id strings), validated against container definitions and failing fast on invalid type/id.
915
+ - A predicate function that is evaluated against container definitions at runtime.
916
+ - Deprecated `tags.eventLane` and `tags.eventLaneHook` fail fast at startup.
917
+ - `transactional + eventLane.applyTo(...)` is invalid.
918
+ - `transactional + parallel` is invalid.
919
+
920
+ ### Event Lane Contract
921
+
922
+ | Concept | API |
923
+ | ---------------- | ---------------------------------------------------------------- |
924
+ | Lane definition | `r.eventLane("...").asyncContexts([...]).applyTo([...])` or `r.eventLane("...").asyncContexts([...]).applyTo((event) => boolean)` |
925
+ | Topology | `r.eventLane.topology({ profiles, bindings })` |
926
+ | Profile consume | `profiles[profile].consume: [{ lane, hooks?: { only?: hook[] } }]` |
927
+ | Binding | `{ lane, queue, prefetch?, maxAttempts?, retryDelayMs? }` |
928
+ | Runtime resource | `eventLanesResource.with({ profile, topology, mode? })` |
929
+
930
+ ### RPC Lane Contract
931
+
932
+ | Concept | API |
933
+ | ------------------ | ---------------------------------------------------------------- |
934
+ | Lane definition | `r.rpcLane("...").asyncContexts([...]).applyTo([...])` or `r.rpcLane("...").asyncContexts([...]).applyTo((taskOrEvent) => boolean)` |
935
+ | Task/event tagging | `tags.rpcLane.with({ lane })` |
936
+ | Topology | `r.rpcLane.topology({ profiles, bindings })` |
937
+ | Profile serve | `profiles[profile].serve: lane[]` |
938
+ | Binding | `{ lane, communicator, auth?, allowAsyncContext? }` |
939
+ | Runtime resource | `rpcLanesResource.with({ profile, topology, mode?, exposure? })` |
940
+
941
+ ### Communicator Contract
942
+
943
+ | Method | Signature | Required |
944
+ | ----------------- | ------------------------------------ | ------------------ |
945
+ | `task` | `(id, input?) => Promise<unknown>` | Yes (for task RPC) |
946
+ | `event` | `(id, payload?) => Promise<void>` | Optional |
947
+ | `eventWithResult` | `(id, payload?) => Promise<unknown>` | Optional |