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.
- package/.claude/settings.local.json +10 -0
- package/README.md +257 -0
- package/package.json +40 -0
- package/src/async-context-frame.ts +53 -0
- package/src/async-local-storage.test.ts +264 -0
- package/src/async-local-storage.ts +111 -0
- package/src/index.ts +8 -0
- package/src/patches/event-target.test.ts +482 -0
- package/src/patches/event-target.ts +100 -0
- package/src/patches/index.ts +57 -0
- package/src/patches/microtasks.test.ts +232 -0
- package/src/patches/microtasks.ts +31 -0
- package/src/patches/observers.test.ts +594 -0
- package/src/patches/observers.ts +112 -0
- package/src/patches/patch-helper.ts +73 -0
- package/src/patches/promise.test.ts +355 -0
- package/src/patches/promise.ts +83 -0
- package/src/patches/timers.test.ts +321 -0
- package/src/patches/timers.ts +109 -0
- package/src/patches/xhr.test.ts +81 -0
- package/src/patches/xhr.ts +58 -0
- package/src/snapshot.test.ts +66 -0
- package/src/snapshot.ts +38 -0
- package/tsconfig.json +19 -0
- package/tsup.config.ts +12 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { AsyncLocalStorage } from "../async-local-storage";
|
|
3
|
+
import { AsyncContextFrame } from "../async-context-frame";
|
|
4
|
+
import { patchTimers } from "./timers";
|
|
5
|
+
|
|
6
|
+
describe("Timers patch", () => {
|
|
7
|
+
let als: AsyncLocalStorage<number>;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
AsyncContextFrame.set(undefined);
|
|
11
|
+
als = new AsyncLocalStorage<number>();
|
|
12
|
+
patchTimers();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("setTimeout", () => {
|
|
16
|
+
it("should preserve context through setTimeout", async () => {
|
|
17
|
+
const promise = new Promise<number | undefined>((resolve) => {
|
|
18
|
+
als.run(100, () => {
|
|
19
|
+
setTimeout(() => {
|
|
20
|
+
resolve(als.getStore());
|
|
21
|
+
}, 10);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const result = await promise;
|
|
26
|
+
expect(result).toBe(100);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should preserve context through chained setTimeout calls", async () => {
|
|
30
|
+
const sequence: number[] = [];
|
|
31
|
+
|
|
32
|
+
const promise = new Promise<void>((resolve) => {
|
|
33
|
+
als.run(200, () => {
|
|
34
|
+
sequence.push(als.getStore()!);
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
sequence.push(als.getStore()!);
|
|
37
|
+
setTimeout(() => {
|
|
38
|
+
sequence.push(als.getStore()!);
|
|
39
|
+
resolve();
|
|
40
|
+
}, 10);
|
|
41
|
+
}, 10);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await promise;
|
|
46
|
+
expect(sequence).toEqual([200, 200, 200]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should isolate contexts in parallel setTimeout calls", async () => {
|
|
50
|
+
const results: number[] = [];
|
|
51
|
+
|
|
52
|
+
const promises = [1, 2, 3].map((value) => {
|
|
53
|
+
return new Promise<void>((resolve) => {
|
|
54
|
+
als.run(value, () => {
|
|
55
|
+
setTimeout(() => {
|
|
56
|
+
results.push(als.getStore()!);
|
|
57
|
+
resolve();
|
|
58
|
+
}, 10);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await Promise.all(promises);
|
|
64
|
+
expect(results.sort()).toEqual([1, 2, 3]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should pass arguments to setTimeout callback", async () => {
|
|
68
|
+
const promise = new Promise<{ args: any[]; store: number | undefined }>(
|
|
69
|
+
(resolve) => {
|
|
70
|
+
als.run(300, () => {
|
|
71
|
+
setTimeout(
|
|
72
|
+
(a: number, b: string) => {
|
|
73
|
+
resolve({ args: [a, b], store: als.getStore() });
|
|
74
|
+
},
|
|
75
|
+
10,
|
|
76
|
+
42,
|
|
77
|
+
"test"
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const result = await promise;
|
|
84
|
+
expect(result).toEqual({ args: [42, "test"], store: 300 });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("setInterval", () => {
|
|
90
|
+
it("should preserve context through setInterval", async () => {
|
|
91
|
+
const values: (number | undefined)[] = [];
|
|
92
|
+
|
|
93
|
+
const promise = new Promise<void>((resolve) => {
|
|
94
|
+
als.run(500, () => {
|
|
95
|
+
let count = 0;
|
|
96
|
+
const intervalId = setInterval(() => {
|
|
97
|
+
values.push(als.getStore());
|
|
98
|
+
count++;
|
|
99
|
+
if (count >= 3) {
|
|
100
|
+
clearInterval(intervalId);
|
|
101
|
+
resolve();
|
|
102
|
+
}
|
|
103
|
+
}, 10);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await promise;
|
|
108
|
+
expect(values).toEqual([500, 500, 500]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("should maintain same context across interval iterations", async () => {
|
|
112
|
+
const values: number[] = [];
|
|
113
|
+
|
|
114
|
+
const promise = new Promise<void>((resolve) => {
|
|
115
|
+
als.run(600, () => {
|
|
116
|
+
let count = 0;
|
|
117
|
+
const intervalId = setInterval(() => {
|
|
118
|
+
values.push(als.getStore()!);
|
|
119
|
+
count++;
|
|
120
|
+
if (count >= 5) {
|
|
121
|
+
clearInterval(intervalId);
|
|
122
|
+
resolve();
|
|
123
|
+
}
|
|
124
|
+
}, 5);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await promise;
|
|
129
|
+
expect(values).toEqual([600, 600, 600, 600, 600]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should pass arguments to setInterval callback", async () => {
|
|
133
|
+
const promise = new Promise<{ args: any[]; store: number | undefined }>(
|
|
134
|
+
(resolve) => {
|
|
135
|
+
als.run(700, () => {
|
|
136
|
+
const intervalId = setInterval(
|
|
137
|
+
(a: number, b: string) => {
|
|
138
|
+
clearInterval(intervalId);
|
|
139
|
+
resolve({ args: [a, b], store: als.getStore() });
|
|
140
|
+
},
|
|
141
|
+
10,
|
|
142
|
+
99,
|
|
143
|
+
"interval"
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const result = await promise;
|
|
150
|
+
expect(result).toEqual({ args: [99, "interval"], store: 700 });
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("requestAnimationFrame", () => {
|
|
155
|
+
it("should preserve context through requestAnimationFrame", async () => {
|
|
156
|
+
if (!globalThis.requestAnimationFrame) {
|
|
157
|
+
return; // Skip if not available
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const promise = new Promise<number | undefined>((resolve) => {
|
|
161
|
+
als.run(800, () => {
|
|
162
|
+
requestAnimationFrame(() => {
|
|
163
|
+
resolve(als.getStore());
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const result = await promise;
|
|
169
|
+
expect(result).toBe(800);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("should preserve context through chained requestAnimationFrame calls", async () => {
|
|
173
|
+
if (!globalThis.requestAnimationFrame) {
|
|
174
|
+
return; // Skip if not available
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const sequence: number[] = [];
|
|
178
|
+
|
|
179
|
+
const promise = new Promise<void>((resolve) => {
|
|
180
|
+
als.run(900, () => {
|
|
181
|
+
sequence.push(als.getStore()!);
|
|
182
|
+
requestAnimationFrame(() => {
|
|
183
|
+
sequence.push(als.getStore()!);
|
|
184
|
+
requestAnimationFrame(() => {
|
|
185
|
+
sequence.push(als.getStore()!);
|
|
186
|
+
resolve();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await promise;
|
|
193
|
+
expect(sequence).toEqual([900, 900, 900]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("should receive timestamp parameter", async () => {
|
|
197
|
+
if (!globalThis.requestAnimationFrame) {
|
|
198
|
+
return; // Skip if not available
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const promise = new Promise<{ timestamp: number; store: number | undefined }>(
|
|
202
|
+
(resolve) => {
|
|
203
|
+
als.run(1000, () => {
|
|
204
|
+
requestAnimationFrame((timestamp) => {
|
|
205
|
+
resolve({ timestamp, store: als.getStore() });
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const result = await promise;
|
|
212
|
+
expect(result.store).toBe(1000);
|
|
213
|
+
expect(typeof result.timestamp).toBe("number");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("requestIdleCallback", () => {
|
|
218
|
+
it("should preserve context through requestIdleCallback", async () => {
|
|
219
|
+
if (!globalThis.requestIdleCallback) {
|
|
220
|
+
return; // Skip if not available
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const promise = new Promise<number | undefined>((resolve) => {
|
|
224
|
+
als.run(1100, () => {
|
|
225
|
+
requestIdleCallback(() => {
|
|
226
|
+
resolve(als.getStore());
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const result = await promise;
|
|
232
|
+
expect(result).toBe(1100);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("should preserve context with timeout option", async () => {
|
|
236
|
+
if (!globalThis.requestIdleCallback) {
|
|
237
|
+
return; // Skip if not available
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const promise = new Promise<number | undefined>((resolve) => {
|
|
241
|
+
als.run(1200, () => {
|
|
242
|
+
requestIdleCallback(
|
|
243
|
+
() => {
|
|
244
|
+
resolve(als.getStore());
|
|
245
|
+
},
|
|
246
|
+
{ timeout: 100 }
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const result = await promise;
|
|
252
|
+
expect(result).toBe(1200);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("should receive deadline parameter", async () => {
|
|
256
|
+
if (!globalThis.requestIdleCallback) {
|
|
257
|
+
return; // Skip if not available
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const promise = new Promise<{
|
|
261
|
+
hasDeadline: boolean;
|
|
262
|
+
store: number | undefined;
|
|
263
|
+
}>((resolve) => {
|
|
264
|
+
als.run(1300, () => {
|
|
265
|
+
requestIdleCallback((deadline) => {
|
|
266
|
+
resolve({
|
|
267
|
+
hasDeadline: deadline !== undefined,
|
|
268
|
+
store: als.getStore(),
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const result = await promise;
|
|
275
|
+
expect(result.store).toBe(1300);
|
|
276
|
+
expect(result.hasDeadline).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("setImmediate", () => {
|
|
281
|
+
it("should preserve context through setImmediate if available", async () => {
|
|
282
|
+
if (!(globalThis as any).setImmediate) {
|
|
283
|
+
return; // Skip if not available
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const promise = new Promise<number | undefined>((resolve) => {
|
|
287
|
+
als.run(1400, () => {
|
|
288
|
+
(globalThis as any).setImmediate(() => {
|
|
289
|
+
resolve(als.getStore());
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const result = await promise;
|
|
295
|
+
expect(result).toBe(1400);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("should pass arguments to setImmediate callback if available", async () => {
|
|
299
|
+
if (!(globalThis as any).setImmediate) {
|
|
300
|
+
return; // Skip if not available
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const promise = new Promise<{ args: any[]; store: number | undefined }>(
|
|
304
|
+
(resolve) => {
|
|
305
|
+
als.run(1500, () => {
|
|
306
|
+
(globalThis as any).setImmediate(
|
|
307
|
+
(a: number, b: string) => {
|
|
308
|
+
resolve({ args: [a, b], store: als.getStore() });
|
|
309
|
+
},
|
|
310
|
+
77,
|
|
311
|
+
"immediate"
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const result = await promise;
|
|
318
|
+
expect(result).toEqual({ args: [77, "immediate"], store: 1500 });
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "../async-local-storage";
|
|
2
|
+
import { patch, unpatch } from "./patch-helper";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Patch timer functions to preserve async context.
|
|
6
|
+
* This ensures that callbacks scheduled via setTimeout, setInterval, etc.
|
|
7
|
+
* maintain their async context when they execute.
|
|
8
|
+
*/
|
|
9
|
+
export function patchTimers(): void {
|
|
10
|
+
// Patch setTimeout
|
|
11
|
+
patch(
|
|
12
|
+
globalThis as any,
|
|
13
|
+
"setTimeout",
|
|
14
|
+
(original: typeof setTimeout) => {
|
|
15
|
+
return function (
|
|
16
|
+
callback: TimerHandler,
|
|
17
|
+
delay?: number,
|
|
18
|
+
...args: any[]
|
|
19
|
+
) {
|
|
20
|
+
const bound =
|
|
21
|
+
typeof callback === "function"
|
|
22
|
+
? AsyncLocalStorage.bind(callback as (...args: any[]) => any)
|
|
23
|
+
: callback;
|
|
24
|
+
return original(bound, delay, ...args);
|
|
25
|
+
} as typeof setTimeout;
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// Patch setInterval
|
|
30
|
+
patch(
|
|
31
|
+
globalThis as any,
|
|
32
|
+
"setInterval",
|
|
33
|
+
(original: typeof setInterval) => {
|
|
34
|
+
return function (
|
|
35
|
+
callback: TimerHandler,
|
|
36
|
+
delay?: number,
|
|
37
|
+
...args: any[]
|
|
38
|
+
) {
|
|
39
|
+
const bound =
|
|
40
|
+
typeof callback === "function"
|
|
41
|
+
? AsyncLocalStorage.bind(callback as (...args: any[]) => any)
|
|
42
|
+
: callback;
|
|
43
|
+
return original(bound, delay, ...args);
|
|
44
|
+
} as typeof setInterval;
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Patch setImmediate (non-standard but available in some environments)
|
|
49
|
+
if ((globalThis as any).setImmediate) {
|
|
50
|
+
patch(
|
|
51
|
+
globalThis as any,
|
|
52
|
+
"setImmediate",
|
|
53
|
+
(original: any) => {
|
|
54
|
+
return function (callback: (...args: any[]) => void, ...args: any[]) {
|
|
55
|
+
const bound = AsyncLocalStorage.bind(callback);
|
|
56
|
+
return original(bound, ...args);
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Patch requestAnimationFrame
|
|
63
|
+
if (typeof (globalThis as any).requestAnimationFrame !== "undefined") {
|
|
64
|
+
patch(
|
|
65
|
+
globalThis as any,
|
|
66
|
+
"requestAnimationFrame",
|
|
67
|
+
(original: typeof requestAnimationFrame) => {
|
|
68
|
+
return function (callback: FrameRequestCallback) {
|
|
69
|
+
const bound = AsyncLocalStorage.bind(callback);
|
|
70
|
+
return original(bound);
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Patch requestIdleCallback
|
|
77
|
+
if (typeof (globalThis as any).requestIdleCallback !== "undefined") {
|
|
78
|
+
patch(
|
|
79
|
+
globalThis as any,
|
|
80
|
+
"requestIdleCallback",
|
|
81
|
+
(original: typeof requestIdleCallback) => {
|
|
82
|
+
return function (
|
|
83
|
+
callback: IdleRequestCallback,
|
|
84
|
+
options?: IdleRequestOptions
|
|
85
|
+
) {
|
|
86
|
+
const bound = AsyncLocalStorage.bind(callback);
|
|
87
|
+
return original(bound, options);
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Remove all timer patches, restoring original behavior.
|
|
96
|
+
*/
|
|
97
|
+
export function unpatchTimers(): void {
|
|
98
|
+
unpatch(globalThis as any, "setTimeout");
|
|
99
|
+
unpatch(globalThis as any, "setInterval");
|
|
100
|
+
if ((globalThis as any).setImmediate) {
|
|
101
|
+
unpatch(globalThis as any, "setImmediate");
|
|
102
|
+
}
|
|
103
|
+
if (typeof (globalThis as any).requestAnimationFrame !== "undefined") {
|
|
104
|
+
unpatch(globalThis as any, "requestAnimationFrame");
|
|
105
|
+
}
|
|
106
|
+
if (typeof (globalThis as any).requestIdleCallback !== "undefined") {
|
|
107
|
+
unpatch(globalThis as any, "requestIdleCallback");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { AsyncLocalStorage } from "../async-local-storage";
|
|
3
|
+
import { AsyncContextFrame } from "../async-context-frame";
|
|
4
|
+
import { patchXHR } from "./xhr";
|
|
5
|
+
|
|
6
|
+
describe("XMLHttpRequest patch", () => {
|
|
7
|
+
let als: AsyncLocalStorage<number>;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
AsyncContextFrame.set(undefined);
|
|
11
|
+
als = new AsyncLocalStorage<number>();
|
|
12
|
+
patchXHR();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("on* event handler properties", () => {
|
|
16
|
+
it("should preserve context through onload property", () => {
|
|
17
|
+
if (typeof XMLHttpRequest === "undefined") {
|
|
18
|
+
return; // Skip if not available
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let capturedValue: number | undefined;
|
|
22
|
+
|
|
23
|
+
als.run(100, () => {
|
|
24
|
+
const xhr = new XMLHttpRequest();
|
|
25
|
+
|
|
26
|
+
// Set onload handler inside the context
|
|
27
|
+
xhr.onload = () => {
|
|
28
|
+
capturedValue = als.getStore();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Manually trigger the event to test context preservation
|
|
32
|
+
const event = new Event("load");
|
|
33
|
+
xhr.dispatchEvent(event);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(capturedValue).toBe(100);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should preserve context through onerror property", () => {
|
|
40
|
+
if (typeof XMLHttpRequest === "undefined") {
|
|
41
|
+
return; // Skip if not available
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let capturedValue: number | undefined;
|
|
45
|
+
|
|
46
|
+
als.run(200, () => {
|
|
47
|
+
const xhr = new XMLHttpRequest();
|
|
48
|
+
|
|
49
|
+
xhr.onerror = () => {
|
|
50
|
+
capturedValue = als.getStore();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const event = new Event("error");
|
|
54
|
+
xhr.dispatchEvent(event);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(capturedValue).toBe(200);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should preserve context through onreadystatechange property", () => {
|
|
61
|
+
if (typeof XMLHttpRequest === "undefined") {
|
|
62
|
+
return; // Skip if not available
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let capturedValue: number | undefined;
|
|
66
|
+
|
|
67
|
+
als.run(300, () => {
|
|
68
|
+
const xhr = new XMLHttpRequest();
|
|
69
|
+
|
|
70
|
+
xhr.onreadystatechange = () => {
|
|
71
|
+
capturedValue = als.getStore();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const event = new Event("readystatechange");
|
|
75
|
+
xhr.dispatchEvent(event);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(capturedValue).toBe(300);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "../async-local-storage";
|
|
2
|
+
import { patch, unpatch } from "./patch-helper";
|
|
3
|
+
|
|
4
|
+
const originalDescriptors = new Map<string, PropertyDescriptor>();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Patch XMLHttpRequest to preserve async context.
|
|
8
|
+
* This ensures that XHR on* event handlers maintain their async context.
|
|
9
|
+
* Note: addEventListener is inherited from EventTarget and doesn't need patching here.
|
|
10
|
+
*/
|
|
11
|
+
export function patchXHR(): void {
|
|
12
|
+
if (!globalThis.XMLHttpRequest) return;
|
|
13
|
+
|
|
14
|
+
// Patch on* event handler properties
|
|
15
|
+
const eventProps = [
|
|
16
|
+
"onload",
|
|
17
|
+
"onerror",
|
|
18
|
+
"onabort",
|
|
19
|
+
"ontimeout",
|
|
20
|
+
"onprogress",
|
|
21
|
+
"onloadstart",
|
|
22
|
+
"onloadend",
|
|
23
|
+
"onreadystatechange",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
for (const prop of eventProps) {
|
|
27
|
+
const descriptor = Object.getOwnPropertyDescriptor(
|
|
28
|
+
XMLHttpRequest.prototype,
|
|
29
|
+
prop
|
|
30
|
+
);
|
|
31
|
+
if (descriptor?.set) {
|
|
32
|
+
// Store the original descriptor for unpatching
|
|
33
|
+
originalDescriptors.set(prop, descriptor);
|
|
34
|
+
|
|
35
|
+
const originalSet = descriptor.set;
|
|
36
|
+
Object.defineProperty(XMLHttpRequest.prototype, prop, {
|
|
37
|
+
...descriptor,
|
|
38
|
+
set(handler: ((this: XMLHttpRequest, ev: any) => any) | null) {
|
|
39
|
+
const bound = handler ? AsyncLocalStorage.bind(handler) : null;
|
|
40
|
+
originalSet.call(this, bound);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Remove XMLHttpRequest patches, restoring original behavior.
|
|
49
|
+
*/
|
|
50
|
+
export function unpatchXHR(): void {
|
|
51
|
+
if (!globalThis.XMLHttpRequest) return;
|
|
52
|
+
|
|
53
|
+
// Restore original property descriptors for on* properties
|
|
54
|
+
originalDescriptors.forEach((descriptor, prop) => {
|
|
55
|
+
Object.defineProperty(XMLHttpRequest.prototype, prop, descriptor);
|
|
56
|
+
});
|
|
57
|
+
originalDescriptors.clear();
|
|
58
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { AsyncLocalStorage } from "./async-local-storage";
|
|
3
|
+
import { AsyncContextFrame } from "./async-context-frame";
|
|
4
|
+
import { capture, restore, SnapshotContainer } from "./snapshot";
|
|
5
|
+
|
|
6
|
+
describe("snapshot (capture/restore)", () => {
|
|
7
|
+
let als: AsyncLocalStorage<number>;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
// Reset the global context frame before each test
|
|
11
|
+
AsyncContextFrame.set(undefined);
|
|
12
|
+
als = new AsyncLocalStorage<number>();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("should preserve context across simulated await", async () => {
|
|
16
|
+
const container: SnapshotContainer = {};
|
|
17
|
+
|
|
18
|
+
const promise = new Promise<number>((resolve) => {
|
|
19
|
+
als.run(777, async () => {
|
|
20
|
+
// Simulate: restore(container, await capture(container, promise))
|
|
21
|
+
const captured = capture(container, Promise.resolve(42));
|
|
22
|
+
const value = await captured;
|
|
23
|
+
const result = restore(container, value);
|
|
24
|
+
|
|
25
|
+
resolve(als.getStore()!);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const storeValue = await promise;
|
|
30
|
+
expect(storeValue).toBe(777);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should handle multiple sequential awaits", async () => {
|
|
34
|
+
const container: SnapshotContainer = {};
|
|
35
|
+
const results: (number | undefined)[] = [];
|
|
36
|
+
|
|
37
|
+
await als.run(888, async () => {
|
|
38
|
+
results.push(als.getStore());
|
|
39
|
+
|
|
40
|
+
// First await
|
|
41
|
+
restore(container, await capture(container, Promise.resolve()));
|
|
42
|
+
results.push(als.getStore());
|
|
43
|
+
|
|
44
|
+
// Second await
|
|
45
|
+
restore(container, await capture(container, Promise.resolve()));
|
|
46
|
+
results.push(als.getStore());
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(results).toEqual([888, 888, 888]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should work with actual async operations", async () => {
|
|
53
|
+
const container: SnapshotContainer = {};
|
|
54
|
+
|
|
55
|
+
const result = await als.run(999, async () => {
|
|
56
|
+
const delayedValue = new Promise<string>((resolve) => {
|
|
57
|
+
setTimeout(() => resolve("done"), 10);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
restore(container, await capture(container, delayedValue));
|
|
61
|
+
return als.getStore();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(result).toBe(999);
|
|
65
|
+
});
|
|
66
|
+
});
|
package/src/snapshot.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AsyncContextFrame } from "./async-context-frame";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Container object for storing async context snapshots around await points.
|
|
5
|
+
*/
|
|
6
|
+
export interface SnapshotContainer {
|
|
7
|
+
frame?: AsyncContextFrame;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Capture the current async context frame before an await.
|
|
12
|
+
* Stores the frame in the container and returns the promise unchanged.
|
|
13
|
+
*
|
|
14
|
+
* Usage: `restore(container, await capture(container, promise))`
|
|
15
|
+
*/
|
|
16
|
+
export function capture<T>(
|
|
17
|
+
container: SnapshotContainer,
|
|
18
|
+
promise: T
|
|
19
|
+
): T {
|
|
20
|
+
container.frame = AsyncContextFrame.current();
|
|
21
|
+
return promise;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Restore the async context frame after an await.
|
|
26
|
+
* Retrieves the frame from the container, sets it as current, and returns the value unchanged.
|
|
27
|
+
*
|
|
28
|
+
* Usage: `restore(container, await capture(container, promise))`
|
|
29
|
+
*/
|
|
30
|
+
export function restore<T>(
|
|
31
|
+
container: SnapshotContainer,
|
|
32
|
+
value: T
|
|
33
|
+
): T {
|
|
34
|
+
if (container.frame !== undefined) {
|
|
35
|
+
AsyncContextFrame.set(container.frame);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"outDir": "./dist",
|
|
4
|
+
"rootDir": "./src",
|
|
5
|
+
"lib": ["es2022", "DOM"],
|
|
6
|
+
"target": "es2022",
|
|
7
|
+
"module": "ESNext",
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"declarationMap": true,
|
|
11
|
+
"sourceMap": true,
|
|
12
|
+
"strict": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"forceConsistentCasingInFileNames": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*"],
|
|
18
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
19
|
+
}
|
package/tsup.config.ts
ADDED