@bluelibs/runner-dev 5.3.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/AI.md +25 -3
  2. package/README.md +153 -18
  3. package/dist/cli/generators/scaffold/templates/package.json.d.ts +2 -2
  4. package/dist/cli/generators/scaffold/templates/package.json.js +2 -2
  5. package/dist/cli/generators/scaffold.js +1 -135
  6. package/dist/cli/generators/scaffold.js.map +1 -1
  7. package/dist/cli/generators/templates.js +2 -1
  8. package/dist/cli/generators/templates.js.map +1 -1
  9. package/dist/generated/resolvers-types.d.ts +376 -144
  10. package/dist/index.d.ts +39 -43
  11. package/dist/resources/cli.config.resource.d.ts +1 -1
  12. package/dist/resources/cli.config.resource.js +2 -2
  13. package/dist/resources/cli.config.resource.js.map +1 -1
  14. package/dist/resources/coverage.resource.d.ts +2 -2
  15. package/dist/resources/coverage.resource.js +3 -3
  16. package/dist/resources/coverage.resource.js.map +1 -1
  17. package/dist/resources/dev.resource.d.ts +1 -1
  18. package/dist/resources/dev.resource.js +2 -2
  19. package/dist/resources/dev.resource.js.map +1 -1
  20. package/dist/resources/docs.generator.resource.d.ts +4 -4
  21. package/dist/resources/docs.generator.resource.js +2 -2
  22. package/dist/resources/docs.generator.resource.js.map +1 -1
  23. package/dist/resources/graphql-accumulator.resource.d.ts +2 -2
  24. package/dist/resources/graphql-accumulator.resource.js +6 -3
  25. package/dist/resources/graphql-accumulator.resource.js.map +1 -1
  26. package/dist/resources/graphql.cli.resource.d.ts +1 -1
  27. package/dist/resources/graphql.cli.resource.js +2 -2
  28. package/dist/resources/graphql.cli.resource.js.map +1 -1
  29. package/dist/resources/graphql.query.cli.task.d.ts +14 -16
  30. package/dist/resources/graphql.query.cli.task.js +3 -3
  31. package/dist/resources/graphql.query.cli.task.js.map +1 -1
  32. package/dist/resources/graphql.query.task.d.ts +18 -20
  33. package/dist/resources/graphql.query.task.js +4 -4
  34. package/dist/resources/graphql.query.task.js.map +1 -1
  35. package/dist/resources/http.tag.d.ts +1 -1
  36. package/dist/resources/http.tag.js +2 -2
  37. package/dist/resources/http.tag.js.map +1 -1
  38. package/dist/resources/introspector.cli.resource.d.ts +2 -2
  39. package/dist/resources/introspector.cli.resource.js +14 -6
  40. package/dist/resources/introspector.cli.resource.js.map +1 -1
  41. package/dist/resources/introspector.resource.d.ts +3 -3
  42. package/dist/resources/introspector.resource.js +4 -5
  43. package/dist/resources/introspector.resource.js.map +1 -1
  44. package/dist/resources/live.resource.d.ts +4 -6
  45. package/dist/resources/live.resource.js +38 -25
  46. package/dist/resources/live.resource.js.map +1 -1
  47. package/dist/resources/models/Introspector.d.ts +28 -14
  48. package/dist/resources/models/Introspector.js +334 -161
  49. package/dist/resources/models/Introspector.js.map +1 -1
  50. package/dist/resources/models/durable.runtime.js +36 -10
  51. package/dist/resources/models/durable.runtime.js.map +1 -1
  52. package/dist/resources/models/durable.tools.d.ts +1 -1
  53. package/dist/resources/models/durable.tools.js +6 -3
  54. package/dist/resources/models/durable.tools.js.map +1 -1
  55. package/dist/resources/models/initializeFromStore.js +54 -21
  56. package/dist/resources/models/initializeFromStore.js.map +1 -1
  57. package/dist/resources/models/initializeFromStore.utils.d.ts +7 -6
  58. package/dist/resources/models/initializeFromStore.utils.js +302 -25
  59. package/dist/resources/models/initializeFromStore.utils.js.map +1 -1
  60. package/dist/resources/models/introspector.tools.js +18 -6
  61. package/dist/resources/models/introspector.tools.js.map +1 -1
  62. package/dist/resources/routeHandlers/getDocsData.d.ts +4 -0
  63. package/dist/resources/routeHandlers/getDocsData.js +28 -0
  64. package/dist/resources/routeHandlers/getDocsData.js.map +1 -1
  65. package/dist/resources/routeHandlers/registerHttpRoutes.hook.d.ts +26 -25
  66. package/dist/resources/routeHandlers/registerHttpRoutes.hook.js +10 -9
  67. package/dist/resources/routeHandlers/registerHttpRoutes.hook.js.map +1 -1
  68. package/dist/resources/server.resource.d.ts +20 -22
  69. package/dist/resources/server.resource.js +6 -6
  70. package/dist/resources/server.resource.js.map +1 -1
  71. package/dist/resources/swap.cli.resource.d.ts +4 -4
  72. package/dist/resources/swap.cli.resource.js +2 -2
  73. package/dist/resources/swap.cli.resource.js.map +1 -1
  74. package/dist/resources/swap.resource.d.ts +7 -7
  75. package/dist/resources/swap.resource.js +188 -38
  76. package/dist/resources/swap.resource.js.map +1 -1
  77. package/dist/resources/swap.tools.d.ts +3 -2
  78. package/dist/resources/swap.tools.js +27 -27
  79. package/dist/resources/swap.tools.js.map +1 -1
  80. package/dist/resources/telemetry.resource.d.ts +1 -1
  81. package/dist/resources/telemetry.resource.js +46 -43
  82. package/dist/resources/telemetry.resource.js.map +1 -1
  83. package/dist/runner-compat.d.ts +85 -0
  84. package/dist/runner-compat.js +178 -0
  85. package/dist/runner-compat.js.map +1 -0
  86. package/dist/runner-node-compat.d.ts +2 -0
  87. package/dist/runner-node-compat.js +28 -0
  88. package/dist/runner-node-compat.js.map +1 -0
  89. package/dist/schema/index.js +4 -8
  90. package/dist/schema/index.js.map +1 -1
  91. package/dist/schema/model.d.ts +80 -23
  92. package/dist/schema/model.js.map +1 -1
  93. package/dist/schema/query.js +2 -1
  94. package/dist/schema/query.js.map +1 -1
  95. package/dist/schema/types/AllType.js +6 -3
  96. package/dist/schema/types/AllType.js.map +1 -1
  97. package/dist/schema/types/BaseElementCommon.js +2 -2
  98. package/dist/schema/types/ErrorType.js +1 -1
  99. package/dist/schema/types/ErrorType.js.map +1 -1
  100. package/dist/schema/types/EventType.js +19 -2
  101. package/dist/schema/types/EventType.js.map +1 -1
  102. package/dist/schema/types/LaneSummaryTypes.d.ts +3 -0
  103. package/dist/schema/types/LaneSummaryTypes.js +19 -0
  104. package/dist/schema/types/LaneSummaryTypes.js.map +1 -0
  105. package/dist/schema/types/LiveType.js +67 -0
  106. package/dist/schema/types/LiveType.js.map +1 -1
  107. package/dist/schema/types/ResourceType.js +100 -19
  108. package/dist/schema/types/ResourceType.js.map +1 -1
  109. package/dist/schema/types/RunOptionsType.js +41 -5
  110. package/dist/schema/types/RunOptionsType.js.map +1 -1
  111. package/dist/schema/types/TagType.js +35 -4
  112. package/dist/schema/types/TagType.js.map +1 -1
  113. package/dist/schema/types/TaskType.js +5 -0
  114. package/dist/schema/types/TaskType.js.map +1 -1
  115. package/dist/schema/types/index.d.ts +2 -2
  116. package/dist/schema/types/index.js +6 -7
  117. package/dist/schema/types/index.js.map +1 -1
  118. package/dist/schema/types/middleware/common.d.ts +3 -2
  119. package/dist/schema/types/middleware/common.js +19 -13
  120. package/dist/schema/types/middleware/common.js.map +1 -1
  121. package/dist/ui/.vite/manifest.json +2 -2
  122. package/dist/ui/assets/docs-Btkv97Ls.js +302 -0
  123. package/dist/ui/assets/docs-Btkv97Ls.js.map +1 -0
  124. package/dist/ui/assets/docs-CipvKUxZ.css +1 -0
  125. package/dist/utils/lane-resources.d.ts +55 -0
  126. package/dist/utils/lane-resources.js +143 -0
  127. package/dist/utils/lane-resources.js.map +1 -0
  128. package/dist/utils/zod.js +36 -3
  129. package/dist/utils/zod.js.map +1 -1
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/package.json +4 -4
  133. package/readmes/runner-AI.md +740 -0
  134. package/readmes/runner-durable-workflows.md +2247 -0
  135. package/readmes/runner-full-guide.md +5869 -0
  136. package/readmes/runner-remote-lanes.md +909 -0
  137. package/dist/ui/assets/docs-BhRuaJ5l.css +0 -1
  138. package/dist/ui/assets/docs-H4oDZj7p.js +0 -302
  139. package/dist/ui/assets/docs-H4oDZj7p.js.map +0 -1
