@bluelibs/runner 6.3.0 → 6.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,556 @@
1
+ # Multi-Platform Architecture Guide
2
+
3
+ ← [Back to main README](../README.md)
4
+
5
+ ---
6
+
7
+ Welcome to the BlueLibs Runner multi-platform architecture! This guide will walk you through one of our most interesting architectural decisions: **how we made a single codebase work seamlessly across Node.js, browsers, edge workers, and other JavaScript runtimes**.
8
+
9
+ ## The Challenge We Solved
10
+
11
+ JavaScript runs everywhere these days - Node.js servers, browser tabs, Cloudflare Workers, Deno, Bun, and more. Each environment has its own quirks:
12
+
13
+ - **Node.js** has `process.exit()`, `process.env`, and `AsyncLocalStorage`
14
+ - **Browsers** have `window.addEventListener` and no concept of "process exit"
15
+ - **Edge Workers** are like browsers but without DOM APIs
16
+ - **Deno/Bun** are Node-like but with their own global objects
17
+
18
+ The challenge? How do you write a dependency injection framework that works everywhere without duplicating code?
19
+
20
+ ## Our Solution: Platform Adapters
21
+
22
+ We created a **platform adapter system** that abstracts away runtime differences behind a common interface. Think of it as a translation layer between your application code and the underlying JavaScript runtime.
23
+
24
+ ### The Core Interface
25
+
26
+ ```typescript
27
+ interface IPlatformAdapter {
28
+ // Process management
29
+ onUncaughtException(handler: (error: Error) => void): () => void;
30
+ onUnhandledRejection(handler: (reason: unknown) => void): () => void;
31
+ onShutdownSignal(handler: () => void): () => void;
32
+ exit(code: number): void;
33
+
34
+ // Environment access
35
+ getEnv(key: string): string | undefined;
36
+
37
+ // Async context tracking
38
+ hasAsyncLocalStorage(): boolean;
39
+ createAsyncLocalStorage<T>(): IAsyncLocalStorage<T>;
40
+
41
+ // Timers (already universal!)
42
+ setTimeout: typeof globalThis.setTimeout;
43
+ clearTimeout: typeof globalThis.clearTimeout;
44
+
45
+ // Initialization hook (used by adapters that need setup, like Node ALS loading)
46
+ init(): Promise<void>;
47
+ }
48
+ ```
49
+
50
+ This interface captures **what every runtime needs to provide** for a dependency injection container to work properly.
51
+
52
+ ## Smart Environment Detection
53
+
54
+ The magic starts with environment detection. We don't just guess - we carefully probe the global environment:
55
+
56
+ ```typescript
57
+ export function detectEnvironment(): PlatformId {
58
+ // Browser: has window and document
59
+ if (typeof window !== "undefined" && typeof document !== "undefined") {
60
+ return "browser";
61
+ }
62
+
63
+ const global = globalThis as {
64
+ process?: { versions?: { node?: string; bun?: string } };
65
+ Deno?: unknown;
66
+ Bun?: unknown;
67
+ importScripts?: unknown;
68
+ WorkerGlobalScope?: new () => any;
69
+ };
70
+
71
+ // Node.js: has process.versions.node
72
+ if (global.process?.versions?.node) {
73
+ return "node";
74
+ }
75
+
76
+ // Deno: has global Deno object
77
+ if (typeof global.Deno !== "undefined") {
78
+ return "universal";
79
+ }
80
+
81
+ // Bun: has process.versions.bun
82
+ if (typeof global.Bun !== "undefined" || global.process?.versions?.bun) {
83
+ return "universal";
84
+ }
85
+
86
+ // Edge/Worker-like heuristic: importScripts without window
87
+ if (
88
+ typeof global.importScripts === "function" &&
89
+ typeof window === "undefined"
90
+ ) {
91
+ return "edge";
92
+ }
93
+
94
+ // Edge Workers: WorkerGlobalScope + self instance
95
+ if (
96
+ typeof global.WorkerGlobalScope !== "undefined" &&
97
+ typeof self !== "undefined" &&
98
+ self instanceof global.WorkerGlobalScope
99
+ ) {
100
+ return "edge";
101
+ }
102
+
103
+ // Fallback for unknown environments
104
+ return "universal";
105
+ }
106
+ ```
107
+
108
+ **Why this approach?** We check for the most specific features first, then fall back to broader categories. This means when new runtimes appear, they'll likely work out of the box.
109
+
110
+ `isEdge()` is the public guard for edge/worker-like runtimes. Worker environments are intentionally modeled under the single `"edge"` platform id.
111
+
112
+ ## Meet the Platform Adapters
113
+
114
+ ### NodePlatformAdapter
115
+
116
+ The full-featured adapter for Node.js environments:
117
+
118
+ ```typescript
119
+ export class NodePlatformAdapter implements IPlatformAdapter {
120
+ // Hook into Node's process events
121
+ onUncaughtException(handler) {
122
+ process.on("uncaughtException", handler);
123
+ return () => process.off("uncaughtException", handler);
124
+ }
125
+
126
+ // Handle graceful shutdown
127
+ onShutdownSignal(handler) {
128
+ process.on("SIGINT", handler);
129
+ process.on("SIGTERM", handler);
130
+ // Return cleanup function
131
+ }
132
+
133
+ // Real process.exit()
134
+ exit(code: number) {
135
+ process.exit(code);
136
+ }
137
+
138
+ // Full AsyncLocalStorage support
139
+ hasAsyncLocalStorage() {
140
+ return true; // Node has native ALS
141
+ }
142
+ }
143
+ ```
144
+
145
+ **Why Node.js is special:** It has the richest feature set - real process control, proper signal handling, and native async context tracking.
146
+ Deno and Bun can also expose `AsyncLocalStorage` via compatibility APIs, and the universal path can use it when present.
147
+ That same capability gates Runner's async-context features:
148
+ - `asyncContexts.execution` is fully available only on runtimes that expose `AsyncLocalStorage`
149
+ - when `run(..., { executionContext: ... })` is enabled on a runtime without `AsyncLocalStorage`, Runner fails fast with a typed context error
150
+ - direct `asyncContexts.execution.provide()` / `record()` calls fail fast with a typed context error when no async-local storage exists
151
+ - `asyncContexts.identity` degrades more gently: `tryUse()` returns `undefined`, `has()` returns `false`, and `provide()` still executes the callback without propagation
152
+ - when `run(..., { identity: customIdentityContext })` is enabled on a runtime without `AsyncLocalStorage`, Runner fails fast with a typed identity-context error
153
+
154
+ ### BrowserPlatformAdapter
155
+
156
+ Translates browser concepts to our interface:
157
+
158
+ ```typescript
159
+ export class BrowserPlatformAdapter implements IPlatformAdapter {
160
+ // Browser "uncaught exceptions" = window error events
161
+ onUncaughtException(handler) {
162
+ const target = window ?? globalThis;
163
+ const h = (e) => handler(e?.error ?? e);
164
+ target.addEventListener("error", h);
165
+ return () => target.removeEventListener("error", h);
166
+ }
167
+
168
+ // Browser "shutdown" = page unload
169
+ onShutdownSignal(handler) {
170
+ window.addEventListener("beforeunload", handler);
171
+ // Also handle page visibility changes
172
+ document.addEventListener("visibilitychange", () => {
173
+ if (document.visibilityState === "hidden") handler();
174
+ });
175
+ }
176
+
177
+ // Can't exit a browser tab from JavaScript!
178
+ exit() {
179
+ throw new PlatformUnsupportedFunction("exit");
180
+ }
181
+
182
+ // No AsyncLocalStorage in browsers
183
+ hasAsyncLocalStorage() {
184
+ return false;
185
+ }
186
+ }
187
+ ```
188
+
189
+ **Key insight:** Browsers don't have "processes" but they do have lifecycle events we can map to our interface. The user closing a tab is conceptually similar to SIGTERM.
190
+
191
+ ### EdgePlatformAdapter
192
+
193
+ Simple but effective for worker environments:
194
+
195
+ ```typescript
196
+ export class EdgePlatformAdapter extends BrowserPlatformAdapter {
197
+ // Workers have even fewer lifecycle guarantees
198
+ onShutdownSignal(handler) {
199
+ return () => {}; // No reliable shutdown signal
200
+ }
201
+ }
202
+ ```
203
+
204
+ **Design choice:** Edge workers are like browsers but even more constrained. We inherit most browser behavior but remove unreliable features.
205
+
206
+ ## The Universal Adapter: Our Secret Sauce
207
+
208
+ The `UniversalPlatformAdapter` is where things get really interesting. It's a **lazy-loading, runtime-detecting adapter**:
209
+
210
+ ```typescript
211
+ export class UniversalPlatformAdapter implements IPlatformAdapter {
212
+ private inner: IPlatformAdapter | null = null;
213
+
214
+ private get() {
215
+ if (!this.inner) {
216
+ const kind = detectEnvironment();
217
+ switch (kind) {
218
+ case "node":
219
+ this.inner = new NodePlatformAdapter();
220
+ break;
221
+ case "browser":
222
+ this.inner = new BrowserPlatformAdapter();
223
+ break;
224
+ case "edge":
225
+ this.inner = new EdgePlatformAdapter();
226
+ break;
227
+ default:
228
+ this.inner = new GenericUniversalPlatformAdapter();
229
+ }
230
+ }
231
+ return this.inner;
232
+ }
233
+
234
+ // Delegate everything to the detected adapter
235
+ onUncaughtException(handler) {
236
+ return this.get().onUncaughtException(handler);
237
+ }
238
+ // ... more delegation
239
+ }
240
+ ```
241
+
242
+ **Why lazy loading?** Environment detection might be expensive, and some code paths might never need platform features. We only detect when actually needed.
243
+
244
+ **Why delegation?** Once we know what runtime we're in, we can use the specialized adapter optimized for that environment.
245
+
246
+ ## Build-Time Optimization
247
+
248
+ Here's where it gets clever. We don't just detect at runtime - we also **optimize at build time** using different bundles:
249
+
250
+ ```typescript
251
+ // config/tsup/tsup.config.ts creates different bundles with __TARGET__ defined
252
+
253
+ // In factory.ts:
254
+ export function createPlatformAdapter(): IPlatformAdapter {
255
+ if (typeof __TARGET__ !== "undefined") {
256
+ switch (__TARGET__) {
257
+ case "node":
258
+ return new NodePlatformAdapter();
259
+ case "browser":
260
+ return new BrowserPlatformAdapter();
261
+ case "edge":
262
+ return new EdgePlatformAdapter();
263
+ }
264
+ }
265
+ return new UniversalPlatformAdapter();
266
+ }
267
+ ```
268
+
269
+ **The magic:** When you import from `@bluelibs/runner/node`, you get a bundle where `__TARGET__` is hardcoded to `"node"`. No runtime detection needed!
270
+
271
+ ### Package.json Exports
272
+
273
+ Our package.json shows the full strategy:
274
+
275
+ ```json
276
+ {
277
+ "exports": {
278
+ ".": {
279
+ "types": "./dist/types/index.d.ts",
280
+ "browser": {
281
+ "import": "./dist/browser/index.mjs",
282
+ "require": "./dist/browser/index.cjs",
283
+ "default": "./dist/browser/index.mjs"
284
+ },
285
+ "node": {
286
+ "types": "./dist/types/node/index.d.ts",
287
+ "import": "./dist/node/node.mjs",
288
+ "require": "./dist/node/node.cjs"
289
+ },
290
+ "import": "./dist/universal/index.mjs",
291
+ "require": "./dist/universal/index.cjs",
292
+ "default": "./dist/universal/index.mjs"
293
+ }
294
+ }
295
+ }
296
+ ```
297
+
298
+ **Result:** Node.js bundlers automatically get the Node-optimized bundle (and its Node-only type declarations) even when you import from `@bluelibs/runner`. The Node bundle re-exports the full public API and adds Node-only APIs, so `@bluelibs/runner` and `@bluelibs/runner/node` stay behaviorally aligned in Node runtimes. Browsers and universal runtimes continue to receive the appropriate builds with runtime detection fallback.
299
+
300
+ ## Backwards Compatibility
301
+
302
+ We didn't break existing code. The old `PlatformAdapter` class is now a wrapper:
303
+
304
+ ```typescript
305
+ export class PlatformAdapter implements IPlatformAdapter {
306
+ private inner: IPlatformAdapter;
307
+
308
+ constructor(env?: PlatformId) {
309
+ // Tests used to pass explicit environments
310
+ const kind = env ?? detectEnvironment();
311
+ switch (kind) {
312
+ case "node":
313
+ this.inner = new NodePlatformAdapter();
314
+ break;
315
+ // ...etc
316
+ }
317
+ }
318
+
319
+ // Delegate everything to the new adapters
320
+ onUncaughtException(handler) {
321
+ return this.inner.onUncaughtException(handler);
322
+ }
323
+ }
324
+ ```
325
+
326
+ **Design principle:** New code gets the benefits, old code keeps working.
327
+
328
+ ## Interesting Design Decisions
329
+
330
+ ### Why Not Feature Detection?
331
+
332
+ We could test `if (typeof process !== 'undefined')` everywhere, but that gets messy fast. The adapter pattern centralizes all environment-specific code.
333
+
334
+ ### Why Async Init?
335
+
336
+ Some platforms (like Node.js) need to dynamically import modules like `AsyncLocalStorage`. The `init()` method lets us handle this gracefully:
337
+
338
+ ```typescript
339
+ // In NodePlatformAdapter
340
+ async init() {
341
+ this.alsClass = await loadAsyncLocalStorageClass();
342
+ }
343
+ ```
344
+
345
+ ### Why Return Cleanup Functions?
346
+
347
+ Every event listener registration returns a cleanup function:
348
+
349
+ ```typescript
350
+ onUncaughtException(handler) {
351
+ process.on("uncaughtException", handler);
352
+ return () => process.off("uncaughtException", handler); // Clean cleanup.
353
+ }
354
+ ```
355
+
356
+ **Benefit:** No memory leaks, easy testing, and proper cleanup during shutdown.
357
+
358
+ ### The "Fail Gracefully" Philosophy
359
+
360
+ When a feature isn't available, we don't crash - we throw informative errors:
361
+
362
+ ```typescript
363
+ exit() {
364
+ throw new PlatformUnsupportedFunction("exit");
365
+ }
366
+ ```
367
+
368
+ This lets developers know _why_ something failed and what runtime features they're missing.
369
+
370
+ ## Real-World Benefits
371
+
372
+ ### For Library Authors
373
+
374
+ - Write once, run everywhere
375
+ - No runtime-specific imports in your main code
376
+ - Bundle optimizers can tree-shake unused platform code
377
+
378
+ ### For Application Developers
379
+
380
+ - Same API whether you're building for Node.js, browsers, or edge workers
381
+ - Gradual adoption - migrate one runtime at a time
382
+ - Testing is consistent across all platforms
383
+
384
+ ### For Framework Maintainers
385
+
386
+ - Single codebase to maintain
387
+ - Easy to add new runtime support
388
+ - Clear separation of concerns
389
+
390
+ ## Adding New Platforms
391
+
392
+ Want to support a new runtime? Just implement the interface:
393
+
394
+ ```typescript
395
+ export class DenoEdgePlatformAdapter implements IPlatformAdapter {
396
+ async init() {
397
+ // Deno-specific initialization
398
+ }
399
+
400
+ onUncaughtException(handler) {
401
+ // Use Deno's error handling
402
+ globalThis.addEventListener("error", handler);
403
+ return () => globalThis.removeEventListener("error", handler);
404
+ }
405
+
406
+ getEnv(key: string) {
407
+ return Deno.env.get(key);
408
+ }
409
+
410
+ // ... implement the rest
411
+ }
412
+ ```
413
+
414
+ Then add it to the detection logic and build config. That's it!
415
+
416
+ ## Conclusion
417
+
418
+ The multi-platform architecture might seem complex, but it solves a real problem: **JavaScript fragmentation**. By abstracting platform differences behind a clean interface, we can:
419
+
420
+ - Maintain a single codebase
421
+ - Provide runtime-optimized bundles
422
+ - Support new platforms easily
423
+ - Give developers a consistent experience
424
+
425
+ The key insight is that **dependency injection has universal concepts** (lifecycle, error handling, environment access) but **platform-specific implementations**. Our adapter pattern bridges this gap elegantly.
426
+
427
+ Next time you see code like `getPlatform().onShutdownSignal(handler)`, you'll know there's a sophisticated system making sure it works whether you're running in Node.js, a browser, or the next JavaScript runtime that gets invented!
428
+
429
+ _Happy coding!_
430
+
431
+ ## Testing & Coverage Strategy (What We Practiced)
432
+
433
+ Achieving reliable 100% coverage across Node, Browser, and Universal adapters requires a pragmatic test strategy. Here's what we do and why it works.
434
+
435
+ ### Environments and harnesses
436
+
437
+ - Node.js paths: run under the default Jest node environment.
438
+ - Browser paths: use `@jest-environment jsdom` on tests that need real DOM-like behavior (window/document events).
439
+ - Universal paths: exercise the delegating adapter and generic-universal adapter in plain node, simulating globals as needed.
440
+
441
+ ### Test the contract, not internals
442
+
443
+ - Always go through the public interface (`IPlatformAdapter`) and its methods:
444
+ - error/unhandledrejection listeners: register, trigger, dispose
445
+ - shutdown signals: beforeunload + visibilitychange
446
+ - environment access: getEnv fallbacks (`__ENV__`, `process.env`, `globalThis.env`)
447
+ - async context: positive in Node (ALS), negative in Browser/Universal generic
448
+ - timers: verify `setTimeout`/`clearTimeout` are exposed
449
+
450
+ ### Deterministic environment simulation
451
+
452
+ - Prefer surgical, reversible changes to `globalThis` over module patching:
453
+ - Temporarily set or delete `globalThis.window`, `globalThis.document`, `globalThis.addEventListener`, etc.
454
+ - Restore originals in `afterEach`/`finally` blocks to keep tests hermetic.
455
+ - For visibility changes, use a mocked document object with a controllable `visibilityState`.
456
+ - For error/unhandledrejection, capture listener functions via spies and invoke them with realistic event shapes:
457
+ - error: `{ error: Error }` or a bare value
458
+ - unhandledrejection: `{ reason: any }` or bare value
459
+
460
+ ### Cleanup-first philosophy
461
+
462
+ All registration methods return disposers. Tests should:
463
+
464
+ - Assert the listener is registered on the right target (window or globalThis fallback).
465
+ - Invoke the disposer and assert the correct removal call is issued.
466
+
467
+ ### Handling unreachable branches (coverage without hacks)
468
+
469
+ - Some branches exist for completeness but are unreachable by construction. Example:
470
+ - In `UniversalPlatformAdapter`, the `switch(kind === "browser")` path is effectively unreachable when the earlier `document/addEventListener` check holds true. We keep the branch to document intent, but exclude it from coverage with `` comments.
471
+ - Avoid brittle hacks like rewriting module source at runtime or deep prototype overrides to force coverage on unreachable paths.
472
+
473
+ ### Patterns we used in tests
474
+
475
+ - Browser beforeunload & visibilitychange
476
+ - Mock `window.addEventListener`/`removeEventListener` and stash handlers to invoke them synchronously.
477
+ - Provide a `document` mock with `visibilityState = "hidden"` to trigger the visibility handler.
478
+
479
+ - Error/unhandledrejection handling
480
+ - Register handlers via the adapter, then call captured listeners with shapes `{ error: Error }` or `{ reason: value }` to validate unwrapping paths.
481
+
482
+ - Timers exposure
483
+ - Call `adapter.setTimeout(fn, ms)` and immediately `adapter.clearTimeout(id)` to ensure bindings are wired and execute without throwing.
484
+
485
+ - AsyncLocalStorage
486
+ - Node: calling `createAsyncLocalStorage` before `init()` throws an informative error; after `init()`, `run/getStore` work via the loaded ALS class.
487
+ - Deno (universal path): when `AsyncLocalStorage` is exposed (global or `node:async_hooks` compat), `hasAsyncLocalStorage()` is true and contexts work.
488
+ - Browser/Universal generic (without Deno ALS): `hasAsyncLocalStorage()` is false, and `createAsyncLocalStorage().getStore/run` throw `PlatformUnsupportedFunction`.
489
+
490
+ ### Build-target tests and factory behavior
491
+
492
+ - The factory leverages a build-time `__TARGET__` constant to short-circuit detection and emit optimal bundles (`node`, `browser`, `edge`, `universal`).
493
+ - Tests validate that the factory yields the expected adapter per target and that delegation is intact.
494
+
495
+ ### What to avoid (brittleness checklist)
496
+
497
+ - Don't monkey-patch compiled source files or ESM internals to coerce a branch.
498
+ - Don't override class prototypes to force impossible switch cases.
499
+ - Don't rely on non-deterministic global timing or browser features not modeled by JSDOM.
500
+
501
+ ### CI and quality gates
502
+
503
+ - 100% global thresholds enforced (statements, branches, functions, lines).
504
+ - `npm run coverage` is the canonical command; it's fast and deterministic.
505
+ - If a branch is provably unreachable by design, prefer an `istanbul ignore` with a short comment rather than brittle tests.
506
+
507
+ ### Minimal examples
508
+
509
+ Error listener (browser):
510
+
511
+ ```ts
512
+ const adapter = new BrowserPlatformAdapter();
513
+ const listeners: Record<string, Function> = {};
514
+
515
+ (globalThis as any).window = {
516
+ addEventListener: (evt: string, fn: Function) => (listeners[evt] = fn),
517
+ removeEventListener: jest.fn(),
518
+ };
519
+
520
+ const spy = jest.fn();
521
+ const dispose = adapter.onUncaughtException(spy);
522
+
523
+ // triggers handler(e?.error ?? e)
524
+ listeners["error"]?.({ error: new Error("boom") });
525
+ dispose();
526
+ ```
527
+
528
+ Beforeunload + cleanup:
529
+
530
+ ```ts
531
+ const adapter = new BrowserPlatformAdapter();
532
+ const win = {
533
+ addEventListener: jest.fn(),
534
+ removeEventListener: jest.fn(),
535
+ } as any;
536
+ (globalThis as any).window = win;
537
+
538
+ const handler = jest.fn();
539
+ const dispose = adapter.onShutdownSignal(handler);
540
+
541
+ const before = win.addEventListener.mock.calls.find(
542
+ (c: any[]) => c[0] === "beforeunload",
543
+ )?.[1];
544
+ before?.();
545
+ expect(handler).toHaveBeenCalled();
546
+ dispose();
547
+ ```
548
+
549
+ Universal adapter note (coverage):
550
+
551
+ ```ts
552
+ // The "browser" case inside the switch is kept for completeness but marked:
553
+ // istanbul ignore next — unreachable when document/addEventListener are present
554
+ ```
555
+
556
+ With these practices, we maintain a stable, high-fidelity test suite that reflects real platform behavior while still achieving strict 100% coverage.