@bluelibs/runner 4.9.0 → 5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bluelibs/runner",
3
- "version": "4.9.0",
3
+ "version": "5.0.0",
4
4
  "description": "BlueLibs Runner",
5
5
  "sideEffects": false,
6
6
  "main": "dist/universal/index.cjs",
@@ -77,25 +77,35 @@
77
77
  "node": ">=18"
78
78
  },
79
79
  "scripts": {
80
- "build": "tsup --config tsup.config.ts --sourcemap",
80
+ "build": "tsup --config config/tsup/tsup.config.ts --sourcemap",
81
+ "build:dashboard": "node ./scripts/build-dashboard.mjs",
82
+ "build:dashboard:ci": "node ./scripts/build-dashboard.mjs --install",
81
83
  "clean": "rm -rf dist",
82
- "watch": "tsup --config tsup.config.ts --watch",
83
- "test": "jest --verbose --runInBand",
84
- "test:dev": "jest --verbose --watch",
85
- "coverage": "jest --verbose --coverage",
84
+ "watch": "tsup --config config/tsup/tsup.config.ts --watch",
85
+ "test": "node ./scripts/run-jest-watchdog.mjs -- --config config/jest/jest.config.js --maxWorkers=50% --verbose=false",
86
+ "test:serial": "node ./scripts/run-jest-watchdog.mjs -- --config config/jest/jest.config.js --verbose --runInBand",
87
+ "test:dev": "jest --config config/jest/jest.config.js --verbose --watch",
88
+ "coverage": "jest --config config/jest/jest.config.js --verbose --coverage",
86
89
  "coverage:ai": "node ./scripts/run-coverage-ai.mjs",
87
- "verbose:coverage": "jest --verbose --coverage --verbose=false --silent --noStackTrace --bail=1",
88
- "test:clean": "jest --clearCache",
90
+ "verbose:coverage": "jest --config config/jest/jest.config.js --verbose --coverage --verbose=false --silent --noStackTrace --bail=1",
91
+ "test:clean": "jest --config config/jest/jest.config.js --clearCache",
89
92
  "testonly": "npm test",
90
- "test:ci": "jest --verbose --coverage --ci --maxWorkers=2 --reporters=default --reporters=jest-junit",
91
- "test:security": "jest --verbose --runInBand --testPathPattern=src/__tests__/security",
92
- "prepublishOnly": "npm run clean && npm run build",
93
- "typedoc": "typedoc --options typedoc.json",
94
- "benchmark": "jest --config jest.bench.config.js --verbose --runInBand",
95
- "benchmark:json": "BENCHMARK_OUTPUT=./benchmark-results.json NODE_OPTIONS=--expose-gc npm run benchmark",
96
- "benchmark:compare": "node ./scripts/compare-benchmarks.mjs ./baseline.json ./benchmark-results.json ./benchmarks.config.json"
93
+ "test:ci": "node ./scripts/run-jest-watchdog.mjs -- --config config/jest/jest.config.js --verbose --coverage --ci --maxWorkers=2 --reporters=default --reporters=jest-junit",
94
+ "test:security": "jest --config config/jest/jest.config.js --verbose --runInBand --testPathPattern=src/__tests__/security",
95
+ "typecheck": "tsc -p config/ts/tsconfig.json --noEmit",
96
+ "lint": "eslint --config config/eslint/eslint.config.mjs ./src",
97
+ "lint:fix": "eslint --config config/eslint/eslint.config.mjs ./src --fix",
98
+ "qa": "npm run coverage:ai && npm run lint:fix && npm run typecheck && npm run build",
99
+ "prepublishOnly": "npm run clean && npm run build && npm run build:dashboard",
100
+ "postbuild": "npm run guide:compose",
101
+ "typedoc": "typedoc --options config/typedoc/typedoc.json",
102
+ "benchmark": "jest --config config/jest/jest.bench.config.js --verbose --runInBand",
103
+ "benchmark:json": "BENCHMARK_OUTPUT=./benchmarks/benchmark-results.json NODE_OPTIONS=--expose-gc npm run benchmark",
104
+ "benchmark:compare": "node ./scripts/compare-benchmarks.mjs ./benchmarks/baseline.json ./benchmarks/benchmark-results.json ./benchmarks/benchmarks.config.json",
105
+ "guide:compose": "node ./scripts/compose-readme.mjs"
97
106
  },
