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,10 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm test)",
5
+ "Bash(npm test:*)",
6
+ "Bash(npm run lint:*)",
7
+ "Bash(npm run typecheck:*)"
8
+ ]
9
+ }
10
+ }
package/README.md ADDED
@@ -0,0 +1,257 @@
1
+ # als-browser
2
+
3
+ Browser-compatible polyfill for Node.js's `AsyncLocalStorage` API. This package
4
+ enables async context propagation in browser environments by patching common
5
+ async browser APIs.
6
+
7
+ ## Features
8
+
9
+ - Full `AsyncLocalStorage` API compatibility
10
+ - Automatic patching of browser async APIs
11
+ - Zero dependencies (dev dependencies only)
12
+ - TypeScript support with full type definitions
13
+ - ESM and CommonJS builds
14
+ - Comprehensive test coverage
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install als-browser
20
+ # or
21
+ pnpm add als-browser
22
+ # or
23
+ yarn add als-browser
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```typescript
29
+ import { AsyncLocalStorage } from 'als-browser';
30
+
31
+ // Create a storage instance
32
+ const requestContext = new AsyncLocalStorage<{ userId: string }>();
33
+
34
+ // Use run() to execute code in a context
35
+ requestContext.run({ userId: '123' }, () => {
36
+ console.log(requestContext.getStore()); // { userId: '123' }
37
+
38
+ // Context is preserved through setTimeout
39
+ setTimeout(() => {
40
+ console.log(requestContext.getStore()); // { userId: '123' }
41
+ }, 100);
42
+ });
43
+ ```
44
+
45
+ ## API
46
+
47
+ ### `AsyncLocalStorage<T>`
48
+
49
+ #### `constructor(options?)`
50
+
51
+ ```typescript
52
+ const store = new AsyncLocalStorage<T>({
53
+ defaultValue?: T, // Optional default value
54
+ name?: string // Optional name for debugging
55
+ });
56
+ ```
57
+
58
+ #### `run(data, callback, ...args)`
59
+
60
+ Run a function in a new async context with the given data.
61
+
62
+ ```typescript
63
+ const result = store.run(myData, () => {
64
+ // Your code here
65
+ return store.getStore(); // Returns myData
66
+ });
67
+ ```
68
+
69
+ #### `getStore()`
70
+
71
+ Get the current value from this store.
72
+
73
+ ```typescript
74
+ const currentValue = store.getStore();
75
+ ```
76
+
77
+ #### `enterWith(data)`
78
+
79
+ Enter a new async context with the given data (no callback).
80
+
81
+ ```typescript
82
+ store.enterWith(myData);
83
+ console.log(store.getStore()); // myData
84
+ ```
85
+
86
+ #### `exit(callback, ...args)`
87
+
88
+ Run a function with the store value set to undefined.
89
+
90
+ ```typescript
91
+ store.exit(() => {
92
+ console.log(store.getStore()); // undefined
93
+ });
94
+ ```
95
+
96
+ #### `disable()`
97
+
98
+ Remove this store from the current async context.
99
+
100
+ ```typescript
101
+ store.disable();
102
+ ```
103
+
104
+ #### Static: `bind(fn)`
105
+
106
+ Bind a function to the current async context.
107
+
108
+ ```typescript
109
+ const boundFn = AsyncLocalStorage.bind(() => {
110
+ return store.getStore();
111
+ });
112
+ ```
113
+
114
+ #### Static: `snapshot()`
115
+
116
+ Capture the current async context and return a function that can restore it.
117
+
118
+ ```typescript
119
+ const snapshot = AsyncLocalStorage.snapshot();
120
+ snapshot(() => {
121
+ // Runs in captured context
122
+ });
123
+ ```
124
+
125
+ ### Manual Context Propagation
126
+
127
+ For advanced use cases like code transformers or custom async instrumentation, you can manually capture and restore async context around `await` points.
128
+
129
+ #### `capture(container, promise)`
130
+
131
+ Capture the current async context frame before an await and store it in a container object.
132
+
133
+ ```typescript
134
+ import { capture, restore, SnapshotContainer } from 'als-browser';
135
+
136
+ const container: SnapshotContainer = {};
137
+ const result = restore(container, await capture(container, promise));
138
+ ```
139
+
140
+ #### `restore(container, value)`
141
+
142
+ Restore the async context frame after an await from the container object.
143
+
144
+ ```typescript
145
+ // Transform: await foo()
146
+ // Into: restore(container, await capture(container, foo()))
147
+
148
+ const container: SnapshotContainer = {};
149
+ store.run(myData, async () => {
150
+ // Manually preserve context across await
151
+ restore(container, await capture(container, asyncOperation()));
152
+ console.log(store.getStore()); // myData is preserved
153
+ });
154
+ ```
155
+
156
+ These functions are primarily useful for:
157
+ - Code transformers/compilers that automatically instrument async functions
158
+ - Custom async context tracking systems
159
+ - Debugging and understanding async context flow
160
+
161
+ **Note**: For normal application code, prefer using the automatic patches or `AsyncLocalStorage.bind()`/`snapshot()`.
162
+
163
+ ## Patched Browser APIs
164
+
165
+ The following browser APIs are automatically patched to preserve async context:
166
+
167
+ ### Timers
168
+ - `setTimeout`
169
+ - `setInterval`
170
+ - `setImmediate` (if available)
171
+
172
+ ### Animation
173
+ - `requestAnimationFrame`
174
+ - `requestIdleCallback`
175
+
176
+ ### Network
177
+ - `XMLHttpRequest` event handlers (addEventListener and on* properties)
178
+
179
+ ## How It Works
180
+
181
+ This package implements Node.js's `AsyncContextFrame` model adapted for browsers:
182
+
183
+ 1. **AsyncContextFrame**: A Map-based storage for async context, stored in a module-level variable
184
+ 2. **AsyncLocalStorage**: The main API that stores and retrieves values from the current frame
185
+ 3. **Browser API Patches**: Automatically wraps callbacks to preserve context across async boundaries
186
+
187
+ The implementation replaces Node.js's V8 embedder data APIs with a simple module-level variable, making it work in any JavaScript environment.
188
+
189
+ ## Limitations
190
+
191
+ - **Promise-based APIs**: This package does not automatically patch promise-based APIs like `fetch()`. For those, you need to manually propagate context using `AsyncLocalStorage.bind()` or `AsyncLocalStorage.snapshot()`.
192
+ - **EventTarget.addEventListener**: Only `XMLHttpRequest` is patched. Other event targets may need manual context propagation.
193
+ - **Module-level state**: The context is stored in a module-level variable, which means it's shared across all code in the same JavaScript realm.
194
+
195
+ ## Example: Request Tracing
196
+
197
+ ```typescript
198
+ import { AsyncLocalStorage } from 'als-browser';
199
+
200
+ const requestId = new AsyncLocalStorage<string>();
201
+
202
+ function generateId() {
203
+ return Math.random().toString(36).slice(2);
204
+ }
205
+
206
+ function log(message: string) {
207
+ const id = requestId.getStore() || 'no-context';
208
+ console.log(`[${id}] ${message}`);
209
+ }
210
+
211
+ // Start a request
212
+ requestId.run(generateId(), async () => {
213
+ log('Request started');
214
+
215
+ // Context preserved through setTimeout
216
+ setTimeout(() => {
217
+ log('Async operation 1');
218
+ }, 100);
219
+
220
+ // Context preserved through requestAnimationFrame
221
+ requestAnimationFrame(() => {
222
+ log('Animation frame');
223
+ });
224
+
225
+ // For fetch, manually bind
226
+ const boundHandler = AsyncLocalStorage.bind(async () => {
227
+ const response = await fetch('/api/data');
228
+ log('Fetch completed');
229
+ return response.json();
230
+ });
231
+
232
+ await boundHandler();
233
+ });
234
+ ```
235
+
236
+ ## Testing
237
+
238
+ ```bash
239
+ # Run tests
240
+ pnpm test
241
+
242
+ # Build
243
+ pnpm build
244
+
245
+ # Type check
246
+ pnpm typecheck
247
+ ```
248
+
249
+ ## License
250
+
251
+ MIT
252
+
253
+ ## Credits
254
+
255
+ This implementation is based on Node.js's `AsyncLocalStorage` and `AsyncContextFrame` APIs:
256
+ - `lib/internal/async_context_frame.js`
257
+ - `lib/internal/async_local_storage/async_context_frame.js`
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "als-browser",
3
+ "version": "1.0.0",
4
+ "description": "Browser polyfill for Node.js AsyncLocalStorage",
5
+ "author": "Stephen Belanger <admin@stephenbelanger.com>",
6
+ "license": "MIT",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.mjs",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ "./package.json": "./package.json",
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.mjs",
15
+ "module": "./dist/index.mjs",
16
+ "require": "./dist/index.js"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "build": "tsup",
21
+ "watch": "tsup --watch",
22
+ "test": "vitest run",
23
+ "lint": "eslint src --ext .ts",
24
+ "typecheck": "tsc --noEmit"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^20.0.0",
28
+ "happy-dom": "^12.0.0",
29
+ "tsup": "^8.0.0",
30
+ "typescript": "^5.3.0",
31
+ "vitest": "^1.0.0"
32
+ },
33
+ "keywords": [
34
+ "async-local-storage",
35
+ "context",
36
+ "async-context",
37
+ "browser",
38
+ "polyfill"
39
+ ]
40
+ }
@@ -0,0 +1,53 @@
1
+ import type { AsyncLocalStorage } from "./async-local-storage";
2
+
3
+ // Use a global Symbol to coordinate between ESM and CJS builds
4
+ const CURRENT_FRAME_SYMBOL = Symbol.for("als-browser:currentFrame");
5
+
6
+ /**
7
+ * AsyncContextFrame is a Map-based storage for async context.
8
+ * In Node.js, this uses V8's continuation preserved embedder data.
9
+ * In the browser, we use a global Symbol to coordinate between ESM/CJS builds.
10
+ */
11
+ export class AsyncContextFrame extends Map<AsyncLocalStorage<any>, any> {
12
+ constructor(store: AsyncLocalStorage<any>, data: any) {
13
+ super(AsyncContextFrame.current());
14
+ this.set(store, data);
15
+ }
16
+
17
+ disable(store: AsyncLocalStorage<any>) {
18
+ this.delete(store);
19
+ }
20
+
21
+ /**
22
+ * Get the current async context frame.
23
+ */
24
+ static current(): AsyncContextFrame | undefined {
25
+ return (globalThis as any)[CURRENT_FRAME_SYMBOL];
26
+ }
27
+
28
+ /**
29
+ * Set the current async context frame.
30
+ */
31
+ static set(frame: AsyncContextFrame | undefined): void {
32
+ (globalThis as any)[CURRENT_FRAME_SYMBOL] = frame;
33
+ }
34
+
35
+ /**
36
+ * Exchange the current frame with a new one, returning the previous frame.
37
+ */
38
+ static exchange(
39
+ frame: AsyncContextFrame | undefined
40
+ ): AsyncContextFrame | undefined {
41
+ const prior = this.current();
42
+ this.set(frame);
43
+ return prior;
44
+ }
45
+
46
+ /**
47
+ * Disable (remove) a specific store from the current frame.
48
+ */
49
+ static disable(store: AsyncLocalStorage<any>): void {
50
+ const frame = this.current();
51
+ frame?.disable(store);
52
+ }
53
+ }
@@ -0,0 +1,264 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { AsyncLocalStorage } from "./index";
3
+ import { AsyncContextFrame } from "./async-context-frame";
4
+
5
+ describe("AsyncLocalStorage", () => {
6
+ let als: AsyncLocalStorage<number>;
7
+
8
+ beforeEach(() => {
9
+ // Reset the global context frame before each test
10
+ AsyncContextFrame.set(undefined);
11
+ als = new AsyncLocalStorage<number>();
12
+ });
13
+
14
+ describe("Basic API", () => {
15
+ it("should return undefined when no store is set", () => {
16
+ expect(als.getStore()).toBeUndefined();
17
+ });
18
+
19
+ it("should return default value when configured", () => {
20
+ const alsWithDefault = new AsyncLocalStorage<number>({
21
+ defaultValue: 42,
22
+ });
23
+ expect(alsWithDefault.getStore()).toBe(42);
24
+ });
25
+
26
+ it("should support name option", () => {
27
+ const namedAls = new AsyncLocalStorage<number>({ name: "test-store" });
28
+ expect(namedAls.name).toBe("test-store");
29
+ });
30
+
31
+ it("should preserve context in run()", () => {
32
+ const result = als.run(123, () => {
33
+ return als.getStore();
34
+ });
35
+ expect(result).toBe(123);
36
+ });
37
+
38
+ it("should restore context after run() completes", () => {
39
+ expect(als.getStore()).toBeUndefined();
40
+ als.run(123, () => {
41
+ expect(als.getStore()).toBe(123);
42
+ });
43
+ expect(als.getStore()).toBeUndefined();
44
+ });
45
+
46
+ it("should handle nested run() calls", () => {
47
+ als.run(1, () => {
48
+ expect(als.getStore()).toBe(1);
49
+ als.run(2, () => {
50
+ expect(als.getStore()).toBe(2);
51
+ als.run(3, () => {
52
+ expect(als.getStore()).toBe(3);
53
+ });
54
+ expect(als.getStore()).toBe(2);
55
+ });
56
+ expect(als.getStore()).toBe(1);
57
+ });
58
+ expect(als.getStore()).toBeUndefined();
59
+ });
60
+
61
+ it("should support enterWith()", () => {
62
+ expect(als.getStore()).toBeUndefined();
63
+ als.enterWith(456);
64
+ expect(als.getStore()).toBe(456);
65
+ als.enterWith(789);
66
+ expect(als.getStore()).toBe(789);
67
+ });
68
+
69
+ it("should support exit()", () => {
70
+ als.enterWith(100);
71
+ expect(als.getStore()).toBe(100);
72
+ const result = als.exit(() => {
73
+ expect(als.getStore()).toBeUndefined();
74
+ return "exited";
75
+ });
76
+ expect(result).toBe("exited");
77
+ expect(als.getStore()).toBe(100);
78
+ });
79
+
80
+ it("should support disable()", () => {
81
+ als.enterWith(200);
82
+ expect(als.getStore()).toBe(200);
83
+ als.disable();
84
+ expect(als.getStore()).toBeUndefined();
85
+ });
86
+ });
87
+
88
+ describe("Static methods", () => {
89
+ it("should bind function to current context", () => {
90
+ let capturedValue: number | undefined;
91
+
92
+ als.run(999, () => {
93
+ const bound = AsyncLocalStorage.bind(() => {
94
+ capturedValue = als.getStore();
95
+ });
96
+
97
+ // Call bound function outside of run context
98
+ AsyncContextFrame.set(undefined);
99
+ bound();
100
+ });
101
+
102
+ expect(capturedValue).toBe(999);
103
+ });
104
+
105
+ it("should preserve 'this' context in bound functions", () => {
106
+ const obj = {
107
+ value: 42,
108
+ getValue(this: { value: number }) {
109
+ return this.value;
110
+ },
111
+ };
112
+
113
+ const bound = AsyncLocalStorage.bind(obj.getValue);
114
+ expect(bound.call(obj)).toBe(42);
115
+ });
116
+
117
+ it("should support snapshot()", () => {
118
+ const snapshot = als.run(555, () => {
119
+ return AsyncLocalStorage.snapshot();
120
+ });
121
+
122
+ // Run snapshot outside of original context
123
+ AsyncContextFrame.set(undefined);
124
+ const result = snapshot(() => {
125
+ return als.getStore();
126
+ });
127
+
128
+ expect(result).toBe(555);
129
+ });
130
+ });
131
+
132
+
133
+ describe("Multiple stores", () => {
134
+ it("should isolate different AsyncLocalStorage instances", () => {
135
+ const als1 = new AsyncLocalStorage<string>();
136
+ const als2 = new AsyncLocalStorage<number>();
137
+
138
+ als1.run("hello", () => {
139
+ als2.run(42, () => {
140
+ expect(als1.getStore()).toBe("hello");
141
+ expect(als2.getStore()).toBe(42);
142
+ });
143
+ });
144
+ });
145
+
146
+ it("should handle nested runs with multiple stores", () => {
147
+ const als1 = new AsyncLocalStorage<string>();
148
+ const als2 = new AsyncLocalStorage<number>();
149
+
150
+ als1.run("outer", () => {
151
+ als2.run(1, () => {
152
+ expect(als1.getStore()).toBe("outer");
153
+ expect(als2.getStore()).toBe(1);
154
+
155
+ als1.run("inner", () => {
156
+ als2.run(2, () => {
157
+ expect(als1.getStore()).toBe("inner");
158
+ expect(als2.getStore()).toBe(2);
159
+ });
160
+ expect(als1.getStore()).toBe("inner");
161
+ expect(als2.getStore()).toBe(1);
162
+ });
163
+
164
+ expect(als1.getStore()).toBe("outer");
165
+ expect(als2.getStore()).toBe(1);
166
+ });
167
+ });
168
+ });
169
+ });
170
+
171
+ describe("Edge cases", () => {
172
+ it("should handle run() with same value as current", () => {
173
+ als.enterWith(777);
174
+ const callCount = { count: 0 };
175
+
176
+ als.run(777, () => {
177
+ callCount.count++;
178
+ expect(als.getStore()).toBe(777);
179
+ });
180
+
181
+ expect(callCount.count).toBe(1);
182
+ });
183
+
184
+ it("should pass arguments to run() callback", () => {
185
+ const result = als.run(
186
+ 100,
187
+ (a: number, b: string, c: boolean) => {
188
+ return { a, b, c, store: als.getStore() };
189
+ },
190
+ 1,
191
+ "test",
192
+ true
193
+ );
194
+
195
+ expect(result).toEqual({ a: 1, b: "test", c: true, store: 100 });
196
+ });
197
+
198
+ it("should pass arguments to exit() callback", () => {
199
+ const result = als.exit(
200
+ (x: number, y: number) => {
201
+ return x + y;
202
+ },
203
+ 5,
204
+ 10
205
+ );
206
+
207
+ expect(result).toBe(15);
208
+ });
209
+
210
+ it("should handle undefined as a valid store value", () => {
211
+ als.run(undefined as any, () => {
212
+ expect(als.getStore()).toBeUndefined();
213
+ });
214
+ });
215
+
216
+ it("should handle null as a valid store value", () => {
217
+ const alsNull = new AsyncLocalStorage<string | null>();
218
+ alsNull.run(null, () => {
219
+ expect(alsNull.getStore()).toBeNull();
220
+ });
221
+ });
222
+ });
223
+
224
+ describe("Context propagation", () => {
225
+ it("should propagate through chained setTimeout calls", async () => {
226
+ const sequence: number[] = [];
227
+
228
+ const promise = new Promise<void>((resolve) => {
229
+ als.run(1, () => {
230
+ sequence.push(als.getStore()!);
231
+ setTimeout(() => {
232
+ sequence.push(als.getStore()!);
233
+ setTimeout(() => {
234
+ sequence.push(als.getStore()!);
235
+ resolve();
236
+ }, 10);
237
+ }, 10);
238
+ });
239
+ });
240
+
241
+ await promise;
242
+ expect(sequence).toEqual([1, 1, 1]);
243
+ });
244
+
245
+ it("should isolate contexts in parallel setTimeout calls", async () => {
246
+ const results: number[] = [];
247
+
248
+ const promises = [1, 2, 3].map((value) => {
249
+ return new Promise<void>((resolve) => {
250
+ als.run(value, () => {
251
+ setTimeout(() => {
252
+ results.push(als.getStore()!);
253
+ resolve();
254
+ }, 10);
255
+ });
256
+ });
257
+ });
258
+
259
+ await Promise.all(promises);
260
+ expect(results.sort()).toEqual([1, 2, 3]);
261
+ });
262
+ });
263
+
264
+ });