@@ -0,0 +1,909 @@
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 } from "@bluelibs/runner";
104
+ import { eventLanesResource, rpcLanesResource } from "@bluelibs/runner/node";
105
+
106
+ const lane = r.eventLane("app.lanes.notifications").build();
107
+ const rpc = r.rpcLane("app.rpc.billing").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("app.lanes.audit").build();
148
+ const rpcLane = r.rpcLane("app.rpc.users").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("app.lanes.notifications").build();
188
+ const queue = new MemoryEventLaneQueue();
189
+
190
+ const topology = r.eventLane.topology({
191
+ profiles: {
192
+ api: { consume: [] },
193
+ worker: { consume: [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 notificationsLane = r.eventLane("app.lanes.notifications").build();
233
+
234
+ // 2. Tag the event for lane routing
235
+ const notificationRequested = r
236
+ .event<{ userId: string; channel: "email" | "sms" }>(
237
+ "app.events.notificationRequested",
238
+ )
239
+ .tags([r.runner.tags.eventLane.with({ lane: notificationsLane })])
240
+ .build();
241
+
242
+ // 3. Hook runs on the consumer side after relay
243
+ // Assuming: deliverNotification is defined elsewhere
244
+ const sendNotification = r
245
+ .hook("app.hooks.sendNotification")
246
+ .on(notificationRequested)
247
+ .run(async (event) => {
248
+ await deliverNotification(event.data);
249
+ })
250
+ .build();
251
+
252
+ // 4. Wire topology: who consumes what, and which queue backs each lane
253
+ const topology = r.eventLane.topology({
254
+ profiles: {
255
+ api: { consume: [] },
256
+ worker: { consume: [notificationsLane] },
257
+ },
258
+ bindings: [
259
+ {
260
+ lane: notificationsLane,
261
+ queue: new MemoryEventLaneQueue(),
262
+ prefetch: 8,
263
+ maxAttempts: 3,
264
+ retryDelayMs: 250,
265
+ },
266
+ ],
267
+ });
268
+
269
+ // 5. Register and run
270
+ const app = r
271
+ .resource("app")
272
+ .register([
273
+ notificationRequested,
274
+ sendNotification,
275
+ eventLanesResource.with({
276
+ profile: process.env.RUNNER_PROFILE || "worker",
277
+ topology,
278
+ mode: "network",
279
+ }),
280
+ ])
281
+ .build();
282
+ ```
283
+
284
+ **What you just learned**: Lane definition, event tagging, topology wiring, and profile-based consumer routing — the full Event Lane pattern.
285
+
286
+ ### Event Lane Network Lifecycle (Auth + Serialization)
287
+
288
+ ```mermaid
289
+ sequenceDiagram
290
+ participant P as Producer Runtime
291
+ participant EI as EventLane Interceptor
292
+ participant Q as Queue (Binding)
293
+ participant C as Consumer Runtime
294
+ participant EM as EventManager/Hooks
295
+
296
+ P->>EI: emit(event, payload)
297
+ EI->>EI: resolve lane + binding
298
+ EI->>EI: issue lane JWT (binding.auth signer)
299
+ EI->>EI: serialize payload
300
+ EI->>Q: enqueue { laneId, eventId, payload, authToken, attempts }
301
+
302
+ Q->>C: consume(message)
303
+ C->>C: verify lane JWT (binding.auth verifier)
304
+ C->>C: deserialize payload
305
+ C->>EM: relay emit(event, payload, relay source)
306
+ EM-->>C: hooks execute locally
307
+ C->>Q: ack(message)
308
+ ```
309
+
310
+ ### Event Lane Message Envelope (What Actually Travels)
311
+
312
+ When an event is routed through an Event Lane in `mode: "network"`, Runner wraps it in an internal transport envelope.
313
+
314
+ Wire payload (simplified):
315
+
316
+ ```json
317
+ {
318
+ "id": "uuid",
319
+ "laneId": "app.lanes.notifications",
320
+ "eventId": "app.events.notificationRequested",
321
+ "payload": "{\"userId\":\"u1\",\"channel\":\"email\"}",
322
+ "source": { "kind": "runtime", "id": "app" },
323
+ "createdAt": "2026-02-28T12:00:00.000Z",
324
+ "attempts": 0,
325
+ "maxAttempts": 3
326
+ }
327
+ ```
328
+
329
+ Field intent:
330
+
331
+ - `payload`: serialized event data string (not raw object)
332
+ - `attempts`: transport-managed retry counter
333
+ - `maxAttempts`: retry budget from lane binding
334
+ - `laneId` + `eventId`: routing and relay target
335
+ - `source`: provenance for diagnostics/behavior
336
+
337
+ Delivery lifecycle:
338
+
339
+ 1. Producer emits event -> Runner intercepts and enqueues envelope with `attempts: 0`.
340
+ 2. Consumer dequeues -> queue adapter increments to current delivery attempt (`attempts + 1`) before handler path.
341
+ 3. On failure with retries left -> message is requeued with updated `attempts`.
342
+ 4. On final failure (`attempts >= maxAttempts`) -> `nack(false)` and broker policy (for example DLQ) decides final settlement.
343
+
344
+ Important boundary:
345
+
346
+ - `attempts` is transport metadata, not business payload. Application code should not set or depend on it directly.
347
+
348
+ ### RabbitMQ Notes and Operational Knobs
349
+
350
+ For production, swap `MemoryEventLaneQueue` for `RabbitMQEventLaneQueue`. It supports practical operational controls:
351
+
352
+ - `prefetch`: consumer back-pressure per worker
353
+ - `maxAttempts` + `retryDelayMs`: retry policy at lane binding level
354
+ - `publishConfirm`: wait for broker publish confirmations (recommended for durability)
355
+ - `reconnect`: connection/channel recovery policy for broker drops
356
+ - `queue.deadLetter`: dead-letter policy wiring on queue declaration
357
+
358
+ ```typescript
359
+ import { RabbitMQEventLaneQueue } from "@bluelibs/runner/node";
360
+
361
+ new RabbitMQEventLaneQueue({
362
+ url: process.env.RABBITMQ_URL,
363
+ queue: {
364
+ name: "runner.notifications",
365
+ durable: true,
366
+ deadLetter: {
367
+ queue: "runner.notifications.dlq",
368
+ exchange: "",
369
+ routingKey: "runner.notifications.dlq",
370
+ },
371
+ },
372
+ publishConfirm: true,
373
+ reconnect: {
374
+ enabled: true,
375
+ maxAttempts: 10,
376
+ initialDelayMs: 200,
377
+ maxDelayMs: 2000,
378
+ },
379
+ });
380
+ ```
381
+
382
+ > **runtime:** "publishConfirm: true. Because 'the broker probably got it' is not a delivery guarantee."
383
+
384
+ ## RPC Lanes in Network Mode
385
+
386
+ 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.
387
+
388
+ HTTP client transport options:
389
+
390
+ - `client: "fetch"` uses the universal `createHttpClient` path and works in any `fetch` runtime.
391
+ - `client: "mixed"` and `client: "smart"` are Node-only presets from `@bluelibs/runner/node`, intended for streaming and multipart flows.
392
+ - Runner no longer exposes a global `resources.httpClientFactory`; create clients explicitly or use `r.rpcLane.httpClient(...)` for communicator resources.
393
+
394
+ ### Quick Start
395
+
396
+ ```typescript
397
+ import { r } from "@bluelibs/runner";
398
+ import { rpcLanesResource } from "@bluelibs/runner/node";
399
+
400
+ // 1. Define a lane
401
+ const billingLane = r.rpcLane("app.rpc.billing").build();
402
+
403
+ // 2. Tag the task for lane routing
404
+ const chargeCard = r
405
+ .task("billing.tasks.chargeCard")
406
+ .tags([r.runner.tags.rpcLane.with({ lane: billingLane })])
407
+ .run(async (input: { amount: number }) => ({
408
+ ok: true,
409
+ amount: input.amount,
410
+ }))
411
+ .build();
412
+
413
+ // 3. Create a communicator for the remote side
414
+ const billingCommunicator = r
415
+ .resource("app.resources.billingCommunicator")
416
+ .init(
417
+ r.rpcLane.httpClient({
418
+ client: "mixed",
419
+ baseUrl: process.env.BILLING_RPC_URL as string,
420
+ auth: { token: process.env.RUNNER_RPC_TOKEN as string }, // exposure HTTP auth
421
+ }),
422
+ )
423
+ .build();
424
+
425
+ // 4. Wire topology: who serves what, and which communicator reaches each lane
426
+ const topology = r.rpcLane.topology({
427
+ profiles: {
428
+ api: { serve: [] },
429
+ billing: { serve: [billingLane] },
430
+ },
431
+ bindings: [{ lane: billingLane, communicator: billingCommunicator }],
432
+ });
433
+
434
+ // 5. Register and run
435
+ const app = r
436
+ .resource("app")
437
+ .register([
438
+ chargeCard,
439
+ billingCommunicator,
440
+ rpcLanesResource.with({
441
+ profile: "api",
442
+ topology,
443
+ mode: "network",
444
+ }),
445
+ ])
446
+ .build();
447
+ ```
448
+
449
+ **What you just learned**: RPC lane definition, task tagging, communicator wiring, and profile-based serve routing — the full RPC Lane pattern.
450
+
451
+ ### Routing Branches (`mode: "network"`)
452
+
453
+ | Condition | Result |
454
+ | ------------------------------------- | -------------------------------------- |
455
+ | lane is in active profile `serve` | execute locally |
456
+ | lane is not in active profile `serve` | execute remotely via lane communicator |
457
+ | task/event is not lane-assigned | use normal local Runner path |
458
+
459
+ > **runtime:** "Serve it or ship it. There is no 'maybe call the other service.'"
460
+
461
+ ### RPC Lane Network Lifecycle (Routing + Exposure + Lane Auth)
462
+
463
+ ```mermaid
464
+ sequenceDiagram
465
+ participant CR as Caller Runtime
466
+ participant RL as RpcLane Router
467
+ participant CM as Communicator
468
+ participant EX as RPC Exposure Server
469
+ participant SR as Serving Runtime
470
+
471
+ CR->>RL: runTask/runEvent (lane-assigned)
472
+ RL->>RL: is lane in active profile serve?
473
+ alt Served locally
474
+ RL->>SR: execute locally
475
+ SR-->>CR: result
476
+ else Routed remotely
477
+ RL->>RL: build headers (lane JWT + allowlisted async contexts)
478
+ RL->>CM: communicator.task/event(...)
479
+ CM->>EX: HTTP request to /__runner/*
480
+ EX->>EX: exposure auth + allow-list check
481
+ EX->>EX: verify lane JWT (binding.auth verifier)
482
+ EX->>SR: execute task/event
483
+ SR-->>EX: serialized result
484
+ EX-->>CM: HTTP response
485
+ CM-->>CR: result
486
+ end
487
+ ```
488
+
489
+ ## Common Patterns
490
+
491
+ ### Command via RPC, then Propagate via Event Lane
492
+
493
+ A typical microservice boundary: the API calls billing synchronously (RPC Lane), and billing broadcasts the result asynchronously (Event Lane) for downstream projections.
494
+
495
+ ```typescript
496
+ // API service: calls billing via RPC Lane, waits for result
497
+ const result = await runTask(chargeCard, { amount: 42 });
498
+
499
+ // Billing service hook: after charging, emits domain event via Event Lane
500
+ // -> order service, analytics workers, and audit consumers pick it up asynchronously
501
+ const onCardCharged = r
502
+ .hook("billing.hooks.onCardCharged")
503
+ .on(chargeCardCompleted)
504
+ .run(async (event) => {
505
+ await emitEvent(cardCharged, {
506
+ orderId: event.data.orderId,
507
+ amount: event.data.amount,
508
+ });
509
+ })
510
+ .build();
511
+ ```
512
+
513
+ ### Multi-Worker Prefetch Topology
514
+
515
+ Multiple worker processes consume from the same queue. Each worker gets `prefetch` messages at a time, providing natural back-pressure.
516
+
517
+ ```typescript
518
+ const topology = r.eventLane.topology({
519
+ profiles: {
520
+ api: { consume: [] },
521
+ worker: { consume: [emailLane, smsLane] },
522
+ },
523
+ bindings: [
524
+ { lane: emailLane, queue: emailQueue, prefetch: 16 },
525
+ { lane: smsLane, queue: smsQueue, prefetch: 4 },
526
+ ],
527
+ });
528
+ // Deploy N worker instances — each gets its own prefetch window
529
+ ```
530
+
531
+ ### Progressive Profile Expansion
532
+
533
+ Start with one profile that does everything, then split as traffic grows. Bindings stay the same — only the profile assignment changes per deployment.
534
+
535
+ ```typescript
536
+ // Phase 1: monolith — one profile consumes all lanes
537
+ const topology = r.eventLane.topology({
538
+ profiles: {
539
+ mono: { consume: [emailLane, analyticsLane] },
540
+ },
541
+ bindings: [
542
+ { lane: emailLane, queue: emailQueue },
543
+ { lane: analyticsLane, queue: analyticsQueue },
544
+ ],
545
+ });
546
+
547
+ // Phase 2: split workers — add profiles, same bindings
548
+ const topology = r.eventLane.topology({
549
+ profiles: {
550
+ emailWorker: { consume: [emailLane] },
551
+ analyticsWorker: { consume: [analyticsLane] },
552
+ },
553
+ bindings: [
554
+ { lane: emailLane, queue: emailQueue },
555
+ { lane: analyticsLane, queue: analyticsQueue },
556
+ ],
557
+ });
558
+ ```
559
+
560
+ ## DLQ, Retries, and Failure Ownership
561
+
562
+ Keep responsibilities clearly separated:
563
+
564
+ **Transport-level (lane binding + broker config):**
565
+
566
+ - `maxAttempts` + `retryDelayMs` at the lane binding level control retry budget before final failure
567
+ - DLQ behavior is **broker/queue-policy owned**
568
+ - Runner settles final consumer failure with `nack(false)` — it does **not** manually publish to a DLQ queue
569
+ - If your queue has no dead-letter configuration, a final `nack(false)` discards the message per broker behavior
570
+
571
+ **Business-level (task/hook middleware + domain logic):**
572
+
573
+ - Use task middleware (`retry`, `circuitBreaker`, `fallback`) for business-level resilience
574
+ - Use domain compensation patterns for recovery flows
575
+
576
+ > **runtime:** "Retry at the transport layer. Compensate at the business layer. Panic at the ops layer."
577
+
578
+ ## Testing Lanes
579
+
580
+ ### Unit Tests: `transparent` Mode
581
+
582
+ 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.
583
+
584
+ ```typescript
585
+ import { r, run } from "@bluelibs/runner";
586
+ import { eventLanesResource } from "@bluelibs/runner/node";
587
+
588
+ // Assuming: myEvent and myHook are defined elsewhere
589
+ const app = r
590
+ .resource("app")
591
+ .register([
592
+ myEvent,
593
+ myHook,
594
+ eventLanesResource.with({
595
+ profile: "test",
596
+ mode: "transparent",
597
+ topology: r.eventLane.topology({
598
+ profiles: { test: { consume: [] } },
599
+ bindings: [],
600
+ }),
601
+ }),
602
+ ])
603
+ .build();
604
+
605
+ const { emitEvent, dispose } = await run(app);
606
+ await emitEvent(myEvent, { userId: "u1" }); // hooks run locally, no queue involved
607
+ await dispose();
608
+ ```
609
+
610
+ ### Boundary Tests: `local-simulated` Mode
611
+
612
+ 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.
613
+
614
+ 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.
615
+
616
+ ```typescript
617
+ eventLanesResource.with({
618
+ profile: "test",
619
+ mode: "local-simulated",
620
+ topology: r.eventLane.topology({
621
+ profiles: { test: { consume: [] } },
622
+ bindings: [],
623
+ }),
624
+ });
625
+ ```
626
+
627
+ ### Integration Tests: `MemoryEventLaneQueue`
628
+
629
+ Use `MemoryEventLaneQueue` for full lane routing tests without external infrastructure. This exercises the real enqueue/consume/relay path in-process.
630
+
631
+ ```typescript
632
+ import { r, run } from "@bluelibs/runner";
633
+ import {
634
+ eventLanesResource,
635
+ MemoryEventLaneQueue,
636
+ } from "@bluelibs/runner/node";
637
+
638
+ const lane = r.eventLane("app.lanes.test").build();
639
+ const queue = new MemoryEventLaneQueue();
640
+
641
+ const topology = r.eventLane.topology({
642
+ profiles: { test: { consume: [lane] } },
643
+ bindings: [{ lane, queue }],
644
+ });
645
+
646
+ // Assuming: myEvent and myHook are defined elsewhere
647
+ const app = r
648
+ .resource("app")
649
+ .register([
650
+ myEvent,
651
+ myHook,
652
+ eventLanesResource.with({ profile: "test", topology, mode: "network" }),
653
+ ])
654
+ .build();
655
+
656
+ const { emitEvent, dispose } = await run(app);
657
+ await emitEvent(myEvent, { userId: "u1" }); // enqueues, consumes, relays, hooks run
658
+ await dispose();
659
+ ```
660
+
661
+ ## Debugging
662
+
663
+ When things go sideways, start with verbose mode:
664
+
665
+ ```typescript
666
+ const runtime = await run(app, { debug: "verbose" });
667
+ ```
668
+
669
+ This enables Event Lanes routing diagnostics. Look for these entries:
670
+
671
+ | Diagnostic | Meaning |
672
+ | -------------------------------- | ------------------------------------------------------------------------ |
673
+ | `event-lanes.enqueue` | Event was intercepted and enqueued to the lane's queue |
674
+ | `event-lanes.relay-emit` | Consumer dequeued and re-emitted the event locally |
675
+ | `event-lanes.skip-inactive-lane` | Event's lane is not consumed by the active profile (nacked with requeue) |
676
+
677
+ If you see `skip-inactive-lane`, your active profile likely doesn't include the lane in its `consume` list.
678
+
679
+ ## Pros and Cons by Mode
680
+
681
+ | Mode | Pros | Cons | Misuse Warning |
682
+ | ----------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------- |
683
+ | `network` | true transport behavior, real queue/communicator integration, realistic failure surfaces | infra required, slower local loop | Do not skip this mode before production rollout |
684
+ | `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 |
685
+ | `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 |
686
+
687
+ Key rules:
688
+
689
+ - `mode` is authoritative and sits above topology/profile routing.
690
+ - In `transparent` and `local-simulated`, profile routing (`consume`/`serve`) is ignored for transport decisions.
691
+ - Only `network` uses queue/communicator bindings for remote routing.
692
+
693
+ ## Security and Exposure
694
+
695
+ 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`.
696
+
697
+ Security defaults:
698
+
699
+ - Exposure remains **fail-closed** unless you explicitly configure otherwise
700
+ - Always configure `http.auth` with a token or a validator task (edge gate)
701
+ - Run behind trusted network boundaries and infrastructure gateway controls (rate-limits, network policy)
702
+ - Keep anonymous exposure disabled unless explicitly needed
703
+ - Exposure HTTP bootstrap is `listen`-based; custom `http.Server` injection is not part of the contract
704
+
705
+ There are two independent security layers:
706
+
707
+ 1. **Exposure HTTP auth** (`exposure.http.auth`) controls who can hit `POST /__runner/task/...` and `POST /__runner/event/...`.
708
+ 2. **Lane JWT auth** (`binding.auth`) controls lane-authorized produce/consume behavior.
709
+
710
+ You usually want both.
711
+
712
+ ```typescript
713
+ const activeProfile = (process.env.RUNNER_PROFILE as "api" | "billing") ?? "api";
714
+ const billingLane = r.rpcLane("app.rpc.billing").build();
715
+ const topology = r.rpcLane.topology({
716
+ profiles: {
717
+ api: { serve: [] },
718
+ billing: { serve: [billingLane] },
719
+ },
720
+ bindings: [
721
+ {
722
+ lane: billingLane,
723
+ communicator: billingCommunicator,
724
+ auth: {
725
+ mode: "jwt_asymmetric",
726
+ algorithm: "EdDSA",
727
+ privateKey: process.env.BILLING_PRIVATE_KEY as string,
728
+ publicKey: process.env.BILLING_PUBLIC_KEY as string,
729
+ },
730
+ },
731
+ ],
732
+ });
733
+
734
+ rpcLanesResource.with({
735
+ profile: activeProfile,
736
+ topology,
737
+ mode: "network",
738
+ exposure: {
739
+ http: {
740
+ basePath: "/__runner",
741
+ listen: { port: 7070 },
742
+ auth: { token: process.env.RUNNER_RPC_TOKEN as string }, // HTTP edge gate
743
+ },
744
+ },
745
+ });
746
+ ```
747
+
748
+ Binding auth (JWT):
749
+
750
+ - JWT mode is configured only at `binding.auth`.
751
+ - Supported modes: `none`, `jwt_hmac`, `jwt_asymmetric`.
752
+ - `local-simulated` enforces auth when configured (it does not bypass lane JWT checks).
753
+ - For asymmetric mode (`jwt_asymmetric`), producers sign with **private keys**, consumers verify with **public keys**.
754
+ - This principle is identical for RPC and Event Lanes.
755
+
756
+ Asymmetric JWT should prove these two properties in your setup:
757
+
758
+ 1. **Producer cannot produce with only public key material**.
759
+ 2. **Consumer cannot verify with only private key material**.
760
+
761
+ ```typescript
762
+ // Producer profile (not serving lane): needs signer material (private key)
763
+ const producerTopology = r.rpcLane.topology({
764
+ profiles: { api: { serve: [] } },
765
+ bindings: [
766
+ {
767
+ lane: billingLane,
768
+ communicator: billingCommunicator,
769
+ auth: {
770
+ mode: "jwt_asymmetric",
771
+ privateKey: process.env.BILLING_PRIVATE_KEY as string,
772
+ },
773
+ },
774
+ ],
775
+ });
776
+
777
+ // Consumer profile (serving lane): needs verifier material (public key)
778
+ const consumerTopology = r.rpcLane.topology({
779
+ profiles: { billing: { serve: [billingLane] } },
780
+ bindings: [
781
+ {
782
+ lane: billingLane,
783
+ communicator: billingCommunicator,
784
+ auth: {
785
+ mode: "jwt_asymmetric",
786
+ publicKey: process.env.BILLING_PUBLIC_KEY as string,
787
+ },
788
+ },
789
+ ],
790
+ });
791
+ ```
792
+
793
+ Fail-fast proof snippets (recommended in integration tests):
794
+
795
+ ```typescript
796
+ // 1) Producer with only public key -> signer missing (cannot mint lane token)
797
+ await expect(run(producerAppWithPublicKeyOnly)).rejects.toMatchObject({
798
+ name: "runner.errors.remoteLanes.auth.signerMissing",
799
+ });
800
+
801
+ // 2) Consumer with only private key -> verifier missing (cannot verify lane token)
802
+ await expect(run(consumerAppWithPrivateKeyOnly)).rejects.toMatchObject({
803
+ name: "runner.errors.remoteLanes.auth.verifierMissing",
804
+ });
805
+ ```
806
+
807
+ Event Lane parity example (same asymmetric role split):
808
+
809
+ ```typescript
810
+ const activeProfile = (process.env.RUNNER_PROFILE as "api" | "worker") ?? "api";
811
+ const notificationsLane = r.eventLane("app.events.notifications").build();
812
+ const topology = r.eventLane.topology({
813
+ profiles: {
814
+ api: { consume: [] }, // producer profile
815
+ worker: { consume: [notificationsLane] }, // consumer profile
816
+ },
817
+ bindings: [
818
+ {
819
+ lane: notificationsLane,
820
+ queue: new MemoryEventLaneQueue(),
821
+ auth: {
822
+ mode: "jwt_asymmetric",
823
+ privateKey: process.env.EVENTS_PRIVATE_KEY as string, // producer path
824
+ publicKey: process.env.EVENTS_PUBLIC_KEY as string, // consumer path
825
+ },
826
+ },
827
+ ],
828
+ });
829
+
830
+ eventLanesResource.with({
831
+ profile: activeProfile,
832
+ topology,
833
+ mode: "network",
834
+ });
835
+ ```
836
+
837
+ `local-simulated` note:
838
+
839
+ - Auth is still enforced.
840
+ - For `jwt_asymmetric`, the same runtime signs and verifies during simulation, so binding auth must provide both sides of material.
841
+ - RPC lane `asyncContexts` allowlist still applies in `local-simulated` (default `[]`, so no implicit forwarding).
842
+
843
+ ## Migration Notes (v6)
844
+
845
+ Legacy pre-lane event routing (`events` + `emit` + `eventDeliveryMode`) is removed in v6. Here's what replaces it:
846
+
847
+ | Legacy pre-lane pattern | v6 replacement |
848
+ | ---------------------------------------- | ------------------------------------------------------- |
849
+ | `remoteResource.with({ events: [...] })` | `eventLanesResource.with({ topology, profile })` |
850
+ | `remoteResource.with({ emit: [...] })` | Tag events with `r.runner.tags.eventLane.with({ lane })` |
851
+ | `eventDeliveryMode: "queue"` | Event Lanes `mode: "network"` with queue binding |
852
+ | Sync remote task calls | RPC Lanes `mode: "network"` with communicator binding |
853
+
854
+ Transport/wire details for HTTP RPC are in [REMOTE_LANES_HTTP_POLICY.md](./REMOTE_LANES_HTTP_POLICY.md).
855
+
856
+ ## Troubleshooting Checklist
857
+
858
+ When routing does not behave as expected, check in this order:
859
+
860
+ 1. **Lane binding exists** for each lane-assigned definition used in `network` mode.
861
+ 2. **Active profile includes expected lanes** — `consume` for event workers, `serve` for RPC servers.
862
+ 3. **Queue/communicator dependencies resolve** in the runtime container.
863
+ 4. **Runtime mode is what you think it is** — `transparent` and `local-simulated` bypass network routing entirely.
864
+ 5. **Queue dead-letter policy is configured** if you expect failed messages in a DLQ.
865
+ 6. **Debug mode is on** — `run(app, { debug: "verbose" })` shows lane routing diagnostics.
866
+
867
+ ## Reference Contracts
868
+
869
+ ### Core Guard Rails
870
+
871
+ - Lane ids must be non-empty strings.
872
+ - Event definitions must not end up routed through both lane systems (`eventLane` + `rpcLane`).
873
+ - A definition cannot be assigned to two different lanes in the same lane system.
874
+ - `applyTo(...)` is authoritative: when a task/event matches `applyTo`, it overrides any tag-based (IoC) lane assignment for that definition.
875
+ - `applyTo` supports either:
876
+ - A list of explicit targets (definitions or id strings), validated against container definitions and failing fast on invalid type/id.
877
+ - A predicate function that is evaluated against container definitions at runtime.
878
+ - `transactional + r.runner.tags.eventLane` is invalid.
879
+ - `transactional + parallel` is invalid.
880
+
881
+ ### Event Lane Contract
882
+
883
+ | Concept | API |
884
+ | ---------------- | ---------------------------------------------------------------- |
885
+ | Lane definition | `r.eventLane("...").applyTo([...])` or `r.eventLane("...").applyTo((event) => boolean)` |
886
+ | Event tagging | `r.runner.tags.eventLane.with({ lane })` |
887
+ | Topology | `r.eventLane.topology({ profiles, bindings })` |
888
+ | Profile consume | `profiles[profile].consume: lane[]` |
889
+ | Binding | `{ lane, queue, prefetch?, maxAttempts?, retryDelayMs? }` |
890
+ | Runtime resource | `eventLanesResource.with({ profile, topology, mode? })` |
891
+
892
+ ### RPC Lane Contract
893
+
894
+ | Concept | API |
895
+ | ------------------ | ---------------------------------------------------------------- |
896
+ | Lane definition | `r.rpcLane("...").applyTo([...])` or `r.rpcLane("...").applyTo((taskOrEvent) => boolean)` |
897
+ | Task/event tagging | `r.runner.tags.rpcLane.with({ lane })` |
898
+ | Topology | `r.rpcLane.topology({ profiles, bindings })` |
899
+ | Profile serve | `profiles[profile].serve: lane[]` |
900
+ | Binding | `{ lane, communicator, auth?, allowAsyncContext? }` |
901
+ | Runtime resource | `rpcLanesResource.with({ profile, topology, mode?, exposure? })` |
902
+
903
+ ### Communicator Contract
904
+
905
+ | Method | Signature | Required |
906
+ | ----------------- | ------------------------------------ | ------------------ |
907
+ | `task` | `(id, input?) => Promise<unknown>` | Yes (for task RPC) |
908
+ | `event` | `(id, payload?) => Promise<void>` | Optional |
909
+ | `eventWithResult` | `(id, payload?) => Promise<unknown>` | Optional |