@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.
- package/.agents/skills/core/SKILL.md +37 -0
- package/.agents/skills/durable-workflows/SKILL.md +117 -0
- package/.agents/skills/remote-lanes/SKILL.md +114 -0
- package/package.json +1 -11
- package/readmes/BENCHMARKS.md +131 -0
- package/readmes/COMPARISON.md +233 -0
- package/readmes/CRITICAL_THINKING.md +49 -0
- package/readmes/DURABLE_WORKFLOWS.md +2270 -0
- package/readmes/DURABLE_WORKFLOWS_AI.md +258 -0
- package/readmes/ENTERPRISE.md +219 -0
- package/readmes/FLUENT_BUILDERS.md +524 -0
- package/readmes/FULL_GUIDE.md +6694 -0
- package/readmes/FUNCTIONAL.md +350 -0
- package/readmes/MULTI_PLATFORM.md +556 -0
- package/readmes/OOP.md +627 -0
- package/readmes/REMOTE_LANES.md +947 -0
- package/readmes/REMOTE_LANES_AI.md +154 -0
- package/readmes/REMOTE_LANES_HTTP_POLICY.md +330 -0
- package/readmes/SERIALIZER_PROTOCOL.md +337 -0
|
@@ -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.
|