98
107
  "devDependencies": {
108
+ "@types/amqplib": "^0.10.7",
99
109
  "@types/benchmark": "^2.1.5",
100
110
  "@types/busboy": "^1.5.4",
101
111
  "@types/express": "^5.0.3",
@@ -106,14 +116,17 @@
106
116
  "@typescript-eslint/parser": "8.39.0",
107
117
  "benchmark": "^2.1.4",
108
118
  "busboy": "^1.6.0",
109
- "eslint": "^9.32.0",
110
- "eslint-config-prettier": "6.3.0",
111
- "eslint-plugin-prettier": "3.1.1",
119
+ "eslint": "^9.39.2",
120
+ "eslint-config-prettier": "^10.1.8",
121
+ "eslint-plugin-jest": "^29.11.2",
122
+ "eslint-plugin-prettier": "^5.5.4",
123
+ "eslint-plugin-unused-imports": "^4.3.0",
112
124
  "express": "^5.1.0",
125
+ "globals": "^16.5.0",
113
126
  "jest": "^29.0.0",
114
127
  "jest-environment-jsdom": "^30.1.2",
115
128
  "jest-junit": "^10.0.0",
116
- "prettier": "^2.0.5",
129
+ "prettier": "^3.7.4",
117
130
  "reflect-metadata": "^0.2.2",
118
131
  "source-map-support": "^0.5.13",
119
132
  "tailwindcss": "^4.1.12",
@@ -123,6 +136,7 @@
123
136
  "typedoc-material-theme": "^1.1.0",
124
137
  "typedoc-plugin-pages": "^1.1.0",
125
138
  "typescript": "^5.6.2",
139
+ "typescript-eslint": "^8.51.0",
126
140
  "zod": "^4.0.17"
127
141
  },
128
142
  "typings": "dist/universal/index.d.ts",
@@ -136,15 +150,33 @@
136
150
  "index.d.ts",
137
151
  "node",
138
152
  "README.md",
139
- "AI.md",
153
+ "readmes/AI.md",
140
154
  "LICENSE.md"
141
155
  ],
142
156
  "license": "MIT",
143
157
  "dependencies": {
144
- "@bluelibs/ejson": "^1.5.0",
145
158
  "lru-cache": "^11.1.0"
146
159
  },
147
160
  "optionalDependencies": {
148
- "busboy": "^1.6.0"
161
+ "amqplib": "^0.10.9",
162
+ "busboy": "^1.6.0",
163
+ "cron-parser": "^5.4.0",
164
+ "ioredis": "^5.7.0"
165
+ },
166
+ "prettier": {
167
+ "trailingComma": "all",
168
+ "tabWidth": 2,
169
+ "singleQuote": false,
170
+ "endOfLine": "auto",
171
+ "overrides": [
172
+ {
173
+ "files": [
174
+ "*.ts"
175
+ ],
176
+ "options": {
177
+ "parser": "babel-ts"
178
+ }
179
+ }
180
+ ]
149
181
  }
150
182
  }
