AWS Developer Tools Blog

Introducing Middleware Stack in Modular AWS SDK for JavaScript

As of December 15th, 2020, the AWS SDK for JavaScript, version 3 (v3) is generally available.

On October 19th, 2020, we have released the Release Candidate (RC) of the AWS SDK for JavaScript, version 3 (v3). One of the major changes in v3 is introduction of the middleware stack, which customizes the SDK behavior by modifying the middleware. In this blog post we’d like to describe how you can use this feature in detail.

Overview

The JavaScript SDK maintains a series of asynchronous actions. These series include actions that serialize input parameters into the data over the wire and deserialize response data into JavaScript objects. Such actions are implemented using functions called middleware and executed in a specific order. The object that hosts all the middleware including the ordering information is called a Middleware Stack. You can add your custom actions to the SDK and/or remove the default ones.

When an API call is made, SDK sorts the middleware according to the step it belongs to and its priority within each step. The input parameters pass through each middleware. An HTTP request gets created and updated along the process. The HTTP Handler sends a request to the service, and receives a response. A response object is passed back through the same middleware stack in reverse, and is deserialized into a JavaScript object.

middleware layout in middleware stack from application to cloud

Middleware Stack 

Writing your first middleware

A middleware is a higher-order function that transfers user input and/or HTTP request, then delegates to “next” middleware. It also transfers the result from “next” middleware. A middleware function also has access to context parameter, which optionally contains data to be shared across middleware.

For example, you can use middleware to add a custom header like S3 object metadata:

const { S3 } = require("@aws-sdk/client-s3");
const client = new S3({ region: "us-west-2" });
// Middleware added to client, applies to all commands.
client.middlewareStack.add(
  (next, context) => async (args) => {
    args.request.headers["x-amz-meta-foo"] = "bar";
    const result = await next(args);
    // result.response contains data returned from next middleware.
    return result;
  },
  {
    step: "build",
    name: "addFooMetadataMiddleware",
    tags: ["METADATA", "FOO"],
  }
);

await client.putObject(params);

The second parameter of add() method includes following keys:

  • name: The optional name of your middleware. You can remove middleware by name. You can add middleware before or after another middleware. The name must be unique across the middleware stack.
  • step: The lifecycle step in which middleware is located in the stack. If skipped, it defaults to initialize step.
  • tags: An optional list of strings that identify the general purpose or important characteristics of middleware. You can use the tag to remove multiple middleware by removeByTag().

Service client and commands have their own middleware stack. If you add middleware to the client middleware stack, any request sent by the client will execute the action. If you add middleware to a command’s middleware stack, only the specific command will execute the action.

const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");

const s3Client = new S3Client({ region: "us-west-2" });
const putObject = new PutObjectCommand(params);

// Middleware specific to putObject command.
putObject.middlewareStack.add(middleware, options);

await s3Client.send(putObject);

Deep Dive

In this section we will show you how SDK maintains the order of middleware in the stack. You can do it either by specifying absolute or relative location of middleware. When we say middleware is before another, it means the request is exposed to middleware earlier in the lifecycle. The response is exposed to middleware later in the lifecycle, as the middleware order is reversed for response.

Specifying the absolute location of your middleware

The example above adds middleware to build step of middleware stack. The middleware stack contains five steps to manage a request’s lifecycle:

  • The initialize lifecycle step initializes an API call. This step typically adds default input values to a command. The HTTP request has not yet been constructed.
  • The serialize lifecycle step constructs an HTTP request for the API call. Example of typical serialization tasks include input validation and building an HTTP request from user input. The downstream middleware will have access to serialized HTTP request object in callback’s parameter args.request.
  • The build lifecycle step builds on top of serialized HTTP request. Examples of typical build tasks include injecting HTTP headers that describe a stable aspect of the request, such as Content-Length or a body checksum. Any request alterations will be applied to all retries.
  • The finalizeRequest lifecycle step prepares the request to be sent over the wire. The request in this stage is semantically complete and should therefore only be altered to match the recipient’s expectations. Examples of typical finalization tasks include request signing, performing retries and injecting hop-by-hop headers.
  • The deserialize lifecycle step deserializes the raw response object to a structured response. The upstream middleware have access to deserialized data in next callbacks return value: result.output.

Each middleware must be added to a specific step. By default each middleware in the same step has undifferentiated order. In some cases, you might want to execute a middleware before or after another middleware in the same step. You can achieve it by specifying its priority:

client.middlewareStack.add(middleware, {
  step: "initialize",
  priority: "high", // or "low".
});

Specifying the relative location of your middleware

In some cases, you might want to add your middleware immediately before or after a previously added middleware. Here an example to log something immediately before signing:

const { awsAuthMiddlewareOptions } = require("@aws-sdk/middleware-signing");

client.middlewareStack.addRelativeTo(logMiddleware, {
  relation: "before", // or "after".
  toMiddleware: awsAuthMiddlewareOptions.name,
});

When we add a middleware relative to a given middleware, the given middleware must have a unique name. SDK throws if the middleware referred by toMiddleware does not exist in the stack.

If multiple middleware are added before a given middleware, the last added middleware stays close to the given middleware.

const relativity = {
  relation: "after",
  toMiddleware: name /* name of middlewareA */,
};

client.middlewareStack.addRelative(middleareB, relativity);
client.middlewareStack.addRelative(middleareC, relativity);
client.middlewareStack.addRelative(middleareD, relativity);

// Order would be: A >> D >> C >> B

Please note that you must avoid cyclic relationship among the middleware. It results in undefined behavior. For example:

// This is wrong because A and B pair can be put placed anywhere in the stack
client.middlewareStack.addRelative(middlewareB, {
  name: "middlewareB",
  relation: "before",
  toMiddleware: "middlewareA"
});
client.middlewareStack.addRelative(middlewareB, {
  name: "middlewareA",
  relation: "after",
  toMiddleware: "middlewareB"
});

Level-up: Writing your Plugin

For a complex use case where multiple middleware are involved, the v3 SDK provides another useful interface called Pluggable. You can write a plugin with pluggable interface that adds or removes more than just one middleware.

An example of a plugin that profiles the latency of an API call round trip and individual HTTP requests:

const plugin = {
  applyToStack: (stack) => {
    // Middleware added to mark start and end of an complete API call.
    stack.add(
      (next, context) => async (args) => {
        const start = process.hrtime.bigint();
        const result = await next(args);
        const end = process.hrtime.bigint();
        console.info(`API call round trip uses ${end - start} nanoseconds`);
        return result;
      },
      { tags: ["ROUND_TRIP"] }
    );

    // Middleware added to mark start and end of each HTTP requests including retry.
    stack.add(
      (next, context) => async (args) => {
        const start = process.hrtime.bigint();
        const result = await next(args);
        const end = process.hrtime.bigint();
        console.info(`HTTP request completes in ${end - start} nanoseconds`);
        return result;
      },
      { step: "deserialize", priority: "low", tags: ["ROUND_TRIP"] }
    );
  },
};

client.middlewareStack.use(plugin);

The Pluggable interface provides a higher level abstraction for complex customizations.

Feedback

We value your feedback, so please tell us what you like and don’t like by opening an issue on GitHub.

Allan Zheng

Allan Zheng

Allan is maintainer of AWS SDK for JavaScript in Node.js and browser. He builds tools helping users navigating the AWS. Find him on GitHub @AllanZhengYP.