├── .github └── workflows │ ├── ci.yaml │ └── publish.yaml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── docs └── reference.md ├── examples ├── bun │ ├── README.md │ ├── bun.ts │ └── server.ts ├── deno │ ├── README.md │ └── server.ts └── logger │ ├── Logger.ts │ ├── README.md │ └── index.ts ├── package-lock.json ├── package.json ├── src ├── AsyncLocalStorage.ts ├── core │ ├── AsyncObjectStack.ts │ ├── StackRuntime.test.ts │ ├── StackRuntime.ts │ ├── objectStack.test.ts │ └── objectStack.ts └── index.ts └── tsconfig.json /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | types: 8 | - opened 9 | - synchronize 10 | - reopened 11 | branches: 12 | - main 13 | 14 | jobs: 15 | unit-test-node: 16 | runs-on: ubuntu-latest 17 | name: unit-test-node (Node ${{ matrix.node-version }}) 18 | strategy: 19 | matrix: 20 | node-version: [18, 20] 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: npm 27 | - run: npm ci 28 | - run: npm run build 29 | - run: npm test -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | workflow_dispatch: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | id-token: write 9 | 10 | jobs: 11 | publish: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | cache: npm 19 | - run: npm ci 20 | - run: npm run build 21 | - run: npm test 22 | - name: publish to npm 23 | uses: JS-DevTools/npm-publish@v3 24 | with: 25 | token: ${{ secrets.NPM_TOKEN }} 26 | access: public 27 | strategy: upgrade 28 | provenance: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | /node_modules 3 | 4 | /dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 uhyo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # async-object-stack 2 | 3 | A wrapper of [AsyncLocalStorage](https://nodejs.org/api/async_context.html#async_context_class_asynclocalstorage) that keeps a stack of objects. Supports the `using` syntax to automatically pop objects from the stack when the current scope ends. 4 | 5 | Primary use case is creating a nice API for structural logging. See [Structural Logging Example](./examples/logger). 6 | 7 | **[Reference](./docs/reference.md)** | **[Zenn Article (日本語)](https://zenn.dev/uhyo/articles/async-object-stack)** 8 | 9 | ## Installation 10 | 11 | ```sh 12 | npm install async-object-stack 13 | ``` 14 | 15 | ## Example 16 | 17 | See also: [Structural Logging Example](./examples/logger). 18 | 19 | ```js 20 | import { createAsyncObjectStack } from 'async-object-stack'; 21 | 22 | const stack = createAsyncObjectStack(); 23 | 24 | console.log(stack.render()); // {} 25 | using guard = stack.push({ pika: "chu" }); 26 | console.log(stack.render()); // { pika: "chu" } 27 | { 28 | using guard2 = stack.push({ bulba: "saur" }); 29 | console.log(stack.render()); // { pika: "chu", bulba: "saur" } 30 | } 31 | console.log(stack.render()); // { pika: "chu" } 32 | ``` 33 | 34 | ## License 35 | 36 | MIT -------------------------------------------------------------------------------- /docs/reference.md: -------------------------------------------------------------------------------- 1 | # `async-object-stack` reference 2 | 3 | ## Requirements 4 | 5 | - This package requires a runtime with support for `AsyncLocalStorage`. This includes Node.js, Deno and Bun. 6 | - This package loses most of its power if the `using` syntax is unavailable. You need to use either a runtime with native support or a transpiler that supports it. 7 | - If you use transpilers, you may also need to polyfill `Symbol.disposable`. 8 | 9 | ## Supported Runtimes 10 | 11 | This package supports Node.js 18 and later. 12 | 13 | It is also possible to use this package with other runtimes that support importing `AsyncLocalStorage` from `node:async_hooks` (namely Deno and Bun). However, there is no CI set up to guarantee that it will work correctly. 14 | 15 | ## `createAsyncObjectStack()` 16 | 17 | Creates an `AsyncObjectStack` instance. 18 | 19 | ```js 20 | import { createAsyncObjectStack } from 'async-object-stack'; 21 | 22 | const stack = createAsyncObjectStack(); 23 | ``` 24 | 25 | ## `AsyncObjectStack` 26 | 27 | Object that keeps a stack of objects. Usually, you will create one instance of `AsyncObjectStack` and use it throughout your application. 28 | 29 | Basic operations are **push** and **render**. **Push** is to push an object to the stack. **Render** is to get the result of merging all objects in the stack into one. 30 | 31 | > [!TIP] 32 | > Technically, an `AsyncObjectStack` instance is a wrapper of `AsyncLocalStorage` that keeps a stack of objects in the current async execution context. 33 | 34 | ### `AsyncObjectStack#push(object)` 35 | 36 | Pushes an object to the stack. Returns an opaque Disposable object. 37 | 38 | You should store the return value of `push` in a `using` variable. By doing so, the object will be automatically popped from the stack when the current scope ends. 39 | 40 | ```js 41 | // Push an object to the stack. 42 | using guard = stack.push({ pika: "chu" }); 43 | // Object can have multiple properties. 44 | using guard2 = stack.push({ bulba: "saur", char: "mander" }); 45 | ``` 46 | 47 | > [!NOTE] 48 | > Given object is **shallowly** cloned before pushing to the stack. This means that mutation of the object after pushing to the stack may affect the object in the stack. Generally, you should avoid mutating objects after pushing them to the stack. 49 | 50 | ### `AsyncObjectStack#render()` 51 | 52 | Renders the stack into one object. Returned object is null-prototyped in order to avoid weird security stuff. 53 | 54 | Properties are shallowly merged from the bottom to the top of the stack. If there are multiple properties with the same name, the one added later will override the one added earlier. 55 | 56 | ```js 57 | using guard = stack.push({ pika: "chu" }); 58 | using guard2 = stack.push({ bulba: "saur" }); 59 | 60 | const obj = stack.render(); 61 | console.log(obj); // { pika: "chu", bulba: "saur" } 62 | ``` 63 | 64 | ### `AsyncObjectStack#region()` 65 | 66 | Runs given callback in a child _region_ and returns the result. Modifications to the stack will not affect beyond region boundaries. 67 | 68 | Objects in the stack are inherited from the parent region. 69 | 70 | ```js 71 | using guard = stack.push({ pika: "chu" }); 72 | await stack.region(async () => { 73 | using guard2 = stack.push({ bulba: "saur" }); 74 | const obj = stack.render(); 75 | console.log(obj); // { pika: "chu", bulba: "saur" } 76 | }); 77 | ``` 78 | 79 | Regions are useful when you want to dispatch concurrent tasks that push objects to the stack. Without regions, those tasks will interfere with each other. 80 | 81 | ```js 82 | const userIds = ["John", "Jane", "Jack"]; 83 | await Promise.all(userIds.map((userId) => stack.region(async () => { 84 | using guard = stack.push({ userId }); 85 | // Without regions, all tasks will see the same userId in the stack. 86 | await doSomething(); 87 | }))); 88 | ``` 89 | > [!TIP] 90 | > Technically, `region` is just a wrapper of `AsyncLocalStorage#run`. 91 | 92 | 93 | ### `AsyncObjectStack#stackSnapshot()` 94 | 95 | Returns the list of objects in the stack without merging them. 96 | 97 | The result is a snapshot of the stack. It won't be affected by further mutations of the stack. 98 | 99 | ```js 100 | using guard = stack.push({ pika: "chu" }); 101 | using guard2 = stack.push({ bulba: "saur" }); 102 | 103 | const snapshot = stack.stackSnapshot(); 104 | console.log(snapshot); // [{ pika: "chu" }, { bulba: "saur" }] 105 | ``` 106 | -------------------------------------------------------------------------------- /examples/bun/README.md: -------------------------------------------------------------------------------- 1 | # `async-object-stack` with Deno 2 | 3 | This is an example of using `async-object-stack` with Bun. 4 | 5 | Run with the following command: 6 | 7 | ```sh 8 | bun run server.ts 9 | ``` 10 | 11 | Then access to http://localhost:8080/abc (for example) and see the console. 12 | 13 | Read more at [the Node.js example readme](../logger/README.md). 14 | 15 | -------------------------------------------------------------------------------- /examples/bun/bun.ts: -------------------------------------------------------------------------------- 1 | Bun.serve({ 2 | port: 8080, 3 | fetch(req) { 4 | return new Response("Bun!"); 5 | }, 6 | }); 7 | -------------------------------------------------------------------------------- /examples/bun/server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AsyncObjectStack, 3 | type StackGuard, 4 | createAsyncObjectStack, 5 | } from "async-object-stack"; 6 | 7 | class Logger { 8 | #stack: AsyncObjectStack; 9 | 10 | constructor() { 11 | this.#stack = createAsyncObjectStack(); 12 | } 13 | 14 | /** 15 | * Runs the given function in a separate region. 16 | */ 17 | region(fn: () => R): R { 18 | return this.#stack.region(fn); 19 | } 20 | 21 | /** 22 | * Add metadata to the current execution. 23 | */ 24 | metadata(metadata: object): StackGuard { 25 | return this.#stack.push(metadata); 26 | } 27 | 28 | /** 29 | * Emit a log message. 30 | */ 31 | log(message: string): void { 32 | const metadata = this.#stack.render(); 33 | console.log(JSON.stringify({ message, ...metadata })); 34 | } 35 | } 36 | 37 | const logger = new Logger(); 38 | logger.metadata({ app: "example-logger" }); 39 | 40 | logger.log("app started: http://localhost:8080/"); 41 | Bun.serve({ 42 | port: 8080, 43 | fetch(request, server) { 44 | console.log("Hi"); 45 | return logger.region(() => processRequest(request, server)); 46 | }, 47 | }); 48 | 49 | async function processRequest(req: Request, server: Server): Promise { 50 | // `using` syntax is not supported yet. 51 | // using guard = logger.metadata({ 52 | // url: req.url, 53 | // ipAddress: server.requestIP(req).address, 54 | // }); 55 | logger.metadata({ 56 | url: req.url, 57 | ipAddress: server.requestIP(req).address, 58 | }); 59 | logger.log("processing request"); 60 | const userId = getUserId(); 61 | logger.metadata({ userId }); 62 | await new Promise((resolve) => setTimeout(resolve, 200)); 63 | logger.log("request processed"); 64 | return new Response("Hello World!"); 65 | } 66 | 67 | /** 68 | * Function to get userId. 69 | */ 70 | function getUserId(): string { 71 | return String(Math.floor(Math.random() * 1000)); 72 | } 73 | -------------------------------------------------------------------------------- /examples/deno/README.md: -------------------------------------------------------------------------------- 1 | # `async-object-stack` with Deno 2 | 3 | This is an example of using `async-object-stack` with Deno. 4 | 5 | Run with the following command: 6 | 7 | ```sh 8 | deno run --allow-net server.ts 9 | ``` 10 | 11 | Then access to http://localhost:8080/abc (for example) and see the console. 12 | 13 | Read more at [the Node.js example readme](../logger/README.md). 14 | 15 | -------------------------------------------------------------------------------- /examples/deno/server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AsyncObjectStack, 3 | type StackGuard, 4 | createAsyncObjectStack, 5 | } from "npm:async-object-stack"; 6 | 7 | class Logger { 8 | #stack: AsyncObjectStack; 9 | 10 | constructor() { 11 | this.#stack = createAsyncObjectStack(); 12 | } 13 | 14 | /** 15 | * Runs the given function in a separate region. 16 | */ 17 | region(fn: () => R): R { 18 | return this.#stack.region(fn); 19 | } 20 | 21 | /** 22 | * Add metadata to the current execution. 23 | */ 24 | metadata(metadata: object): StackGuard { 25 | return this.#stack.push(metadata); 26 | } 27 | 28 | /** 29 | * Emit a log message. 30 | */ 31 | log(message: string): void { 32 | const metadata = this.#stack.render(); 33 | console.log(JSON.stringify({ message, ...metadata })); 34 | } 35 | } 36 | 37 | const logger = new Logger(); 38 | logger.metadata({ app: "example-logger" }); 39 | 40 | logger.log("app started"); 41 | Deno.serve({ port: 8080 }, (request, serveInfo) => { 42 | return logger.region(() => processRequest(request, serveInfo)); 43 | }); 44 | 45 | async function processRequest( 46 | req: Request, 47 | serveInfo: Deno.ServeHandlerInfo, 48 | ): Promise { 49 | using guard = logger.metadata({ 50 | url: req.url, 51 | ipAddress: serveInfo.remoteAddr.hostname, 52 | }); 53 | logger.log("processing request"); 54 | const userId = getUserId(); 55 | using guard2 = logger.metadata({ userId }); 56 | await new Promise((resolve) => setTimeout(resolve, 200)); 57 | logger.log("request processed"); 58 | return new Response("Hello World!"); 59 | } 60 | 61 | /** 62 | * Function to get userId. 63 | */ 64 | function getUserId(): string { 65 | return String(Math.floor(Math.random() * 1000)); 66 | } 67 | -------------------------------------------------------------------------------- /examples/logger/Logger.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AsyncObjectStack, 3 | type StackGuard, 4 | createAsyncObjectStack, 5 | } from "../../src/index.js"; 6 | 7 | export class Logger { 8 | #stack: AsyncObjectStack; 9 | 10 | constructor() { 11 | this.#stack = createAsyncObjectStack(); 12 | } 13 | 14 | /** 15 | * Runs the given function in a separate region. 16 | */ 17 | region(fn: () => R): R { 18 | return this.#stack.region(fn); 19 | } 20 | 21 | /** 22 | * Add metadata to the current execution. 23 | */ 24 | metadata(metadata: object): StackGuard { 25 | return this.#stack.push(metadata); 26 | } 27 | 28 | /** 29 | * Emit a log message. 30 | */ 31 | log(message: string): void { 32 | const metadata = this.#stack.render(); 33 | console.log(JSON.stringify({ message, ...metadata })); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/logger/README.md: -------------------------------------------------------------------------------- 1 | # Structural Logging Example 2 | 3 | This example shows how to use `async-object-stack` to implement an easy to use structural logging system. 4 | 5 | ## How to run 6 | 7 | Build TypeScript by `npx tsc` and run `node dist/examples/logger/index.js` to invoke an HTTP server on port 8080. 8 | 9 | Then access to http://localhost:8080/abc (for example) and see the console. A typical output is like this: 10 | 11 | ```json 12 | {"message":"app started","app":"example-logger"} 13 | {"message":"processing request","app":"example-logger","url":"/abc","ipAddress":"::1"} 14 | {"message":"request processed","app":"example-logger","url":"/abc","ipAddress":"::1","userId":"639"} 15 | {"message":"processing request","app":"example-logger","url":"/favicon.ico","ipAddress":"::1"} 16 | {"message":"request processed","app":"example-logger","url":"/favicon.ico","ipAddress":"::1","userId":"755"} 17 | ``` 18 | 19 | ## What is structural logging? 20 | 21 | **Structural logging** is a logging system where each log entry can contain arbitrary metadata in addition to log message. The benefit of structural logging is that you can easily filter and aggregate logs by metadata. 22 | 23 | You often want to share the same metadata across multiple log entries. For example, once you know the ID of the user who sent a request, you want to add it to all log entries related to the request. This is where the idea of “context” (in other words, current shared metadata) comes in. 24 | 25 | Accurate propagation of the context is a hard problem. In server-side JavaScript, [AsyncLocalStorage](https://nodejs.org/api/async_context.html#class-asynclocalstorage) is known as a good solution. With `AsyncLocalStorage`, the context is automatically propagated to all asynchronous callbacks, so you don’t need to pass it around manually. 26 | 27 | ## Using `async-object-stack` 28 | 29 | `async-object-stack` utilizes `AsyncLocalStorage` to provide a simple interface to manage the context. Its API is specialized for managing the context as a stack of objects which can be rendered into one object at any time. Since log metadata is usually represented as an object, this API is a good fit for structural logging. 30 | 31 | This example implements a [`Logger` class](./Logger.ts) that uses `async-object-stack` to manage the context. This is a thin wrapper of `AsyncObjectStack` from `async-object-stack`. This class exposes `AsyncObjectStack`'s `push` and `region` methods for managing the context, and provides a `log` method that automatically renders the context into a log entry. 32 | 33 | While this implementation emits log entries as JSON strings to the console, you can easily replace it with your favorite logging library. 34 | 35 | Usage of `Logger` is shown in [`index.ts`](./index.ts). This example shows a typical usage pattern of `Logger` for a web server. 36 | 37 | Of note is that you can use a singleton `Logger` instance throughout your application. 38 | 39 | Whenever you want to add metadata to the context, you use the `Logger#metadata` method. Then, those metadata will be shared across all log entries emitted in the current function (including ones called from the current function). 40 | 41 | ```ts 42 | using guard = logger.metadata({ 43 | url: req.url, 44 | ipAddress: req.socket.remoteAddress, 45 | }); 46 | ``` 47 | 48 | By utilizing the `using` syntax, you can ensure that the metadata is automatically removed from the context when the current function ends. In a rare case where you want the metadata to be persisted even after the current function ends, you can just avoid using `using`. 49 | 50 | ## Using `region` 51 | 52 | When you use `async-object-stack`, it is important to understand the concept of `region`. Failing to use `region` correctly can lead to unwanted and weird behavior where the context is shared across unrelated tasks. 53 | 54 | Fortunately, the rule of `region` is simple. You should use `region` whenever you want to dispatch a task that works concurrently with the current task. In other words, you should use `region` whenever you create a `Promise` instance that you don't `await` immediately. This includes e.g. `Promise.all` and `Promise.race` (where you pass `Promise` instances to such function before you `await` them). 55 | 56 | In the example, the `region` method is used as follows: 57 | 58 | ```ts 59 | logger.region(() => 60 | processRequest(req, res).catch((error) => { 61 | using guard = logger.metadata({ error }); 62 | logger.log("error processing request"); 63 | }), 64 | ); 65 | ``` 66 | 67 | The server may process multiple requests concurrently. Therefore, we use `region` to ensure that the context is not shared across requests. -------------------------------------------------------------------------------- /examples/logger/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type IncomingMessage, 3 | type ServerResponse, 4 | createServer, 5 | } from "node:http"; 6 | import { Logger } from "./Logger.js"; 7 | 8 | /** 9 | * Global logger instance. 10 | */ 11 | const logger = new Logger(); 12 | logger.metadata({ app: "example-logger" }); 13 | 14 | logger.log("app started"); 15 | 16 | const httpServer = createServer((req, res) => { 17 | logger.region(() => 18 | processRequest(req, res).catch((error) => { 19 | using guard = logger.metadata({ error }); 20 | logger.log("error processing request"); 21 | }), 22 | ); 23 | }); 24 | httpServer.listen(8080, () => {}); 25 | 26 | async function processRequest(req: IncomingMessage, res: ServerResponse) { 27 | using guard = logger.metadata({ 28 | url: req.url, 29 | ipAddress: req.socket.remoteAddress, 30 | }); 31 | logger.log("processing request"); 32 | const userId = getUserId(); 33 | using guard2 = logger.metadata({ userId }); 34 | await new Promise((resolve) => setTimeout(resolve, 200)); 35 | logger.log("request processed"); 36 | res.end("Hello World!"); 37 | } 38 | 39 | /** 40 | * Function to get userId. 41 | */ 42 | function getUserId(): string { 43 | return String(Math.floor(Math.random() * 1000)); 44 | } 45 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "async-object-stack", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "async-object-stack", 9 | "version": "1.0.0", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "@tsconfig/node20": "^20.1.2", 13 | "@types/node": "^20.10.5", 14 | "prettier": "^3.1.1", 15 | "typescript": "^5.3.3" 16 | } 17 | }, 18 | "node_modules/@tsconfig/node20": { 19 | "version": "20.1.2", 20 | "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.2.tgz", 21 | "integrity": "sha512-madaWq2k+LYMEhmcp0fs+OGaLFk0OenpHa4gmI4VEmCKX4PJntQ6fnnGADVFrVkBj0wIdAlQnK/MrlYTHsa1gQ==", 22 | "dev": true 23 | }, 24 | "node_modules/@types/node": { 25 | "version": "20.10.5", 26 | "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz", 27 | "integrity": "sha512-nNPsNE65wjMxEKI93yOP+NPGGBJz/PoN3kZsVLee0XMiJolxSekEVD8wRwBUBqkwc7UWop0edW50yrCQW4CyRw==", 28 | "dev": true, 29 | "dependencies": { 30 | "undici-types": "~5.26.4" 31 | } 32 | }, 33 | "node_modules/prettier": { 34 | "version": "3.1.1", 35 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz", 36 | "integrity": "sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==", 37 | "dev": true, 38 | "bin": { 39 | "prettier": "bin/prettier.cjs" 40 | }, 41 | "engines": { 42 | "node": ">=14" 43 | }, 44 | "funding": { 45 | "url": "https://github.com/prettier/prettier?sponsor=1" 46 | } 47 | }, 48 | "node_modules/typescript": { 49 | "version": "5.3.3", 50 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", 51 | "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", 52 | "dev": true, 53 | "bin": { 54 | "tsc": "bin/tsc", 55 | "tsserver": "bin/tsserver" 56 | }, 57 | "engines": { 58 | "node": ">=14.17" 59 | } 60 | }, 61 | "node_modules/undici-types": { 62 | "version": "5.26.5", 63 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", 64 | "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", 65 | "dev": true 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "async-object-stack", 3 | "version": "1.0.0", 4 | "description": "Stacked objects in async call stack", 5 | "author": "uhyo ", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/uhyo/async-object-stack.git" 10 | }, 11 | "keywords": [ 12 | "AsyncLocalStorage" 13 | ], 14 | "type": "module", 15 | "exports": { 16 | ".": { 17 | "default": "./dist/src/index.js" 18 | } 19 | }, 20 | "scripts": { 21 | "build": "tsc", 22 | "test": "node --test" 23 | }, 24 | "files": [ 25 | "dist", 26 | "!dist/.tsbuildinfo", 27 | "src", 28 | "examples" 29 | ], 30 | "devDependencies": { 31 | "@tsconfig/node20": "^20.1.2", 32 | "@types/node": "^20.10.5", 33 | "prettier": "^3.1.1", 34 | "typescript": "^5.3.3" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/AsyncLocalStorage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WinterCG's AsyncLocalStorage interface 3 | * @see {@link https://github.com/wintercg/proposal-common-minimum-api/blob/main/asynclocalstorage.md} 4 | */ 5 | export interface AsyncLocalStorage { 6 | run unknown>(value: T | undefined, fn: Fn, ...args: Parameters): ReturnType; 7 | exit unknown>(fn: Fn, ...args: Parameters): ReturnType; 8 | getStore(): T | undefined; 9 | } 10 | 11 | export type AsyncLocalStorageConstructor = new () => AsyncLocalStorage; 12 | -------------------------------------------------------------------------------- /src/core/AsyncObjectStack.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uhyo/async-object-stack/6e14c27fe8f49e3ed019c18334ccfda96963c3aa/src/core/AsyncObjectStack.ts -------------------------------------------------------------------------------- /src/core/StackRuntime.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "node:assert/strict"; 2 | import { test } from "node:test"; 3 | import { createAsyncObjectStack } from "../index.js"; 4 | 5 | test("Basic behavior", async (t) => { 6 | await t.test("default region", async (t) => { 7 | await t.test("default region is empty at first", () => { 8 | const stack = createAsyncObjectStack(); 9 | assert.deepEqual(stack.render(), Object.create(null)); 10 | }); 11 | await t.test("can push to the default region", () => { 12 | const stack = createAsyncObjectStack(); 13 | stack.push({ pika: "chu" }); 14 | assert.deepEqual(stack.render(), nullPrototype({ pika: "chu" })); 15 | }); 16 | await t.test("first region inherits from default region", () => { 17 | const stack = createAsyncObjectStack(); 18 | stack.push({ pika: "chu" }); 19 | stack.region(() => { 20 | using guard = stack.push({ abc: "def" }); 21 | assert.deepEqual( 22 | stack.render(), 23 | nullPrototype({ pika: "chu", abc: "def" }), 24 | ); 25 | }); 26 | }); 27 | }); 28 | await t.test("render()", async (t) => { 29 | await t.test("returns an empty object when no objects are pushed", () => { 30 | const stack = createAsyncObjectStack(); 31 | stack.region(() => { 32 | assert.deepEqual(stack.render(), Object.create(null)); 33 | }); 34 | }); 35 | }); 36 | await t.test("push()", async (t) => { 37 | await t.test("pushed object is reflected", () => { 38 | const stack = createAsyncObjectStack(); 39 | stack.region(() => { 40 | const obj1 = { pika: "chu" }; 41 | using guard = stack.push(obj1); 42 | assert.deepEqual(stack.render(), nullPrototype({ pika: "chu" })); 43 | }); 44 | }); 45 | await t.test("When guard is disposed, pushed object is removed", () => { 46 | const stack = createAsyncObjectStack(); 47 | stack.region(() => { 48 | const obj1 = { pika: "chu" }; 49 | { 50 | using guard = stack.push(obj1); 51 | assert.deepEqual(stack.render(), nullPrototype({ pika: "chu" })); 52 | { 53 | using guard = stack.push({ abc: "def" }); 54 | assert.deepEqual( 55 | stack.render(), 56 | nullPrototype({ pika: "chu", abc: "def" }), 57 | ); 58 | } 59 | assert.deepEqual(stack.render(), nullPrototype({ pika: "chu" })); 60 | } 61 | assert.deepEqual(stack.render(), Object.create(null)); 62 | }); 63 | }); 64 | await t.test("Region is available in functions", async () => { 65 | const stack = createAsyncObjectStack(); 66 | async function inner() { 67 | using guard = stack.push({ 68 | hello: "world", 69 | }); 70 | assert.deepEqual( 71 | stack.render(), 72 | nullPrototype({ pika: "chu", hello: "world" }), 73 | ); 74 | } 75 | await stack.region(async () => { 76 | using guard = stack.push({ pika: "chu" }); 77 | await inner(); 78 | }); 79 | }); 80 | }); 81 | }); 82 | test("region()", async (t) => { 83 | await t.test("inner region inherits outer region", async () => { 84 | const stack = createAsyncObjectStack(); 85 | await stack.region(async () => { 86 | const obj1 = { pika: "chu" }; 87 | using guard = stack.push(obj1); 88 | await stack.region(async () => { 89 | assert.deepEqual(stack.render(), nullPrototype({ pika: "chu" })); 90 | using guard = stack.push({ abc: "def" }); 91 | assert.deepEqual( 92 | stack.render(), 93 | nullPrototype({ pika: "chu", abc: "def" }), 94 | ); 95 | }); 96 | }); 97 | }); 98 | await t.test("inner region does not affect outer region", async () => { 99 | const stack = createAsyncObjectStack(); 100 | await stack.region(async () => { 101 | const obj1 = { pika: "chu" }; 102 | using guard = stack.push(obj1); 103 | const list: number[] = []; 104 | 105 | const inner = async () => { 106 | using guard = stack.push({ pika: "pika", abc: "def" }); 107 | list.push(1); 108 | await microSleep(2); 109 | assert.deepEqual( 110 | stack.render(), 111 | nullPrototype({ pika: "pika", abc: "def" }), 112 | ); 113 | }; 114 | 115 | const innerP = stack.region(inner); 116 | list.push(2); 117 | assert.deepEqual(stack.render(), nullPrototype({ pika: "chu" })); 118 | await innerP; 119 | assert.deepEqual(list, [1, 2]); 120 | }); 121 | }); 122 | await t.test( 123 | "modification to outer region does not affect inner region", 124 | async () => { 125 | const stack = createAsyncObjectStack(); 126 | await stack.region(async () => { 127 | const inner = async () => { 128 | using guard = stack.push({ abc: "def" }); 129 | await microSleep(2); 130 | assert.deepEqual( 131 | stack.render(), 132 | nullPrototype({ pika: "chu", abc: "def" }), 133 | ); 134 | }; 135 | 136 | let innerP; 137 | let error: unknown; 138 | { 139 | const obj1 = { pika: "chu" }; 140 | using guard = stack.push(obj1); 141 | innerP = stack.region(inner).catch((e) => (error = e)); 142 | } 143 | await microSleep(1); 144 | assert.deepEqual(stack.render(), nullPrototype({})); 145 | await innerP; 146 | assert.ifError(error); 147 | assert.deepEqual(stack.render(), nullPrototype({})); 148 | }); 149 | }, 150 | ); 151 | }); 152 | 153 | function nullPrototype(object: object): object { 154 | return Object.assign(Object.create(null), object); 155 | } 156 | 157 | async function microSleep(count: number): Promise { 158 | for (let i = 0; i < count; i++) { 159 | await Promise.resolve(); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/core/StackRuntime.ts: -------------------------------------------------------------------------------- 1 | import type { AsyncLocalStorage } from "../AsyncLocalStorage.js"; 2 | import { ObjectStack } from "./objectStack.js"; 3 | 4 | export class AsyncObjectStack { 5 | #store: AsyncLocalStorage; 6 | #defaultRegion: ObjectStack; 7 | 8 | constructor(store: AsyncLocalStorage) { 9 | this.#store = store; 10 | this.#defaultRegion = ObjectStack.create(); 11 | } 12 | 13 | /** 14 | * Creates a region and runs given callback. 15 | */ 16 | region(callback: () => R): R { 17 | const current = this.#store.getStore() ?? this.#defaultRegion; 18 | const next = current.child(); 19 | return this.#store.run(next, callback); 20 | } 21 | 22 | /** 23 | * Pushes given object to the object stack. 24 | */ 25 | push(value: object): StackGuard { 26 | const stack = this.#store.getStore() ?? this.#defaultRegion; 27 | const remove = stack.push(value); 28 | return new StackGuard(remove); 29 | } 30 | 31 | /** 32 | * Returns the current stack rendered into a single object. 33 | */ 34 | render(): object { 35 | const stack = this.#store.getStore() ?? this.#defaultRegion; 36 | return stack.render(); 37 | } 38 | 39 | /** 40 | * Returns the snapshot of the current stack. 41 | */ 42 | stackSnapshot(): object[] { 43 | const stack = this.#store.getStore() ?? this.#defaultRegion; 44 | return stack.snapshot(); 45 | } 46 | } 47 | 48 | export class StackGuard implements Disposable { 49 | #remove: () => void; 50 | constructor(remove: () => void) { 51 | this.#remove = remove; 52 | } 53 | [Symbol.dispose](): void { 54 | this.#remove(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/core/objectStack.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "node:assert/strict"; 2 | import { test } from "node:test"; 3 | import { ObjectStack } from "./objectStack.js"; 4 | 5 | test("render()", async (t) => { 6 | await t.test("returns an empty object when no objects are pushed", () => { 7 | const stack = ObjectStack.create(); 8 | assert.deepEqual(stack.render(), Object.create(null)); 9 | }); 10 | 11 | await t.test("returns an object with pushed objects", () => { 12 | const stack = ObjectStack.create(); 13 | const obj1 = { pika: "chu" }; 14 | stack.push(obj1); 15 | const result = stack.render(); 16 | assert.notEqual(result, obj1); 17 | assert.deepEqual(result, nullPrototype({ pika: "chu" })); 18 | }); 19 | 20 | await t.test("returns a merged object", () => { 21 | const stack = ObjectStack.create(); 22 | const obj1 = { pika: "chu" }; 23 | const obj2 = { a: "b" }; 24 | stack.push(obj1); 25 | stack.push(obj2); 26 | const result = stack.render(); 27 | assert.deepEqual(result, nullPrototype({ pika: "chu", a: "b" })); 28 | }); 29 | 30 | await t.test("Later objects override earlier ones", () => { 31 | const stack = ObjectStack.create(); 32 | const obj1 = { pika: "chu", a: "b" }; 33 | const obj2 = { a: "c" }; 34 | stack.push(obj1); 35 | stack.push(obj2); 36 | const result = stack.render(); 37 | assert.deepEqual(result, nullPrototype({ pika: "chu", a: "c" })); 38 | }); 39 | }); 40 | 41 | test("push()", async (t) => { 42 | await t.test("pushed object is shallow copied", () => { 43 | const stack = ObjectStack.create(); 44 | const obj1 = { pika: "chu" }; 45 | stack.push(obj1); 46 | obj1.pika = "pika"; 47 | assert.deepEqual(stack.render(), nullPrototype({ pika: "chu" })); 48 | }); 49 | await t.test("pushed object is not deep copied", () => { 50 | const stack = ObjectStack.create(); 51 | const obj1 = { pika: { chu: "chu" } }; 52 | stack.push(obj1); 53 | obj1.pika.chu = "pika"; 54 | assert.deepEqual(stack.render(), nullPrototype({ pika: { chu: "pika" } })); 55 | }); 56 | 57 | await test("remove", async (t) => { 58 | await t.test("removes pushed object", () => { 59 | const stack = ObjectStack.create(); 60 | const obj1 = { pika: "chu" }; 61 | const remove = stack.push(obj1); 62 | remove(); 63 | assert.deepEqual(stack.render(), Object.create(null)); 64 | }); 65 | 66 | await t.test("throws when remove function is called twice", () => { 67 | const stack = ObjectStack.create(); 68 | const obj1 = { pika: "chu" }; 69 | const remove = stack.push(obj1); 70 | remove(); 71 | assert.throws(remove, { message: "Given object is not in the stack" }); 72 | }); 73 | }); 74 | }); 75 | 76 | test("snapshot()", async (t) => { 77 | await t.test("returns an empty array when no objects are pushed", () => { 78 | const stack = ObjectStack.create(); 79 | assert.deepEqual(stack.snapshot(), []); 80 | }); 81 | 82 | await t.test("returns an array with pushed objects", () => { 83 | const stack = ObjectStack.create(); 84 | const obj1 = { pika: "chu" }; 85 | stack.push(obj1); 86 | const result = stack.snapshot(); 87 | assert.notEqual(result, obj1); 88 | assert.deepEqual(result, [nullPrototype({ pika: "chu" })]); 89 | }); 90 | 91 | await t.test("keeps all object", () => { 92 | const stack = ObjectStack.create(); 93 | const obj1 = { pika: "chu" }; 94 | const obj2 = { a: "b" }; 95 | stack.push(obj1); 96 | stack.push(obj2); 97 | const result = stack.snapshot(); 98 | assert.deepEqual(result, [ 99 | nullPrototype({ pika: "chu" }), 100 | nullPrototype({ a: "b" }), 101 | ]); 102 | }); 103 | 104 | await t.test("Object in the middle can be removed", () => { 105 | const stack = ObjectStack.create(); 106 | const obj1 = { pika: "chu", a: "b" }; 107 | const obj2 = { a: "c" }; 108 | const remove = stack.push(obj1); 109 | stack.push(obj2); 110 | remove(); 111 | const result = stack.snapshot(); 112 | assert.deepEqual(result, [nullPrototype({ a: "c" })]); 113 | }); 114 | 115 | await t.test("Modification of snapshot array does not affect stack", () => { 116 | const stack = ObjectStack.create(); 117 | const obj1 = { pika: "chu" }; 118 | stack.push(obj1); 119 | const result = stack.snapshot(); 120 | (result as [Record])[0] = { a: "b" }; 121 | assert.deepEqual(stack.render(), nullPrototype({ pika: "chu" })); 122 | }); 123 | }); 124 | 125 | test("child()", async (t) => { 126 | await t.test("returns a new instance", () => { 127 | const stack = ObjectStack.create(); 128 | const child = stack.child(); 129 | assert.notEqual(stack, child); 130 | }); 131 | 132 | await t.test("inherits parent render result", () => { 133 | const stack = ObjectStack.create(); 134 | stack.push({ pika: "chu" }); 135 | const child = stack.child(); 136 | assert.deepEqual(child.render(), nullPrototype({ pika: "chu" })); 137 | assert.deepEqual(child.snapshot(), [nullPrototype({ pika: "chu" })]); 138 | }); 139 | 140 | await t.test("child render result is merged with parent", () => { 141 | const stack = ObjectStack.create(); 142 | stack.push({ pika: "chu" }); 143 | const child = stack.child(); 144 | child.push({ a: "b" }); 145 | assert.deepEqual(child.render(), nullPrototype({ pika: "chu", a: "b" })); 146 | assert.deepEqual(child.snapshot(), [ 147 | nullPrototype({ pika: "chu" }), 148 | nullPrototype({ a: "b" }), 149 | ]); 150 | }); 151 | 152 | await t.test("child render result overrides parent", () => { 153 | const stack = ObjectStack.create(); 154 | stack.push({ pika: "chu", a: "b" }); 155 | const child = stack.child(); 156 | child.push({ a: "c" }); 157 | assert.deepEqual(child.render(), nullPrototype({ pika: "chu", a: "c" })); 158 | assert.deepEqual(child.snapshot(), [ 159 | nullPrototype({ pika: "chu", a: "b" }), 160 | nullPrototype({ a: "c" }), 161 | ]); 162 | }); 163 | }); 164 | 165 | function nullPrototype(object: object): object { 166 | return Object.assign(Object.create(null), object); 167 | } 168 | -------------------------------------------------------------------------------- /src/core/objectStack.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Stack of objects. 3 | */ 4 | export class ObjectStack { 5 | #parentStack: object[] | undefined; 6 | #stack: object[] = []; 7 | 8 | static create(): ObjectStack { 9 | return new ObjectStack(); 10 | } 11 | 12 | private constructor(parent?: ObjectStack) { 13 | this.#parentStack = parent?.snapshot(); 14 | } 15 | 16 | child(): ObjectStack { 17 | return new ObjectStack(this); 18 | } 19 | 20 | push(value: object): () => void { 21 | const internal = Object.freeze(Object.assign(Object.create(null), value)); 22 | this.#stack.push(internal); 23 | 24 | return () => { 25 | this.#remove(internal); 26 | }; 27 | } 28 | 29 | #remove(value: object): void { 30 | const index = this.#stack.indexOf(value); 31 | if (index === -1) { 32 | throw new Error("Given object is not in the stack"); 33 | } 34 | this.#stack.splice(index, 1); 35 | } 36 | 37 | render(): object { 38 | const result = Object.create(null); 39 | if (this.#parentStack !== undefined) { 40 | for (const object of this.#parentStack) { 41 | Object.assign(result, object); 42 | } 43 | } 44 | for (const object of this.#stack) { 45 | Object.assign(result, object); 46 | } 47 | return result; 48 | } 49 | 50 | snapshot(): object[] { 51 | const result = this.#parentStack ?? []; 52 | return result.concat(this.#stack); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from "node:async_hooks"; 2 | import { AsyncObjectStack, type StackGuard } from "./core/StackRuntime.js"; 3 | import type { AsyncLocalStorage as WinterCGAsyncLocalStorage } from "./AsyncLocalStorage.js"; 4 | import type { ObjectStack } from "./core/objectStack.js"; 5 | 6 | export type { AsyncObjectStack, StackGuard }; 7 | 8 | export function createAsyncObjectStack(): AsyncObjectStack { 9 | return new AsyncObjectStack( 10 | new AsyncLocalStorage() as WinterCGAsyncLocalStorage, 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "declaration": true, 6 | "sourceMap": true, 7 | "declarationMap": true, 8 | "noUncheckedIndexedAccess": true, 9 | "verbatimModuleSyntax": true, 10 | "incremental": true, 11 | "tsBuildInfoFile": "dist/.tsbuildinfo" 12 | }, 13 | "include": ["src", "examples"], 14 | "exclude": ["examples/deno", "examples/bun"] 15 | } 16 | --------------------------------------------------------------------------------