package/readmes/AI.md ADDED
@@ -0,0 +1,534 @@
1
+ # BlueLibs Runner: Fluent Builder Field Guide
2
+
3
+ > Token-friendly guide spotlighting the fluent builder API (`r.*`). Classic `defineX` / `resource({...})` remain supported for backwards compatibility.
4
+
5
+ For the landing overview, see [README.md](../README.md). For the complete guide, see [FULL_GUIDE.md](./FULL_GUIDE.md).
6
+
7
+ **Durable Workflows (Node-only):** For persistence and crash recovery, see `DURABLE_WORKFLOWS.md`.
8
+
9
+ ## Serializer Safety
10
+
11
+ When deserializing untrusted payloads, configure the serializer to restrict
12
+ symbol handling so payloads cannot grow the global Symbol registry.
13
+
14
+ ```ts
15
+ import { Serializer, SymbolPolicy } from "@bluelibs/runner";
16
+
17
+ const serializer = new Serializer({
18
+ symbolPolicy: SymbolPolicy.WellKnownOnly,
19
+ });
20
+ ```
21
+
22
+ ## Resources
23
+
24
+ ```ts
25
+ import express from "express";
26
+ import { r, run, globals } from "@bluelibs/runner";
27
+ import { nodeExposure } from "@bluelibs/runner/node";
28
+
29
+ const server = r
30
+ .resource<{ port: number }>("app.server")
31
+ .context(() => ({ app: express() }))
32
+ .init(async ({ port }, _deps, ctx) => {
33
+ ctx.app.use(express.json());
34
+ const listener = ctx.app.listen(port);
35
+ return { ...ctx, listener };
36
+ })
37
+ .dispose(async ({ listener }) => listener.close())
38
+ .build();
39
+
40
+ const createUser = r
41
+ .task("app.tasks.createUser")
42
+ .dependencies({ logger: globals.resources.logger })
43
+ .inputSchema<{ name: string }>({ parse: (value) => value })
44
+ .resultSchema<{ id: string; name: string }>({ parse: (value) => value })
45
+ .run(async (input, { logger }) => {
46
+ await logger.info(`Creating user ${input.name}`);
47
+ return { id: "user-1", name: input.name };
48
+ })
49
+ .build();
50
+
51
+ const api = r
52
+ .resource("app.api")
53
+ .register([
54
+ server.with({ port: 3000 }),
55
+ nodeExposure.with({
56
+ http: { basePath: "/__runner", listen: { port: 3000 } },
57
+ }),
58
+ createUser,
59
+ ])
60
+ .dependencies({ server, createUser })
61
+ .init(async (_config, { server, createUser }) => {
62
+ server.listener.on("listening", () => {
63
+ console.log("Runner HTTP server ready on port 3000");
64
+ });
65
+
66
+ server.app.post("/users", async (req, res) => {
67
+ const user = await createUser(req.body);
68
+ res.json(user);
69
+ });
70
+ })
71
+ .build();
72
+
73
+ const runtime = await run(api);
74
+ await runtime.runTask(createUser, { name: "Ada" });
75
+ // runtime.dispose() when you are done.
76
+ ```
77
+
78
+ - `r.*.with(config)` produces a configured copy of the definition.
79
+ - `r.*.fork(newId)` creates a new resource with a different id but the same definition—useful for multi-instance patterns. Export forked resources to use as dependencies.
80
+ - `run(root)` wires dependencies, runs `init`, emits lifecycle events, and returns helpers such as `runTask`, `getResourceValue`, and `dispose`.
81
+ - Enable verbose logging with `run(root, { debug: "verbose" })`.
82
+
83
+ ### Resource Forking
84
+
85
+ Use `.fork(newId)` to clone a resource definition under a new id (handy for multi-instance patterns).
86
+ Forks keep the same implementation/types but get separate runtime instances (no shared state).
87
+ Prefer exporting forks so other tasks/resources can depend on them.
88
+
89
+ ## Tasks
90
+
91
+ Tasks are your business actions. They are plain async functions with DI, middleware, and validation.
92
+
93
+ ```ts
94
+ import { r } from "@bluelibs/runner";
95
+
96
+ // Assuming: userService, loggingMiddleware, and tracingMiddleware are defined elsewhere
97
+ const sendEmail = r
98
+ .task("app.tasks.sendEmail")
99
+ .inputSchema<{ to: string; subject: string; body: string }>({
100
+ parse: (value) => value,
101
+ })
102
+ .dependencies({ emailer: userService })
103
+ .middleware([loggingMiddleware.with({ label: "email" }), tracingMiddleware])
104
+ .run(async (input, { emailer }) => {
105
+ await emailer.send(input);
106
+ return { delivered: true };
107
+ })
108
+ .build();
109
+ ```
110
+
111
+ **Builder composition rules (applies to tasks, resources, hooks, middleware):**
112
+
113
+ - `.dependencies()` accepts a literal map or function `(config) => deps`; appends (shallow-merge) by default
114
+ - `.middleware()` appends by default
115
+ - `.tags()` replaces the list each time
116
+ - Pass `{ override: true }` to any of these methods to replace instead of append
117
+ - Provide result validation with `.resultSchema()` when the function returns structured data
118
+
119
+ ## Events and Hooks
120
+
121
+ Events are strongly typed signals. Hooks listen to them with predictable execution order.
122
+
123
+ ```ts
124
+ import { r } from "@bluelibs/runner";
125
+
126
+ const userRegistered = r
127
+ .event("app.events.userRegistered")
128
+ .payloadSchema<{ userId: string; email: string }>({ parse: (v) => v })
129
+ .build();
130
+
131
+ // Type-only alternative (no runtime payload validation):
132
+ // const userRegistered = r.event<{ userId: string; email: string }>("app.events.userRegistered").build();
133
+
134
+ // Assuming: userService and sendEmail are defined elsewhere
135
+ const registerUser = r
136
+ .task("app.tasks.registerUser")
137
+ .dependencies({ userRegistered, userService })
138
+ .run(async (input, deps) => {
139
+ const user = await deps.userService.create(input);
140
+ await deps.userRegistered({ userId: user.id, email: user.email });
141
+ return user;
142
+ })
143
+ .build();
144
+
145
+ const sendWelcomeEmail = r
146
+ .hook("app.hooks.sendWelcomeEmail")
147
+ .on(userRegistered)
148
+ .dependencies({ mailer: sendEmail })
149
+ .run(async (event, { mailer }) => {
150
+ await mailer({
151
+ to: event.data.email,
152
+ subject: "Welcome",
153
+ body: "Welcome!",
154
+ });
155
+ })
156
+ .build();
157
+ ```
158
+
159
+ - Use `.on(onAnyOf(...))` to listen to several events while keeping inference.
160
+ - Hooks can set `.order(priority)`; lower numbers run first. Call `event.stopPropagation()` inside `run` to cancel downstream hooks.
161
+ - Wildcard hooks use `.on("*")` and receive every emission except events tagged with `globals.tags.excludeFromGlobalHooks`.
162
+ - Use `.parallel(true)` on event definitions to enable batched parallel execution:
163
+ - Listeners with the same `order` run concurrently within a batch
164
+ - Batches execute sequentially in ascending order priority
165
+ - All listeners in a failing batch run to completion; if multiple fail, an `AggregateError` with all errors is thrown
166
+ - Propagation is checked between batches only (not mid-batch since parallel listeners can't be stopped mid-flight)
167
+ - If any listener throws, subsequent batches will not run
168
+
169
+ ## Middleware
170
+
171
+ Middleware wraps tasks or resources. Fluent builders live under `r.middleware`.
172
+
173
+ ```ts
174
+ import { r } from "@bluelibs/runner";
175
+ import { globals } from "@bluelibs/runner";
176
+
177
+ const auditTasks = r.middleware
178
+ .task("app.middleware.audit")
179
+ .dependencies({ logger: globals.resources.logger })
180
+ .everywhere((task) => !task.id.startsWith("admin."))
181
+ .run(async ({ task, next }, { logger }) => {
182
+ logger.info(`→ ${task.definition.id}`);
183
+ const result = await next(task.input);
184
+ logger.info(`← ${task.definition.id}`);
185
+ return result;
186
+ })
187
+ .build();
188
+
189
+ const cacheResources = r.middleware
190
+ .resource("app.middleware.cache")
191
+ .configSchema<{ ttl: number }>({ parse: (value) => value })
192
+ .run(async ({ value, next }, _deps, config) => {
193
+ if (value.current) {
194
+ return value.current;
195
+ }
196
+ const computed = await next();
197
+ value.current = computed;
198
+ setTimeout(() => (value.current = null), config.ttl);
199
+ return computed;
200
+ })
201
+ .build();
202
+ ```
203
+
204
+ Attach middleware using `.middleware([auditTasks])` on the definition that owns it, and register the middleware alongside the target resource or task at the root.
205
+
206
+ - Contract middleware: middleware can declare `Config`, `Input`, `Output` generics; tasks using it must conform (contracts intersect across `.middleware([...])` and `.tags([...])`). Collisions surface as `InputContractViolationError` / `OutputContractViolationError` in TypeScript; if you add `.inputSchema()`, ensure the schema’s inferred type includes the contract shape.
207
+
208
+ ```ts
209
+ type AuthConfig = { requiredRole: string };
210
+ type AuthInput = { user: { role: string } };
211
+ type AuthOutput = { ok: true };
212
+
213
+ const auth = r.middleware
214
+ .task<AuthConfig, AuthInput, AuthOutput>("app.middleware.auth")
215
+ .run(async ({ task, next }) => next(task.input))
216
+ .build();
217
+ ```
218
+
219
+ ### ExecutionJournal
220
+
221
+ **ExecutionJournal** is a typed key-value store scoped to a single task execution, enabling middleware and tasks to share state. It has **fail-fast semantics**: calling `set()` on an existing key throws an error (prevents silent bugs from middleware clobbering each other). Use `{ override: true }` to intentionally update.
222
+
223
+ ```ts
224
+ import { r, globals, journal } from "@bluelibs/runner";
225
+
226
+ const abortControllerKey =
227
+ globals.middleware.task.timeout.journalKeys.abortController;
228
+
229
+ // Middleware accesses journal via execution input
230
+ const auditMiddleware = r.middleware
231
+ .task("app.middleware.audit")
232
+ .run(async ({ task, next, journal }) => {
233
+ // Access typed values from journal
234
+ const ctrl = journal.get(abortControllerKey);
235
+ if (journal.has(abortControllerKey)) {
236
+ /* ... */
237
+ }
238
+ return next(task.input);
239
+ })
240
+ .build();
241
+
242
+ // Task accesses journal via context
243
+ const myTask = r
244
+ .task("app.tasks.myTask")
245
+ .run(async (input, deps, { journal }) => {
246
+ journal.set(abortControllerKey, new AbortController());
247
+ // To update existing: journal.set(key, newValue, { override: true });
248
+ return "done";
249
+ })
250
+ .build();
251
+
252
+ // Create custom keys
253
+ const myKey = journal.createKey<{ startedAt: Date }>("app.middleware.timing");
254
+ ```
255
+
256
+ **Built-in Middleware Journal Keys**: Global middlewares (`retry`, `cache`, `circuitBreaker`, `rateLimit`, `fallback`, `timeout`) expose runtime state via typed journal keys at `globals.middleware.task.<name>.journalKeys`. For example, `retry` exposes `attempt` and `lastError`; `cache` exposes `hit`; `circuitBreaker` exposes `state` and `failures`. Access these via `journal.get(key)` without deep imports.
257
+
258
+ ## Tags
259
+
260
+ Tags let you annotate definitions with metadata that can be queried later.
261
+
262
+ ```ts
263
+ import { r, globals } from "@bluelibs/runner";
264
+
265
+ const httpRouteTag = r
266
+ .tag("app.tags.httpRoute")
267
+ .configSchema<{ method: "GET" | "POST"; path: string }>({
268
+ parse: (value) => value,
269
+ })
270
+ .build();
271
+
272
+ const getHealth = r
273
+ .task("app.tasks.getHealth")
274
+ .tags([httpRouteTag.with({ method: "GET", path: "/health" })])
275
+ .run(async () => ({ status: "ok" }))
276
+ .build();
277
+ ```
278
+
279
+ Retrieve tagged items by using `globals.resources.store` inside a hook or resource and calling `store.getTasksWithTag(tag)`.
280
+
281
+ - Contract tags (a “smart tag”): define type contracts for task input/output (or resource config/value) via `r.tag<TConfig, TInputContract, TOutputContract>(id)`. They don’t change runtime behavior; they shape the inferred types and compose with contract middleware.
282
+ - Smart tags: built-in tags like `globals.tags.system`, `globals.tags.debug`, and `globals.tags.excludeFromGlobalHooks` change framework behavior; use them for per-component debug or to opt out of global hooks.
283
+
284
+ ```ts
285
+ type Input = { id: string };
286
+ type Output = { name: string };
287
+ const userContract = r.tag<void, Input, Output>("contract.user").build();
288
+
289
+ const getUser = r
290
+ .task("app.tasks.getUser")
291
+ .tags([userContract])
292
+ .run(async (input) => ({ name: input.id }))
293
+ .build();
294
+ ```
295
+
296
+ ## Async Context
297
+
298
+ Async Context provides per-request/thread-local state via the platform's `AsyncLocalStorage` (Node). Use the fluent builder under `r.asyncContext` or the classic `asyncContext({ ... })` export.
299
+
300
+ > **Platform Note**: `AsyncLocalStorage` is Node.js-only. Async Context is unavailable in browsers/edge runtimes.
301
+
302
+ ```ts
303
+ import { r } from "@bluelibs/runner";
304
+
305
+ const requestContext = r
306
+ .asyncContext<{ requestId: string }>("app.ctx.request")
307
+ // below is optional
308
+ .configSchema(z.object({ ... }))
309
+ .serialize((data) => JSON.stringify(data))
310
+ .parse((raw) => JSON.parse(raw))
311
+ .build();
312
+
313
+ // Provide and read within an async boundary
314
+ await requestContext.provide({ requestId: "abc" }, async () => {
315
+ const ctx = requestContext.use(); // { requestId: "abc" }
316
+ });
317
+
318
+ // Require middleware for tasks that need the context
319
+ r.task('task').middleware([requestContext.require()]);
320
+ ```
321
+
322
+ - If you don't provide `serialize`/`parse`, Runner uses its default serializer to preserve Dates, RegExp, etc.
323
+ - You can also inject async contexts as dependencies; the injected value is the helper itself. Contexts must be registered to be used.
324
+
325
+ ```ts
326
+ const whoAmI = r
327
+ .task("app.tasks.whoAmI")
328
+ .dependencies({ requestContext })
329
+ .run(async (_input, { requestContext }) => requestContext.use().requestId)
330
+ .build();
331
+
332
+ const app = r.resource("app").register([requestContext, whoAmI]).build();
333
+ ```
334
+
335
+ ## Errors
336
+
337
+ Define typed, namespaced errors with a fluent builder. Built helpers expose `throw` and `is`:
338
+
339
+ ```ts
340
+ import { r } from "@bluelibs/runner";
341
+
342
+ // Fluent builder
343
+ const AppError = r
344
+ .error<{ code: number; message: string }>("app.errors.AppError")
345
+ .dataSchema({ parse: (value) => value })
346
+ .build();
347
+
348
+ try {
349
+ AppError.throw({ code: 400, message: "Oops" });
350
+ } catch (err) {
351
+ if (AppError.is(err)) {
352
+ // Do something
353
+ }
354
+ }
355
+ ```
356
+
357
+ - The thrown `Error` has `name = id` and `message = format(data)`. If you don’t provide `.format(...)`, the default is `JSON.stringify(data)`.
358
+ - `message` is not required in the data unless your custom formatter expects it.
359
+ - Declare a task/resource error contract with `.throws([AppError])` (or ids). This is declarative only and does not imply DI.
360
+
361
+ ## Overrides
362
+
363
+ Override a task/resource/hook/middleware while preserving `id`. Use the helper or the fluent override builder:
364
+
365
+ ```ts
366
+ const mockMailer = r
367
+ .override(realMailer)
368
+ .init(async () => new MockMailer())
369
+ .build();
370
+
371
+ const app = r
372
+ .resource("app")
373
+ .register([realMailer])
374
+ .overrides([mockMailer])
375
+ .build();
376
+ ```
377
+
378
+ - `r.override(base)` starts from the base definition and applies fluent mutations using the same composition rules as the base builder.
379
+ - Hook overrides keep the same `.on` target; only behavior/metadata is overridable.
380
+ - The `override(base, patch)` helper remains for direct, shallow patches.
381
+
382
+ ## Runtime & Lifecycle
383
+
384
+ - `run(root, options)` wires dependencies, initializes resources, and returns helpers: `runTask`, `emitEvent`, `getResourceValue`, `store`, `logger`, and `dispose`.
385
+ - Run options highlights: `debug` (normal/verbose or custom config), `logs` (printThreshold/strategy/buffer), `errorBoundary` and `onUnhandledError`, `shutdownHooks`, `dryRun`.
386
+ - Task interceptors: inside resource init, call `deps.someTask.intercept(async (next, input) => next(input))` to wrap a single task execution at runtime (runs inside middleware; won’t run if middleware short-circuits).
387
+ - Shutdown hooks: install signal listeners to call `dispose` (default in `run`).
388
+ - Unhandled errors: `onUnhandledError` receives a structured context (kind and source) for telemetry or controlled shutdown.
389
+
390
+ ## Reliability & Performance
391
+
392
+ - **Concurrency**: Limit parallel execution using a shared or local `Semaphore`.
393
+ ```ts
394
+ .middleware([globals.middleware.task.concurrency.with({ limit: 5 })])
395
+ ```
396
+ - **Circuit Breaker**: Trip after failures to prevent cascading downstream pressure.
397
+ ```ts
398
+ .middleware([globals.middleware.task.circuitBreaker.with({ failureThreshold: 5, resetTimeout: 30000 })])
399
+ ```
400
+ - **Rate Limit**: Protect APIs with fixed-window request counting.
401
+ ```ts
402
+ .middleware([globals.middleware.task.rateLimit.with({ windowMs: 60000, max: 100 })])
403
+ ```
404
+ - **Temporal (Debounce/Throttle)**: Control execution frequency.
405
+ ```ts
406
+ .middleware([globals.middleware.task.debounce.with({ ms: 300 })])
407
+ ```
408
+ - **Fallback**: Provide a Plan B (value, function, or another task) when the primary fails.
409
+ ```ts
410
+ // Recommended: Fallback should be outer (on top) of Retry to catch final failures
411
+ .middleware([
412
+ globals.middleware.task.fallback.with({ fallback: "Guest User" }),
413
+ globals.middleware.task.retry.with({ attempts: 3 })
414
+ ])
415
+ ```
416
+ - **Retry/Backoff**: `globals.middleware.task.retry` and `globals.middleware.resource.retry` for transient failures.
417
+ ```ts
418
+ .middleware([globals.middleware.task.retry.with({ retries: 3 })])
419
+ ```
420
+ - **Caching**: `globals.middleware.task.cache` plus `globals.resources.cache`.
421
+ ```ts
422
+ .middleware([globals.middleware.task.cache.with({ ttl: 60000 })])
423
+ ```
424
+ - **Timeouts**: `globals.middleware.task.timeout` / `globals.middleware.resource.timeout` using `AbortController`.
425
+ ```ts
426
+ .middleware([globals.middleware.task.timeout.with({ ttl: 5000 })])
427
+ ```
428
+ - **Logging & Debug**: `globals.resources.logger` and `globals.resources.debug`.
429
+ ```ts
430
+ // Verbose debug logging for a specific task
431
+ .tags([globals.tags.debug])
432
+ ```
433
+
434
+ ## HTTP & Tunnels
435
+
436
+ Tunnels let you call Runner tasks/events across a process boundary over a small HTTP surface (Node-only exposure via `nodeExposure`), while preserving task ids, middleware, validation, typed errors, and async context.
437
+
438
+ For “no call-site changes”, register a client-mode tunnel resource tagged with `globals.tags.tunnel` plus phantom tasks for the remote ids; the tunnel middleware auto-routes selected tasks/events to an HTTP client. For explicit boundaries, create a client once and call `client.task(id, input)` / `client.event(id, payload)` directly. Full guide: `readmes/TUNNELS.md`.
439
+
440
+ Node client note: prefer `createHttpMixedClient` (it uses the serialized-JSON path via Runner `Serializer` + `fetch` when possible and switches to the streaming-capable Smart path when needed). If a task may return a stream even for plain JSON inputs (ex: downloads), set `forceSmart` on Mixed (or use `createHttpSmartClient` directly).
441
+
442
+ ## Serialization
443
+
444
+ Runner ships with a serializer that round-trips Dates, RegExp, binary, and custom shapes across Node and web.
445
+
446
+ It also supports:
447
+
448
+ - `bigint` (encoded as a decimal string under `__type: "BigInt"`)
449
+ - `symbol` for `Symbol.for(key)` and well-known symbols like `Symbol.iterator` (unique `Symbol("...")` values are rejected because identity cannot be preserved)
450
+
451
+ ```ts
452
+ import { r, globals } from "@bluelibs/runner";
453
+
454
+ const serializerSetup = r
455
+ .resource("app.serialization")
456
+ .dependencies({ serializer: globals.resources.serializer })
457
+ .init(async (_config, { serializer }) => {
458
+ class Distance {
459
+ constructor(
460
+ public value: number,
461
+ public unit: string,
462
+ ) {}
463
+ typeName() {
464
+ return "Distance";
465
+ }
466
+ toJSONValue() {
467
+ return { value: this.value, unit: this.unit };
468
+ }
469
+ }
470
+
471
+ serializer.addType(
472
+ "Distance",
473
+ (json) => new Distance(json.value, json.unit),
474
+ );
475
+ })
476
+ .build();
477
+ ```
478
+
479
+ Use `getDefaultSerializer()` when you need a standalone instance outside DI.
480
+
481
+ Note on files: The “File” you see in tunnels is not a custom serializer type. Runner uses a dedicated `$runnerFile: "File"` sentinel in inputs which the tunnel client/server convert to multipart streams via a manifest. File handling is performed by the tunnel layer (manifest hydration and multipart), not by the serializer. Keep using `createWebFile`/`createNodeFile` for uploads.
482
+
483
+ ## Testing
484
+
485
+ - In unit tests, prefer running a minimal root resource and call `await run(root)` to get `runTask`, `emitEvent`, or `getResourceValue`.
486
+ - The Jest runner has a watchdog (`JEST_WATCHDOG_MS`, default 10 minutes) to avoid "hung test run" situations.
487
+ - For durable workflow tests, use `createDurableTestSetup` from `@bluelibs/runner/node` for fast, in-memory execution.
488
+
489
+ ```ts
490
+ import { run } from "@bluelibs/runner";
491
+
492
+ test("sends welcome email", async () => {
493
+ const app = r
494
+ .resource("spec.app")
495
+ .register([sendWelcomeEmail, registerUser])
496
+ .build();
497
+ const runtime = await run(app);
498
+ await runtime.runTask(registerUser, { email: "user@example.com" });
499
+ await runtime.dispose();
500
+ });
501
+ ```
502
+
503
+ ## Observability & Debugging
504
+
505
+ - Pass `{ debug: "verbose" }` to `run` for structured logs about registration, middleware, and lifecycle events.
506
+ - `globals.resources.logger` exposes the framework logger; register your own logger resource and override it at the root to capture logs centrally.
507
+ - Hooks and tasks emit metadata through `globals.resources.store`. Query it for dashboards or editor plugins.
508
+ - Use middleware for tracing (`r.middleware.task("...").run(...)`) to wrap every task call.
509
+ - `Semaphore` and `Queue` publish local lifecycle events through isolated `EventManager` instances (`on/once`). These are separate from the global EventManager used for business-level application events. Event names: semaphore → `queued/acquired/released/timeout/aborted/disposed`; queue → `enqueue/start/finish/error/cancel/disposed`.
510
+
511
+ ## Metadata & Namespacing
512
+
513
+ - Meta: `.meta({ title, description })` on tasks/resources/events/middleware for human-friendly docs and tooling; extend meta types via module augmentation when needed.
514
+ - Namespacing: keep ids consistent with `domain.resources.name`, `domain.tasks.name`, `domain.events.name`, `domain.hooks.on-name`, and `domain.middleware.{task|resource}.name`.
515
+ - Runtime validation: `inputSchema`, `resultSchema`, `payloadSchema`, `configSchema` share the same `parse(input)` contract; config validation happens on `.with()`, task/event validation happens on call/emit.
516
+
517
+ ## Advanced Patterns
518
+
519
+ - **Optional dependencies:** mark dependencies as optional (`analytics: analyticsService.optional()`) so the builder injects `null` when the resource is absent.
520
+ - **Conditional registration:** `.register((config) => (config.enableFeature ? [featureResource] : []))`.
521
+ - **Async coordination:** `Semaphore` (O(1) linked queue for heavy contention) and `Queue` live in the main package. Both use isolated EventManagers internally for their lifecycle events, separate from the global EventManager used for business-level application events.
522
+ - **Event safety:** Runner detects event emission cycles and throws an `EventCycleError` with the offending chain.
523
+ - **Internal services:** access `globals.resources.store`, `globals.resources.taskRunner`, and `globals.resources.eventManager` for advanced introspection or custom tooling.
524
+
525
+ ## Interop With Classic APIs
526
+
527
+ Existing code that uses `resource({ ... })`, `task({ ... })`, or `defineX` keeps working. You can gradually migrate:
528
+
529
+ ```ts
530
+ import { r, resource as classicResource } from "@bluelibs/runner";
531
+
532
+ const classic = classicResource({ id: "legacy", init: async () => "ok" });
533
+ const modern = r.resource("modern").register([classic]).build();
534
+ ```