Last year at Intangles, we encountered an intriguing challenge in one of our projects. Our API server, built with Node.JS and using Restify as the server framework, has hundreds of APIs.
Our server architecture follows this structure: Routes -> Middlewares -> Controllers -> Store -> DB. We aimed to add access control to our APIs. We already had some access control logic in middlewares, but we also wanted to add it on controllers and stores so that our queries to the DB can be optimized for the user. However, to achieve this, we needed to incorporate Context.
What is Context?
If you have an Android background, you might already be familiar with the concept of Context. In Android, Context is a global object accessible throughout the entire application and used to access application-level resources.
In our scenario, we aimed to create a similar global object available during the entire request lifecycle. This object, referred to as Context, would contain all necessary information for decision-making in our middlewares, such as user and account information.
Many systems and programming languages, like Android, utilize the Context Design Pattern. While JavaScript has its own execution context, it changes with each function execution. We needed something that remains accessible throughout the entire execution lifecycle.
You might wonder, why not use the req object?
The req object is indeed a handy way to pass information between middlewares. However, the challenge lies in the need to pass this object through every function. Adding new parameters to all these functions can be a tedious and error-prone task.
Moreover, our server handles more than just User API requests. The digital twin framework also uses the same set of APIs to read and write data through the same interfaces. Using the req object would tightly couple the HTTP server with the business logic, which we wanted to avoid. We needed a solution that allowed us to access the current execution context without having to pass it explicitly through each function, thereby maintaining a cleaner separation of concerns.
Why not use a global object?
Using a global object might seem like a straightforward solution. However, global objects are shared across all requests, making them unsuitable for request-specific data. We needed an object that was unique to each request.
One approach is to create a new object for each request by assigning a UUID to each request and storing the context object in a map with the UUID as the key. But this method still requires passing the UUID to each function, which doesn’t solve our problem of needing a seamless way to access request-specific data throughout the request lifecycle.
Why not store the current request object as a global variable?
Storing the current request object as a global variable might seem like an easy fix. However, a single server handles multiple requests simultaneously. If we store the current request object globally, it will be overwritten by each new request. We needed a solution that allows us to store the request object in a way that is specific to each individual request.
Why not use async_hooks?
We considered using async_hooks, a module that provides an API to track asynchronous resources in a Node.js application. With async_hooks, we could create a new context object for each request. However, async_hooks is a low-level API, which makes it complex and difficult to use. Additionally, it lacks comprehensive documentation and isn’t supported in all Node.js versions. We needed a solution that was straightforward and user-friendly.
You might be thinking of other approaches, but most of them require passing parameters to each function. We sought a method to access the context object throughout the entire request lifecycle without passing it to every function.
After extensive discussions among our core team, we asked ourselves:
What is the one thing that is present with every function call in JavaScript?
Drum roll, please…
It’s the Stack Trace.
What is a Stack Trace in JS?
A stack trace is a report of the active stack frames at a specific point in time during a program’s execution. It provides a snapshot of the function calls that were active when an error occurred, making it a valuable tool for debugging. By examining the stack trace, developers can trace the sequence of function calls that led to an error, helping them to identify and fix issues in the code.
How to Access the Stack Trace in JavaScript?
To access the stack trace in JavaScript, you can use the Error object.
The Error object has a property called stack, which contains the stack trace. This property provides a snapshot of the function calls that were active at the time the error occurred, helping in debugging.
For those interested in integrating this tool or exploring its features, more information is available on its GitHub
repository: stacktrace.js on GitHub.
How Can We Add Context to the Stack Trace?
A stack trace in JavaScript is a collection of stack frames. Each stack frame contains information about a function call, such as the function name, file name, and line number. We can add context information as a stack frame to the stack.
However, you can’t just mutate the stack trace directly.
For example:
const error = Error("Something went wrong");
console.log(error.stack);
//'Error: Something went wrong\n at :1:15'
As you can see, the stack trace is a string. While you can change this string, the modification will only apply to this instance of the Error.
How to Make a Persistent Change in the Stack Trace?
The stack trace in JavaScript is managed by the JavaScript runtime engine, so directly modifying it would require changes to the engine’s code. However, since the function names are defined by us, we can include context information within these names to indirectly add context to the stack trace.
Implementation
We’ll create a provider that adds context information to the stack for functions called from within this provider and all its child functions. This will help us persist context information across the execution lifecycle of these functions.
Here’s how we can implement this:
const crypto = require("crypto");
const globalExecutionContext = {};
const KEY_PREFIX = "$$exec_context$$";
function provideExecutionContext(handler, contextValue) {
const uniqueId = "f-" + crypto.randomBytes(16).toString("hex") + "-" + Date.now();
const that = this;
const functionName = `Object.${KEY_PREFIX}${uniqueId}`;
const contextProvider = {
[functionName]: async function() {
try {
const returnValue = await handler.apply(that, arguments);
// destroy the execution context after the execution is completed
delete globalExecutionContext[uniqueId];
return returnValue;
} catch (err) {
// destroy the execution context in case of an error
delete globalExecutionContext[uniqueId];
throw err;
}
},
};
globalExecutionContext[uniqueId] = contextValue;
contextProvider[functionName].name = functionName;
return contextProvider[functionName];
}
The functionName serves as the identifier for the particular function lifecycle. Now we can access this context value using this identifier.
You can retrieve this context value using the function name in the stack trace.
Usage
We have released this implementation as an NPM module. You can find the source code here.
Consider the following code:
const firstFunction = (param) => {
const userDetails = {
name: "John",
hasAccessTo4thFunction: true
}
console.log("first function", param) // first function, 10
secondFunction(20)
}
const secondFunction = (param) => {
console.log("second function", param) // second function, 20
thirdFunction(30)
}
const thirdFunction = (param) => {
console.log("third function", param, contextValue)
// Call fourth function if user has access
// fourthFunction(40);
}
const fourthFunction = (param) => {
console.log("fourth function", param);
}
We have four functions. firstFunction is the entry function. In the thirdFunction, we want to call fourthFunction only if the user has access to it. Here’s how we can provide the user context to the following functions.
Step 1: Install the Execution Context Package
Run the following command to install the package:
npm i @intangles-lab/execution-context
Step 2: Wrap the Function with provideExecutionContext
We will wrap the secondFunction with provideExecutionContext. Let’s modify the firstFunction to provide the execution context.
const {
provideExecutionContext
} = require("@intangles-lab/execution-context");
...
const firstFunction = (param) => {
const userDetails = {
name: "John",
hasAccessTo4thFunction: true
}
console.log("first function", param) // first function, 10
const secondFunctionWithContext = provideExecutionContext(secondFunction, userDetails);
secondFunctionWithContext(20);
}
Step 3: Access the Context in the thirdFunction
Modify thirdFunction to retrieve the context using getExecutionContext.
const {
provideExecutionContext,
getExecutionContext
} = require("@intangles-lab/execution-context");
...
const thirdFunction = (param) => {
console.log("third function", param) // third function, 30
const userDetails = getExecutionContext();
if (userDetails?.hasAccessTo4thFunction) {
fourthFunction(40); // this is called
}
}
...
By following these steps, you can provide and access context information across different functions in your application using the @intangles-lab/execution-context module.
How to Change Context from Within the Lifecycle?
To change the context for the function lifecycle, you can update the context information during execution. For instance, if you need to revoke the user’s access, you can modify the context accordingly.
Here’s how you can do it:
const {
provideExecutionContext,
getExecutionContext,
setExecutionContext
} = require("@intangles-lab/execution-context-js");
...
const secondFunction = (param) => {
console.log("second function", param) // second function, 20
// Remove access of the user
const userDetails = getExecutionContext();
const userDetailsWOAccess = {
…
userDetails,
hasAccessTo4thFunction: false
};
setExecutionContext(userDetailsWOAccess);
thirdFunction(30); // Now the fourth function will not be called
}
...
By using getExecutionContext and setExecutionContext, you can dynamically modify the context during the function execution lifecycle.
How Have We Used This on Our HTTP Server?
We have utilized the provideExecutionContext function from the library to override the get, post, and other Restify server functions. This allows us to provide the req object as context throughout the request lifecycle.
Here’s the implementation:
const {
provideExecutionContext
} = require("@intangles-lab/execution-context-js");
/**
* Override the server methods to add the request context
*
* @param {import("restify").Server} server
* @param {string} methodString
* @returns
**/
function overrideServerRequestMethod(server, methodString) {
const methodFunction = server[methodString].bind(server);
server[methodString] = function(path, requestHandler) {
return methodFunction(path, (req, res, next) => {
const contextProvider = provideExecutionContext(requestHandler, req);
return contextProvider(req, res, next);
});
};
}
/**
* Initialize the request context for all the server methods
*
* @param {import("restify").Server} server - restify server
* @returns {undefined} no return value
*/
exports.initializeRequestContext = function initializeRequestContext(server) {
overrideServerRequestMethod(server, "get");
overrideServerRequestMethod(server, "post");
overrideServerRequestMethod(server, "put");
overrideServerRequestMethod(server, "del");
};
In server.js, we call initializeRequestContext before starting the server:
...
const server = restify.createServer(…options);
initializeRequestContext(server);
server.listen(port, function() {
log("%s listening at %s", server.name, server.url);
});
...
Now, you can access this context almost anywhere in the request lifecycle:
// requestController.js
const {
getExecutionContext
} = require("@intangles-lab/execution-context-js");
function requestController() {
const req = getExecutionContext();
// you can use req object now. No need to pass it down as parameters.
return {
results: […]
}
}
By following these steps, you can seamlessly provide and access context information throughout your request lifecycle without passing the req object down as parameters.
Caveats
The context value is only available to the functions that are within the same call stack as the function provided with the context value. Due to the nature of JavaScript, callbacks and promises will not have access to the context value. However, this approach works well with async/await functions. To ensure the context is preserved, the async functions need to be called using await so that they remain in the same call stack as the function provided with the context value.
If you want to use it in callbacks, you’ll need to get the context outside the callback and set it again inside the callback:
const {
getExecutionContext,
setExecutionContext
} = require("@intangles-lab/execution-context-js");
function callbackExample() {
const req = getExecutionContext();
setTimeout(() => {
setExecutionContext(req);
func();
}, 1000);
}
By doing this, you ensure that the context value is accessible within the callback, maintaining the continuity of context throughout different parts of the request lifecycle.
Final Thoughts
Integrating the execution-context module has significantly streamlined our request lifecycle management. This approach simplifies accessing context-specific information and enhances the maintainability and readability of our code. With this setup, we ensure that vital context is always available where needed, making our application more robust and easier to debug.
We’re looking forward to meeting you