als-browser 1.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.
@@ -0,0 +1,111 @@
1
+ import { AsyncContextFrame } from "./async-context-frame";
2
+
3
+ /**
4
+ * @property {any} [defaultValue] - The default value to use when no value is set.
5
+ * @property {string} [name] - The name of the storage.
6
+ */
7
+ export interface AsyncLocalStorageOptions<T> {
8
+ defaultValue?: T;
9
+ name?: string;
10
+ }
11
+
12
+ /**
13
+ * Browser-compatible implementation of Node.js AsyncLocalStorage.
14
+ * Provides context isolation across async operations.
15
+ */
16
+ export class AsyncLocalStorage<T> {
17
+ #defaultValue: T | undefined;
18
+ #name: string | undefined;
19
+
20
+ /**
21
+ * @param {AsyncLocalStorageOptions} [options]
22
+ */
23
+ constructor(options: AsyncLocalStorageOptions<T> = {}) {
24
+ this.#defaultValue = options.defaultValue;
25
+ this.#name = options.name;
26
+ }
27
+
28
+ /** @type {string} */
29
+ get name(): string {
30
+ return this.#name || "";
31
+ }
32
+
33
+ /**
34
+ * Bind a function to the current async context.
35
+ * The returned function will restore the captured context when called.
36
+ */
37
+ static bind<F extends (...args: any[]) => any>(fn: F): F {
38
+ const frame = AsyncContextFrame.current();
39
+ return function (this: any, ...args: any[]) {
40
+ const prior = AsyncContextFrame.exchange(frame);
41
+ try {
42
+ return fn.apply(this, args);
43
+ } finally {
44
+ AsyncContextFrame.set(prior);
45
+ }
46
+ } as F;
47
+ }
48
+
49
+ /**
50
+ * Capture the current async context and return a function that
51
+ * can restore it when running a callback.
52
+ */
53
+ static snapshot(): <R>(fn: (...args: any[]) => R, ...args: any[]) => R {
54
+ return AsyncLocalStorage.bind((cb, ...args) => cb(...args));
55
+ }
56
+
57
+ /**
58
+ * Remove this store from the current async context.
59
+ */
60
+ disable(): void {
61
+ AsyncContextFrame.disable(this);
62
+ }
63
+
64
+ /**
65
+ * Enter a new async context with the given data.
66
+ * Unlike run(), this doesn't use a callback - the context persists
67
+ * until changed by another enterWith() or run() call.
68
+ */
69
+ enterWith(data: T | undefined): void {
70
+ const frame = new AsyncContextFrame(this, data);
71
+ AsyncContextFrame.set(frame);
72
+ }
73
+
74
+ /**
75
+ * Run a function in a new async context with the given data.
76
+ * The context is automatically restored after the function completes.
77
+ */
78
+ run<R>(data: T, fn: (...args: any[]) => R, ...args: any[]): R {
79
+ const prior = this.getStore();
80
+
81
+ if (Object.is(prior, data)) {
82
+ return Reflect.apply(fn, null, args);
83
+ }
84
+
85
+ this.enterWith(data);
86
+ try {
87
+ return Reflect.apply(fn, null, args);
88
+ } finally {
89
+ this.enterWith(prior);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Run a function with the store value set to undefined.
95
+ */
96
+ exit<R>(fn: (...args: any[]) => R, ...args: any[]): R {
97
+ return this.run(undefined as T, fn, ...args);
98
+ }
99
+
100
+ /**
101
+ * Get the current value from this store.
102
+ * Returns the default value if no value is set in the current context.
103
+ */
104
+ getStore(): T | undefined {
105
+ const frame = AsyncContextFrame.current();
106
+ if (!frame?.has(this)) {
107
+ return this.#defaultValue;
108
+ }
109
+ return frame.get(this);
110
+ }
111
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export { AsyncLocalStorage } from "./async-local-storage";
2
+ export type { AsyncLocalStorageOptions } from "./async-local-storage";
3
+ export { capture, restore } from "./snapshot";
4
+ export type { SnapshotContainer } from "./snapshot";
5
+
6
+ // Auto-patch on import
7
+ import { patchAll } from "./patches";
8
+ patchAll();
@@ -0,0 +1,482 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { AsyncLocalStorage } from "../async-local-storage";
3
+ import { AsyncContextFrame } from "../async-context-frame";
4
+ import { patchEventTarget } from "./event-target";
5
+
6
+ describe("EventTarget patch", () => {
7
+ let als: AsyncLocalStorage<number>;
8
+
9
+ beforeEach(() => {
10
+ AsyncContextFrame.set(undefined);
11
+ als = new AsyncLocalStorage<number>();
12
+ patchEventTarget();
13
+ });
14
+
15
+ describe("addEventListener with function listener", () => {
16
+ it("should preserve context through addEventListener", async () => {
17
+ const target = new EventTarget();
18
+
19
+ const promise = new Promise<number | undefined>((resolve) => {
20
+ als.run(100, () => {
21
+ target.addEventListener("test", () => {
22
+ resolve(als.getStore());
23
+ });
24
+
25
+ target.dispatchEvent(new Event("test"));
26
+ });
27
+ });
28
+
29
+ const result = await promise;
30
+ expect(result).toBe(100);
31
+ });
32
+
33
+ it("should preserve context across multiple events", async () => {
34
+ const target = new EventTarget();
35
+ const results: (number | undefined)[] = [];
36
+
37
+ const promise = new Promise<void>((resolve) => {
38
+ als.run(200, () => {
39
+ let count = 0;
40
+ target.addEventListener("test", () => {
41
+ results.push(als.getStore());
42
+ count++;
43
+ if (count >= 3) {
44
+ resolve();
45
+ }
46
+ });
47
+
48
+ target.dispatchEvent(new Event("test"));
49
+ target.dispatchEvent(new Event("test"));
50
+ target.dispatchEvent(new Event("test"));
51
+ });
52
+ });
53
+
54
+ await promise;
55
+ expect(results).toEqual([200, 200, 200]);
56
+ });
57
+
58
+ it("should preserve context when event is dispatched later", async () => {
59
+ const target = new EventTarget();
60
+
61
+ const promise = new Promise<number | undefined>((resolve) => {
62
+ als.run(300, () => {
63
+ target.addEventListener("test", () => {
64
+ resolve(als.getStore());
65
+ });
66
+ });
67
+
68
+ // Dispatch event outside of run context
69
+ setTimeout(() => {
70
+ target.dispatchEvent(new Event("test"));
71
+ }, 10);
72
+ });
73
+
74
+ const result = await promise;
75
+ expect(result).toBe(300);
76
+ });
77
+
78
+ it("should isolate contexts for different event listeners", async () => {
79
+ const target = new EventTarget();
80
+ const results: number[] = [];
81
+
82
+ const promise = new Promise<void>((resolve) => {
83
+ let count = 0;
84
+
85
+ als.run(400, () => {
86
+ target.addEventListener("test", () => {
87
+ results.push(als.getStore()!);
88
+ count++;
89
+ if (count >= 2) resolve();
90
+ });
91
+ });
92
+
93
+ als.run(500, () => {
94
+ target.addEventListener("test", () => {
95
+ results.push(als.getStore()!);
96
+ count++;
97
+ if (count >= 2) resolve();
98
+ });
99
+ });
100
+
101
+ target.dispatchEvent(new Event("test"));
102
+ });
103
+
104
+ await promise;
105
+ expect(results.sort()).toEqual([400, 500]);
106
+ });
107
+
108
+ it("should handle multiple listeners for the same event", async () => {
109
+ const target = new EventTarget();
110
+ const results: number[] = [];
111
+
112
+ const promise = new Promise<void>((resolve) => {
113
+ let callCount = 0;
114
+ als.run(600, () => {
115
+ target.addEventListener("test", () => {
116
+ results.push(als.getStore()!);
117
+ callCount++;
118
+ if (callCount === 2) resolve();
119
+ });
120
+
121
+ target.addEventListener("test", () => {
122
+ results.push(als.getStore()! + 1000);
123
+ callCount++;
124
+ if (callCount === 2) resolve();
125
+ });
126
+
127
+ target.dispatchEvent(new Event("test"));
128
+ });
129
+ });
130
+
131
+ await promise;
132
+ expect(results.length).toBe(2);
133
+ expect(results).toContain(600);
134
+ expect(results).toContain(1600);
135
+ });
136
+
137
+ it("should pass event object to listener", async () => {
138
+ const target = new EventTarget();
139
+
140
+ const promise = new Promise<{
141
+ type: string;
142
+ target: EventTarget | null;
143
+ store: number | undefined;
144
+ }>((resolve) => {
145
+ als.run(700, () => {
146
+ target.addEventListener("custom", (event) => {
147
+ resolve({
148
+ type: event.type,
149
+ target: event.target,
150
+ store: als.getStore(),
151
+ });
152
+ });
153
+
154
+ target.dispatchEvent(new Event("custom"));
155
+ });
156
+ });
157
+
158
+ const result = await promise;
159
+ expect(result.type).toBe("custom");
160
+ expect(result.target).toBe(target);
161
+ expect(result.store).toBe(700);
162
+ });
163
+
164
+ it("should handle event listener with options", async () => {
165
+ const target = new EventTarget();
166
+ let callCount = 0;
167
+
168
+ const promise = new Promise<void>((resolve) => {
169
+ als.run(800, () => {
170
+ target.addEventListener(
171
+ "test",
172
+ () => {
173
+ callCount++;
174
+ expect(als.getStore()).toBe(800);
175
+ if (callCount === 1) {
176
+ resolve();
177
+ }
178
+ },
179
+ { once: true }
180
+ );
181
+
182
+ target.dispatchEvent(new Event("test"));
183
+ target.dispatchEvent(new Event("test"));
184
+ });
185
+ });
186
+
187
+ await promise;
188
+ expect(callCount).toBe(1);
189
+ });
190
+
191
+ it("should handle capture phase events", async () => {
192
+ if (typeof document === "undefined") {
193
+ return; // Skip in non-browser environment
194
+ }
195
+
196
+ const parent = document.createElement("div");
197
+ const child = document.createElement("span");
198
+ parent.appendChild(child);
199
+
200
+ const sequence: string[] = [];
201
+
202
+ const promise = new Promise<void>((resolve) => {
203
+ als.run(900, () => {
204
+ parent.addEventListener(
205
+ "test",
206
+ () => {
207
+ sequence.push(`parent-capture:${als.getStore()}`);
208
+ },
209
+ { capture: true }
210
+ );
211
+
212
+ child.addEventListener("test", () => {
213
+ sequence.push(`child:${als.getStore()}`);
214
+ });
215
+
216
+ parent.addEventListener("test", () => {
217
+ sequence.push(`parent-bubble:${als.getStore()}`);
218
+ resolve();
219
+ });
220
+
221
+ child.dispatchEvent(new Event("test", { bubbles: true }));
222
+ });
223
+ });
224
+
225
+ await promise;
226
+ expect(sequence).toEqual([
227
+ "parent-capture:900",
228
+ "child:900",
229
+ "parent-bubble:900",
230
+ ]);
231
+ });
232
+ });
233
+
234
+ describe("addEventListener with EventListenerObject", () => {
235
+ it("should preserve context with EventListenerObject", async () => {
236
+ const target = new EventTarget();
237
+
238
+ const promise = new Promise<number | undefined>((resolve) => {
239
+ als.run(1000, () => {
240
+ const listener = {
241
+ handleEvent() {
242
+ resolve(als.getStore());
243
+ },
244
+ };
245
+
246
+ target.addEventListener("test", listener);
247
+ target.dispatchEvent(new Event("test"));
248
+ });
249
+ });
250
+
251
+ const result = await promise;
252
+ expect(result).toBe(1000);
253
+ });
254
+
255
+ it("should preserve 'this' binding in handleEvent", async () => {
256
+ const target = new EventTarget();
257
+
258
+ const promise = new Promise<{
259
+ thisValue: any;
260
+ store: number | undefined;
261
+ }>((resolve) => {
262
+ als.run(1100, () => {
263
+ const listener = {
264
+ value: 42,
265
+ handleEvent(this: any) {
266
+ resolve({
267
+ thisValue: this.value,
268
+ store: als.getStore(),
269
+ });
270
+ },
271
+ };
272
+
273
+ target.addEventListener("test", listener);
274
+ target.dispatchEvent(new Event("test"));
275
+ });
276
+ });
277
+
278
+ const result = await promise;
279
+ expect(result.thisValue).toBe(42);
280
+ expect(result.store).toBe(1100);
281
+ });
282
+
283
+ it("should pass event to handleEvent", async () => {
284
+ const target = new EventTarget();
285
+
286
+ const promise = new Promise<{
287
+ eventType: string;
288
+ store: number | undefined;
289
+ }>((resolve) => {
290
+ als.run(1200, () => {
291
+ const listener = {
292
+ handleEvent(event: Event) {
293
+ resolve({
294
+ eventType: event.type,
295
+ store: als.getStore(),
296
+ });
297
+ },
298
+ };
299
+
300
+ target.addEventListener("custom-event", listener);
301
+ target.dispatchEvent(new Event("custom-event"));
302
+ });
303
+ });
304
+
305
+ const result = await promise;
306
+ expect(result.eventType).toBe("custom-event");
307
+ expect(result.store).toBe(1200);
308
+ });
309
+ });
310
+
311
+ describe("removeEventListener", () => {
312
+ it("should support removeEventListener with bound listeners", () => {
313
+ const target = new EventTarget();
314
+ let callCount = 0;
315
+
316
+ als.run(1300, () => {
317
+ const listener = () => {
318
+ callCount++;
319
+ expect(als.getStore()).toBe(1300);
320
+ };
321
+
322
+ target.addEventListener("test", listener);
323
+ target.dispatchEvent(new Event("test"));
324
+ expect(callCount).toBe(1);
325
+
326
+ target.removeEventListener("test", listener);
327
+ target.dispatchEvent(new Event("test"));
328
+ expect(callCount).toBe(1); // Should not increase
329
+ });
330
+ });
331
+
332
+ it("should support removeEventListener with EventListenerObject", () => {
333
+ const target = new EventTarget();
334
+ let callCount = 0;
335
+
336
+ als.run(1400, () => {
337
+ const listener = {
338
+ handleEvent() {
339
+ callCount++;
340
+ expect(als.getStore()).toBe(1400);
341
+ },
342
+ };
343
+
344
+ target.addEventListener("test", listener);
345
+ target.dispatchEvent(new Event("test"));
346
+ expect(callCount).toBe(1);
347
+
348
+ target.removeEventListener("test", listener);
349
+ target.dispatchEvent(new Event("test"));
350
+ expect(callCount).toBe(1); // Should not increase
351
+ });
352
+ });
353
+
354
+ it("should handle removeEventListener before any events are dispatched", () => {
355
+ const target = new EventTarget();
356
+ let callCount = 0;
357
+
358
+ als.run(1500, () => {
359
+ const listener = () => {
360
+ callCount++;
361
+ };
362
+
363
+ target.addEventListener("test", listener);
364
+ target.removeEventListener("test", listener);
365
+ target.dispatchEvent(new Event("test"));
366
+ expect(callCount).toBe(0);
367
+ });
368
+ });
369
+
370
+ it("should only remove the specific listener", () => {
371
+ const target = new EventTarget();
372
+ const calls: string[] = [];
373
+
374
+ als.run(1600, () => {
375
+ const listener1 = () => {
376
+ calls.push("listener1");
377
+ };
378
+ const listener2 = () => {
379
+ calls.push("listener2");
380
+ };
381
+
382
+ target.addEventListener("test", listener1);
383
+ target.addEventListener("test", listener2);
384
+
385
+ target.dispatchEvent(new Event("test"));
386
+ expect(calls).toEqual(["listener1", "listener2"]);
387
+
388
+ calls.length = 0;
389
+ target.removeEventListener("test", listener1);
390
+ target.dispatchEvent(new Event("test"));
391
+ expect(calls).toEqual(["listener2"]);
392
+ });
393
+ });
394
+ });
395
+
396
+ describe("DOM elements (if available)", () => {
397
+ it("should preserve context with DOM element events", async () => {
398
+ if (typeof document === "undefined") {
399
+ return; // Skip in non-browser environment
400
+ }
401
+
402
+ const button = document.createElement("button");
403
+ document.body.appendChild(button);
404
+
405
+ const promise = new Promise<number | undefined>((resolve) => {
406
+ als.run(1700, () => {
407
+ button.addEventListener("click", () => {
408
+ resolve(als.getStore());
409
+ });
410
+
411
+ button.click();
412
+ });
413
+ });
414
+
415
+ const result = await promise;
416
+ expect(result).toBe(1700);
417
+
418
+ document.body.removeChild(button);
419
+ });
420
+
421
+ it("should preserve context with custom events", async () => {
422
+ if (typeof document === "undefined") {
423
+ return; // Skip in non-browser environment
424
+ }
425
+
426
+ const element = document.createElement("div");
427
+
428
+ const promise = new Promise<{
429
+ detail: any;
430
+ store: number | undefined;
431
+ }>((resolve) => {
432
+ als.run(1800, () => {
433
+ element.addEventListener("custom", ((event: CustomEvent) => {
434
+ resolve({
435
+ detail: event.detail,
436
+ store: als.getStore(),
437
+ });
438
+ }) as EventListener);
439
+
440
+ element.dispatchEvent(
441
+ new CustomEvent("custom", { detail: { foo: "bar" } })
442
+ );
443
+ });
444
+ });
445
+
446
+ const result = await promise;
447
+ expect(result.detail).toEqual({ foo: "bar" });
448
+ expect(result.store).toBe(1800);
449
+ });
450
+ });
451
+
452
+ describe("null and undefined listeners", () => {
453
+ it("should pass null listener to original implementation", () => {
454
+ const target = new EventTarget();
455
+
456
+ // Should pass null through without modification
457
+ als.run(1900, () => {
458
+ target.addEventListener("test", null);
459
+ // We don't dispatch because behavior is environment-specific
460
+ });
461
+ });
462
+
463
+ it("should pass undefined listener to original implementation", () => {
464
+ const target = new EventTarget();
465
+
466
+ // Should pass undefined through without modification
467
+ als.run(2000, () => {
468
+ target.addEventListener("test", undefined as any);
469
+ // We don't dispatch because behavior is environment-specific
470
+ });
471
+ });
472
+
473
+ it("should handle removeEventListener with null", () => {
474
+ const target = new EventTarget();
475
+
476
+ // This should not throw
477
+ als.run(2100, () => {
478
+ target.removeEventListener("test", null);
479
+ });
480
+ });
481
+ });
482
+ });
@@ -0,0 +1,100 @@
1
+ import { AsyncLocalStorage } from "../async-local-storage";
2
+ import { patch, unpatch } from "./patch-helper";
3
+
4
+ // WeakMap to track original listeners so we can remove them properly
5
+ const listenerMap = new WeakMap<
6
+ EventTarget,
7
+ Map<EventListenerOrEventListenerObject, EventListenerOrEventListenerObject>
8
+ >();
9
+
10
+ /**
11
+ * Patch EventTarget.addEventListener to preserve async context.
12
+ * This ensures that event listeners maintain their async context when they execute.
13
+ *
14
+ * This single patch covers all EventTarget-based APIs including:
15
+ * - DOM events (Element, Document, Window)
16
+ * - WebSocket events
17
+ * - MessagePort events
18
+ * - BroadcastChannel events
19
+ * - EventSource (SSE) events
20
+ * - FileReader events
21
+ * - XMLHttpRequest events
22
+ * - IndexedDB request events
23
+ * - And many more...
24
+ */
25
+ export function patchEventTarget(): void {
26
+ patch(
27
+ EventTarget.prototype as any,
28
+ "addEventListener",
29
+ (original: typeof EventTarget.prototype.addEventListener) => {
30
+ return function (
31
+ this: EventTarget,
32
+ type: string,
33
+ listener: EventListenerOrEventListenerObject | null,
34
+ options?: boolean | AddEventListenerOptions
35
+ ) {
36
+ if (!listener) {
37
+ return original.call(this, type, listener, options);
38
+ }
39
+
40
+ // Bind the listener to preserve async context
41
+ const bound =
42
+ typeof listener === "function"
43
+ ? AsyncLocalStorage.bind(listener)
44
+ : {
45
+ handleEvent: AsyncLocalStorage.bind(
46
+ listener.handleEvent.bind(listener)
47
+ ),
48
+ };
49
+
50
+ // Store the mapping from original to bound listener for removeEventListener
51
+ if (!listenerMap.has(this)) {
52
+ listenerMap.set(this, new Map());
53
+ }
54
+ listenerMap.get(this)!.set(listener, bound);
55
+
56
+ return original.call(this, type, bound, options);
57
+ };
58
+ }
59
+ );
60
+
61
+ patch(
62
+ EventTarget.prototype as any,
63
+ "removeEventListener",
64
+ (original: typeof EventTarget.prototype.removeEventListener) => {
65
+ return function (
66
+ this: EventTarget,
67
+ type: string,
68
+ listener: EventListenerOrEventListenerObject | null,
69
+ options?: boolean | EventListenerOptions
70
+ ) {
71
+ if (!listener) {
72
+ return original.call(this, type, listener, options);
73
+ }
74
+
75
+ // Look up the bound listener that we registered
76
+ const map = listenerMap.get(this);
77
+ const bound = map?.get(listener);
78
+
79
+ if (bound) {
80
+ // Remove the bound listener
81
+ const result = original.call(this, type, bound, options);
82
+ // Clean up the mapping
83
+ map!.delete(listener);
84
+ return result;
85
+ }
86
+
87
+ // Fallback to the original listener if we don't have a mapping
88
+ return original.call(this, type, listener, options);
89
+ };
90
+ }
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Remove EventTarget patches, restoring original behavior.
96
+ */
97
+ export function unpatchEventTarget(): void {
98
+ unpatch(EventTarget.prototype as any, "addEventListener");
99
+ unpatch(EventTarget.prototype as any, "removeEventListener");
100
+ }