@bluelibs/runner-dev 5.3.0 → 6.0.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.
- package/AI.md +25 -3
- package/README.md +190 -55
- package/dist/cli/generators/artifact.js +2 -14
- package/dist/cli/generators/artifact.js.map +1 -1
- package/dist/cli/generators/common.d.ts +1 -0
- package/dist/cli/generators/common.js +22 -0
- package/dist/cli/generators/common.js.map +1 -1
- package/dist/cli/generators/printNewHelp.js +2 -2
- package/dist/cli/generators/printNewHelp.js.map +1 -1
- package/dist/cli/generators/scaffold/templates/package.json.d.ts +2 -2
- package/dist/cli/generators/scaffold/templates/package.json.js +2 -2
- package/dist/cli/generators/scaffold/templates/src/main.ts.js +7 -9
- package/dist/cli/generators/scaffold/templates/src/main.ts.js.map +1 -1
- package/dist/cli/generators/scaffold.js +1 -135
- package/dist/cli/generators/scaffold.js.map +1 -1
- package/dist/cli/generators/templates.js +64 -63
- package/dist/cli/generators/templates.js.map +1 -1
- package/dist/generated/resolvers-types.d.ts +376 -144
- package/dist/index.d.ts +39 -43
- package/dist/resources/cli.config.resource.d.ts +1 -1
- package/dist/resources/cli.config.resource.js +2 -2
- package/dist/resources/cli.config.resource.js.map +1 -1
- package/dist/resources/coverage.resource.d.ts +2 -2
- package/dist/resources/coverage.resource.js +3 -3
- package/dist/resources/coverage.resource.js.map +1 -1
- package/dist/resources/dev.resource.d.ts +1 -1
- package/dist/resources/dev.resource.js +2 -2
- package/dist/resources/dev.resource.js.map +1 -1
- package/dist/resources/docs.generator.resource.d.ts +4 -4
- package/dist/resources/docs.generator.resource.js +2 -2
- package/dist/resources/docs.generator.resource.js.map +1 -1
- package/dist/resources/graphql-accumulator.resource.d.ts +2 -2
- package/dist/resources/graphql-accumulator.resource.js +6 -3
- package/dist/resources/graphql-accumulator.resource.js.map +1 -1
- package/dist/resources/graphql.cli.resource.d.ts +1 -1
- package/dist/resources/graphql.cli.resource.js +2 -2
- package/dist/resources/graphql.cli.resource.js.map +1 -1
- package/dist/resources/graphql.query.cli.task.d.ts +14 -16
- package/dist/resources/graphql.query.cli.task.js +3 -3
- package/dist/resources/graphql.query.cli.task.js.map +1 -1
- package/dist/resources/graphql.query.task.d.ts +18 -20
- package/dist/resources/graphql.query.task.js +4 -4
- package/dist/resources/graphql.query.task.js.map +1 -1
- package/dist/resources/http.tag.d.ts +1 -1
- package/dist/resources/http.tag.js +2 -2
- package/dist/resources/http.tag.js.map +1 -1
- package/dist/resources/introspector.cli.resource.d.ts +2 -2
- package/dist/resources/introspector.cli.resource.js +14 -6
- package/dist/resources/introspector.cli.resource.js.map +1 -1
- package/dist/resources/introspector.resource.d.ts +3 -3
- package/dist/resources/introspector.resource.js +4 -5
- package/dist/resources/introspector.resource.js.map +1 -1
- package/dist/resources/live.resource.d.ts +4 -6
- package/dist/resources/live.resource.js +38 -25
- package/dist/resources/live.resource.js.map +1 -1
- package/dist/resources/models/Introspector.d.ts +28 -14
- package/dist/resources/models/Introspector.js +334 -161
- package/dist/resources/models/Introspector.js.map +1 -1
- package/dist/resources/models/durable.runtime.js +36 -10
- package/dist/resources/models/durable.runtime.js.map +1 -1
- package/dist/resources/models/durable.tools.d.ts +1 -1
- package/dist/resources/models/durable.tools.js +6 -3
- package/dist/resources/models/durable.tools.js.map +1 -1
- package/dist/resources/models/initializeFromStore.js +54 -21
- package/dist/resources/models/initializeFromStore.js.map +1 -1
- package/dist/resources/models/initializeFromStore.utils.d.ts +7 -6
- package/dist/resources/models/initializeFromStore.utils.js +302 -25
- package/dist/resources/models/initializeFromStore.utils.js.map +1 -1
- package/dist/resources/models/introspector.tools.js +18 -6
- package/dist/resources/models/introspector.tools.js.map +1 -1
- package/dist/resources/routeHandlers/getDocsData.d.ts +4 -0
- package/dist/resources/routeHandlers/getDocsData.js +28 -0
- package/dist/resources/routeHandlers/getDocsData.js.map +1 -1
- package/dist/resources/routeHandlers/registerHttpRoutes.hook.d.ts +26 -25
- package/dist/resources/routeHandlers/registerHttpRoutes.hook.js +10 -9
- package/dist/resources/routeHandlers/registerHttpRoutes.hook.js.map +1 -1
- package/dist/resources/server.resource.d.ts +20 -22
- package/dist/resources/server.resource.js +6 -6
- package/dist/resources/server.resource.js.map +1 -1
- package/dist/resources/swap.cli.resource.d.ts +4 -4
- package/dist/resources/swap.cli.resource.js +2 -2
- package/dist/resources/swap.cli.resource.js.map +1 -1
- package/dist/resources/swap.resource.d.ts +7 -7
- package/dist/resources/swap.resource.js +188 -38
- package/dist/resources/swap.resource.js.map +1 -1
- package/dist/resources/swap.tools.d.ts +3 -2
- package/dist/resources/swap.tools.js +27 -27
- package/dist/resources/swap.tools.js.map +1 -1
- package/dist/resources/telemetry.resource.d.ts +1 -1
- package/dist/resources/telemetry.resource.js +46 -43
- package/dist/resources/telemetry.resource.js.map +1 -1
- package/dist/runner-compat.d.ts +85 -0
- package/dist/runner-compat.js +178 -0
- package/dist/runner-compat.js.map +1 -0
- package/dist/runner-node-compat.d.ts +2 -0
- package/dist/runner-node-compat.js +28 -0
- package/dist/runner-node-compat.js.map +1 -0
- package/dist/schema/index.js +4 -8
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/model.d.ts +80 -23
- package/dist/schema/model.js.map +1 -1
- package/dist/schema/query.js +2 -1
- package/dist/schema/query.js.map +1 -1
- package/dist/schema/types/AllType.js +6 -3
- package/dist/schema/types/AllType.js.map +1 -1
- package/dist/schema/types/BaseElementCommon.js +2 -2
- package/dist/schema/types/ErrorType.js +1 -1
- package/dist/schema/types/ErrorType.js.map +1 -1
- package/dist/schema/types/EventType.js +19 -2
- package/dist/schema/types/EventType.js.map +1 -1
- package/dist/schema/types/LaneSummaryTypes.d.ts +3 -0
- package/dist/schema/types/LaneSummaryTypes.js +19 -0
- package/dist/schema/types/LaneSummaryTypes.js.map +1 -0
- package/dist/schema/types/LiveType.js +67 -0
- package/dist/schema/types/LiveType.js.map +1 -1
- package/dist/schema/types/ResourceType.js +100 -19
- package/dist/schema/types/ResourceType.js.map +1 -1
- package/dist/schema/types/RunOptionsType.js +41 -5
- package/dist/schema/types/RunOptionsType.js.map +1 -1
- package/dist/schema/types/TagType.js +35 -4
- package/dist/schema/types/TagType.js.map +1 -1
- package/dist/schema/types/TaskType.js +5 -0
- package/dist/schema/types/TaskType.js.map +1 -1
- package/dist/schema/types/index.d.ts +2 -2
- package/dist/schema/types/index.js +6 -7
- package/dist/schema/types/index.js.map +1 -1
- package/dist/schema/types/middleware/common.d.ts +3 -2
- package/dist/schema/types/middleware/common.js +19 -13
- package/dist/schema/types/middleware/common.js.map +1 -1
- package/dist/ui/.vite/manifest.json +2 -2
- package/dist/ui/assets/docs-Btkv97Ls.js +302 -0
- package/dist/ui/assets/docs-Btkv97Ls.js.map +1 -0
- package/dist/ui/assets/docs-CipvKUxZ.css +1 -0
- package/dist/utils/lane-resources.d.ts +55 -0
- package/dist/utils/lane-resources.js +143 -0
- package/dist/utils/lane-resources.js.map +1 -0
- package/dist/utils/zod.js +36 -3
- package/dist/utils/zod.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -4
- package/readmes/runner-AI.md +740 -0
- package/readmes/runner-durable-workflows.md +2247 -0
- package/readmes/runner-full-guide.md +5869 -0
- package/readmes/runner-remote-lanes.md +909 -0
- package/dist/ui/assets/docs-BhRuaJ5l.css +0 -1
- package/dist/ui/assets/docs-H4oDZj7p.js +0 -302
- 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 |
|