diff --git a/.changeset/pink-mangos-relate.md b/.changeset/pink-mangos-relate.md new file mode 100644 index 00000000000..7882b7a7576 --- /dev/null +++ b/.changeset/pink-mangos-relate.md @@ -0,0 +1,5 @@ +--- +"@smithy/undici-http-handler": major +--- + +Add HttpHandler backed by Node.js undici diff --git a/api-snapshot/api.json b/api-snapshot/api.json index 0327a8a71a7..1dc717f2f0f 100644 --- a/api-snapshot/api.json +++ b/api-snapshot/api.json @@ -1321,6 +1321,10 @@ "WaiterConfiguration": "type(interface)", "WithSdkStreamMixin": "type(object)" }, + "@smithy/undici-http-handler": { + "UndiciHttpHandler": "function", + "UndiciHttpHandlerOptions": "type(interface)" + }, "@smithy/url-parser": { "parseUrl": "function" }, diff --git a/packages/undici-http-handler/.gitignore b/packages/undici-http-handler/.gitignore new file mode 100644 index 00000000000..3d1714c9806 --- /dev/null +++ b/packages/undici-http-handler/.gitignore @@ -0,0 +1,8 @@ +/node_modules/ +/build/ +/coverage/ +/docs/ +*.tsbuildinfo +*.tgz +*.log +package-lock.json diff --git a/packages/undici-http-handler/CHANGELOG.md b/packages/undici-http-handler/CHANGELOG.md new file mode 100644 index 00000000000..f9fa3fbef48 --- /dev/null +++ b/packages/undici-http-handler/CHANGELOG.md @@ -0,0 +1,6 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +See [@smithy/undici-http-handler](https://github.com/smithy/smithy-typescript/blob/main/packages/undici-http-handler/CHANGELOG.md) for additional history. diff --git a/packages/undici-http-handler/LICENSE b/packages/undici-http-handler/LICENSE new file mode 100644 index 00000000000..7b6491ba787 --- /dev/null +++ b/packages/undici-http-handler/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018-2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/undici-http-handler/README.md b/packages/undici-http-handler/README.md new file mode 100644 index 00000000000..3ac7451fe44 --- /dev/null +++ b/packages/undici-http-handler/README.md @@ -0,0 +1,122 @@ +# @smithy/undici-http-handler + +[![NPM version](https://img.shields.io/npm/v/@smithy/undici-http-handler/latest.svg)](https://www.npmjs.com/package/@smithy/undici-http-handler) +[![NPM downloads](https://img.shields.io/npm/dm/@smithy/undici-http-handler.svg)](https://www.npmjs.com/package/@smithy/undici-http-handler) + +Smithy-compatible HTTP handler backed by Node.js [undici][]. + +## Usage + +Use `UndiciHttpHandler` as a Smithy-compatible request handler for generated +clients. It uses undici for HTTP transport, and accepts optional undici +`Dispatcher` to set up transport details. + +### Basic example + +```js +import { S3 } from "@aws-sdk/client-s3"; +import { UndiciHttpHandler } from "@smithy/undici-http-handler"; + +const client = new S3({ + requestHandler: new UndiciHttpHandler(), +}); + +client.listBuckets().then(console.log); +``` + +### Configuring undici Dispatcher + +You can pass `Agent.Options` to configure transport behavior such as connection +pooling and timeouts. The handler creates an `Agent` internally for you. + +```js +import { S3 } from "@aws-sdk/client-s3"; +import { UndiciHttpHandler } from "@smithy/undici-http-handler"; + +const client = new S3({ + requestHandler: new UndiciHttpHandler({ + dispatcher: { + connections: 50, + headersTimeout: 3000, + bodyTimeout: 3000, + connect: { + timeout: 3000, + }, + }, + }), +}); + +client.listBuckets().then(console.log); +``` + +Alternatively, pass an existing undici `Dispatcher` instance (Agent, Pool, +Client, etc.) directly if you need full control over its lifecycle. + +```js +import { S3 } from "@aws-sdk/client-s3"; +import { UndiciHttpHandler } from "@smithy/undici-http-handler"; +import { Agent } from "undici"; + +const dispatcher = new Agent({ + connections: 50, + headersTimeout: 3000, + bodyTimeout: 3000, + connect: { + timeout: 3000, + }, +}); + +const client = new S3({ + requestHandler: new UndiciHttpHandler({ dispatcher }), +}); + +client.listBuckets().then(console.log); +``` + +If your application only talks to a single origin (e.g. a Lambda function +calling one service endpoint), you can pass a `Client` for lower overhead by +skipping the per-origin routing that `Agent` performs. + +```js +import { DynamoDB } from "@aws-sdk/client-dynamodb"; +import { UndiciHttpHandler } from "@smithy/undici-http-handler"; +import { Client } from "undici"; + +const region = "us-east-1"; +const endpoint = `https://dynamodb.${region}.amazonaws.com`; + +const dispatcher = new Client(endpoint, { + pipelining: 1, + connect: { timeout: 3000 }, +}); + +const client = new DynamoDB({ + region, + endpoint, + requestHandler: new UndiciHttpHandler({ dispatcher }), +}); + +client.listTables({}).then(console.log); +``` + +> **Note:** When you pass a `Dispatcher` instance, the handler treats it as +> externally owned and will not destroy it when `handler.destroy()` is called. +> When you pass `Agent.Options`, the handler owns the created Agent and will +> destroy it on cleanup. + +## Benchmarks + +Our benchmark spin up a local HTTP server and runs two scenarios: + +- **10 sequential GETs** – measures per-request latency when requests are issued + one after another. +- **50 concurrent GETs** – measures throughput under parallel load using + `Promise.all`. + +The results show UndiciHttpHandler spends **20%-30%** less time in request handling +as compared to NodeHttpHandler from `@smithy/node-http-handler`. + +We recommend running benchmarks for your own use case on your own setup, as +results will vary depending on workload, network conditions, and environment. + +[undici]: https://undici.nodejs.org/ diff --git a/packages/undici-http-handler/api-extractor.json b/packages/undici-http-handler/api-extractor.json new file mode 100644 index 00000000000..b03e22a16a0 --- /dev/null +++ b/packages/undici-http-handler/api-extractor.json @@ -0,0 +1,4 @@ +{ + "extends": "../../api-extractor.packages.json", + "mainEntryPointFilePath": "./dist-types/index.d.ts" +} diff --git a/packages/undici-http-handler/package.json b/packages/undici-http-handler/package.json new file mode 100644 index 00000000000..a665bc62f06 --- /dev/null +++ b/packages/undici-http-handler/package.json @@ -0,0 +1,72 @@ +{ + "name": "@smithy/undici-http-handler", + "version": "0.5.0", + "description": "Smithy-compatible HTTP handler backed by Node.js undici", + "scripts": { + "build": "concurrently 'yarn:build:types' 'yarn:build:es:cjs'", + "build:es:cjs": "yarn g:tsc -p tsconfig.es.json && node ../../scripts/inline undici-http-handler", + "build:types": "yarn g:tsc -p tsconfig.types.json", + "build:types:downlevel": "premove dist-types/ts3.4 && downlevel-dts dist-types dist-types/ts3.4", + "clean": "premove dist-cjs dist-es dist-types tsconfig.cjs.tsbuildinfo tsconfig.es.tsbuildinfo tsconfig.types.tsbuildinfo", + "extract:docs": "api-extractor run --local", + "format": "prettier --config ../../prettier.config.js --ignore-path ../../.prettierignore --write \"**/*.{ts,md,json}\"", + "lint": "eslint -c ../../.eslintrc.js \"src/**/*.ts\"", + "stage-release": "premove .release && yarn pack && mkdir ./.release && tar zxvf ./package.tgz --directory ./.release && rm ./package.tgz", + "test": "yarn g:vitest run", + "test:bench": "yarn g:vitest bench --config vitest.config.bench.mts", + "test:watch": "yarn g:vitest watch" + }, + "author": { + "name": "AWS SDK for JavaScript Team", + "email": "", + "url": "https://aws.amazon.com/javascript/" + }, + "license": "Apache-2.0", + "sideEffects": false, + "main": "./dist-cjs/index.js", + "module": "./dist-es/index.js", + "types": "./dist-types/index.d.ts", + "dependencies": { + "@smithy/core": "workspace:^", + "@smithy/types": "workspace:^", + "tslib": "^2.6.2", + "undici": "^6.0.0" + }, + "devDependencies": { + "@smithy/abort-controller": "workspace:^", + "@smithy/node-http-handler": "workspace:^", + "@types/node": "^18.11.9", + "concurrently": "7.0.0", + "downlevel-dts": "0.10.1", + "premove": "4.0.0", + "typedoc": "0.23.23" + }, + "peerDependencies": { + "undici": "^6.0.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "typesVersions": { + "<4.5": { + "dist-types/*": [ + "dist-types/ts3.4/*" + ] + } + }, + "files": [ + "dist-*/**" + ], + "homepage": "https://github.com/smithy-lang/smithy-typescript/tree/main/packages/undici-http-handler", + "repository": { + "type": "git", + "url": "https://github.com/smithy-lang/smithy-typescript.git", + "directory": "packages/undici-http-handler" + }, + "typedoc": { + "entryPoint": "src/index.ts" + }, + "publishConfig": { + "directory": ".release/package" + } +} diff --git a/packages/undici-http-handler/src/index.ts b/packages/undici-http-handler/src/index.ts new file mode 100644 index 00000000000..dc6af57f7a5 --- /dev/null +++ b/packages/undici-http-handler/src/index.ts @@ -0,0 +1,2 @@ +export { UndiciHttpHandler } from "./undici-http-handler"; +export type { UndiciHttpHandlerOptions } from "./undici-http-handler"; diff --git a/packages/undici-http-handler/src/undici-http-handler.bench.ts b/packages/undici-http-handler/src/undici-http-handler.bench.ts new file mode 100644 index 00000000000..d82081b1bbb --- /dev/null +++ b/packages/undici-http-handler/src/undici-http-handler.bench.ts @@ -0,0 +1,129 @@ +import { once } from "node:events"; +import { createServer } from "node:http"; +import { HttpRequest } from "@smithy/core/protocols"; +import { NodeHttpHandler } from "@smithy/node-http-handler"; +import { Agent } from "undici"; +import { afterAll, beforeAll, bench, describe } from "vitest"; + +import { UndiciHttpHandler } from "./undici-http-handler"; + +// --------------------------------------------------------------------------- +// 1. Spin up a local HTTP server +// --------------------------------------------------------------------------- + +const RESPONSE_BODY = JSON.stringify({ ok: true, ts: Date.now() }); + +const server = createServer((_req, res) => { + res.writeHead(200, { + "content-type": "application/json", + "content-length": Buffer.byteLength(RESPONSE_BODY), + }); + res.end(RESPONSE_BODY); +}); + +// --------------------------------------------------------------------------- +// 2. Helper to build a Smithy HttpRequest targeting the local server +// --------------------------------------------------------------------------- + +let port: number; + +function makeRequest(overrides = {}) { + return Object.assign( + new HttpRequest({ + protocol: "http:", + hostname: "127.0.0.1", + port, + method: "GET", + path: "/", + headers: {}, + }), + overrides + ); +} + +// Drain the response body so the connection can be reused. +async function drain(response: { body?: AsyncIterable }) { + if (response.body) { + for await (const _ of response.body) { + // discard + } + } +} + +// --------------------------------------------------------------------------- +// 3. Create handler instances +// --------------------------------------------------------------------------- + +const nodeHandler = new NodeHttpHandler({ + connectionTimeout: 3000, + requestTimeout: 3000, +}); + +const undiciDispatcher = new Agent({ + bodyTimeout: 3000, + headersTimeout: 3000, + connect: { + timeout: 3000, + }, +}); +const undiciHandler = new UndiciHttpHandler({ dispatcher: undiciDispatcher }); + +// --------------------------------------------------------------------------- +// 4. Lifecycle +// --------------------------------------------------------------------------- + +beforeAll(async () => { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const addr = server.address(); + port = typeof addr === "object" && addr !== null ? addr.port : 0; + + // Warm up both handlers so first-request setup cost is excluded. + await drain((await nodeHandler.handle(makeRequest())).response); + await drain((await undiciHandler.handle(makeRequest())).response); +}); + +afterAll(() => { + nodeHandler.destroy(); + undiciHandler.destroy(); + undiciDispatcher.destroy(); + server.close(); +}); + +// --------------------------------------------------------------------------- +// 5. Benchmarks +// --------------------------------------------------------------------------- + +describe("10 sequential GETs", () => { + bench("NodeHttpHandler", async () => { + for (let i = 0; i < 10; i++) { + const { response } = await nodeHandler.handle(makeRequest()); + await drain(response); + } + }); + + bench("UndiciHttpHandler", async () => { + for (let i = 0; i < 10; i++) { + const { response } = await undiciHandler.handle(makeRequest()); + await drain(response); + } + }); +}); + +describe("50 concurrent GETs", () => { + bench("NodeHttpHandler", async () => { + const tasks = Array.from({ length: 50 }, async () => { + const { response } = await nodeHandler.handle(makeRequest()); + await drain(response); + }); + await Promise.all(tasks); + }); + + bench("UndiciHttpHandler", async () => { + const tasks = Array.from({ length: 50 }, async () => { + const { response } = await undiciHandler.handle(makeRequest()); + await drain(response); + }); + await Promise.all(tasks); + }); +}); diff --git a/packages/undici-http-handler/src/undici-http-handler.spec.ts b/packages/undici-http-handler/src/undici-http-handler.spec.ts new file mode 100644 index 00000000000..c2fbebb6c27 --- /dev/null +++ b/packages/undici-http-handler/src/undici-http-handler.spec.ts @@ -0,0 +1,573 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { HttpRequest } from "@smithy/core/protocols"; +import { Agent, type Dispatcher } from "undici"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +import { UndiciHttpHandler } from "./undici-http-handler"; + +let server: Server; +let port: number; + +function createMockRequest(overrides: Partial = {}): HttpRequest { + return Object.assign( + new HttpRequest({ + protocol: "http:", + hostname: "127.0.0.1", + port, + method: "GET", + path: "/", + headers: {}, + }), + overrides + ); +} + +function createMockLogger() { + return { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +beforeAll(async () => { + server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url!, `http://localhost`); + + if (url.pathname === "/delay") { + const parsedMs = parseInt(url.searchParams.get("ms") ?? "1000", 10); + const ms = Number.isFinite(parsedMs) ? Math.min(Math.max(parsedMs, 0), 5000) : 1000; + setTimeout(() => { + res.writeHead(200, { "content-type": "text/plain" }); + res.end("delayed"); + }, ms); + return; + } + + if (url.pathname === "/echo") { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + res.writeHead(200, { + "content-type": "application/octet-stream", + "x-method": req.method!, + "x-url": req.url!, + }); + res.end(Buffer.concat(chunks)); + }); + return; + } + + if (url.pathname === "/multi-header") { + // Manually write raw response with duplicate headers + res.writeHead(200, [ + ["set-cookie", "a=1"], + ["set-cookie", "b=2"], + ]); + res.end("ok"); + return; + } + + res.writeHead(200, { "content-type": "text/plain" }); + res.end("ok"); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + port = (server.address() as AddressInfo).port; + resolve(); + }); + }); +}); + +afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); +}); + +describe("UndiciHttpHandler", () => { + let handler: UndiciHttpHandler; + + afterEach(() => { + handler?.destroy(); + }); + + describe("handle", () => { + it("makes a basic GET request", async () => { + handler = new UndiciHttpHandler(); + const { response } = await handler.handle(createMockRequest()); + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toBe("text/plain"); + }); + + it("sends request body on POST", async () => { + handler = new UndiciHttpHandler(); + const body = "hello world"; + const { response } = await handler.handle( + createMockRequest({ + method: "POST", + path: "/echo", + body, + headers: { "content-type": "text/plain" }, + }) + ); + expect(response.statusCode).toBe(200); + expect(response.headers["x-method"]).toBe("POST"); + const text = await new Response(response.body).text(); + expect(text).toBe("hello world"); + }); + + it("sends Buffer body", async () => { + handler = new UndiciHttpHandler(); + const body = Buffer.from("buffer body"); + const { response } = await handler.handle( + createMockRequest({ + method: "POST", + path: "/echo", + headers: { "content-type": "application/octet-stream" }, + body, + }) + ); + expect(response.statusCode).toBe(200); + const text = await new Response(response.body).text(); + expect(text).toBe("buffer body"); + }); + + it("appends query string to path", async () => { + handler = new UndiciHttpHandler(); + const { response } = await handler.handle( + createMockRequest({ + path: "/echo", + query: { foo: "bar", baz: "qux" }, + }) + ); + expect(response.statusCode).toBe(200); + expect(response.headers["x-url"]).toContain("foo=bar"); + expect(response.headers["x-url"]).toContain("baz=qux"); + }); + + it("joins multi-value response headers with comma", async () => { + handler = new UndiciHttpHandler(); + const { response } = await handler.handle(createMockRequest({ path: "/multi-header" })); + expect(response.statusCode).toBe(200); + expect(response.headers["set-cookie"]).toBe("a=1, b=2"); + }); + + it("handles fragment in path", async () => { + handler = new UndiciHttpHandler(); + const { response } = await handler.handle( + createMockRequest({ + path: "/echo", + fragment: "section1", + } as any) + ); + expect(response.statusCode).toBe(200); + }); + }); + + describe("abort signal", () => { + it("throws AbortError if signal is already aborted", async () => { + handler = new UndiciHttpHandler(); + const controller = new AbortController(); + controller.abort(); + await expect( + handler.handle(createMockRequest(), { + abortSignal: controller.signal as any, + }) + ).rejects.toThrow("Request aborted"); + }); + + it("throws error with name AbortError if signal is already aborted", async () => { + handler = new UndiciHttpHandler(); + const controller = new AbortController(); + controller.abort(); + try { + await handler.handle(createMockRequest(), { + abortSignal: controller.signal as any, + }); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.name).toBe("AbortError"); + } + }); + + it("throws AbortError when signal is aborted during request", async () => { + handler = new UndiciHttpHandler(); + const controller = new AbortController(); + setTimeout(() => controller.abort(), 10); + await expect( + handler.handle(createMockRequest({ path: "/delay?ms=5000" }), { + abortSignal: controller.signal as any, + }) + ).rejects.toThrow(); + }); + + it("sets name to AbortError on UND_ERR_ABORTED and preserves original error", async () => { + const abortError = Object.assign(new Error("aborted"), { + code: "UND_ERR_ABORTED", + }); + const mockDispatcher = { + request: vi.fn().mockRejectedValue(abortError), + destroy: vi.fn(), + } as unknown as Dispatcher; + + handler = new UndiciHttpHandler({ dispatcher: mockDispatcher }); + try { + await handler.handle(createMockRequest()); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.name).toBe("AbortError"); + expect(err.code).toBe("UND_ERR_ABORTED"); + expect(err).toBe(abortError); + } + }); + }); + + describe("timeouts", () => { + it("uses requestTimeout from handle options", async () => { + handler = new UndiciHttpHandler(); + await expect( + handler.handle(createMockRequest({ path: "/delay?ms=5000" }), { + requestTimeout: 50, + }) + ).rejects.toThrow(); + }); + + it("sets name to TimeoutError on UND_ERR_HEADERS_TIMEOUT and preserves original error", async () => { + const timeoutError = Object.assign(new Error("headers timed out"), { + code: "UND_ERR_HEADERS_TIMEOUT", + }); + const mockDispatcher = { + request: vi.fn().mockRejectedValue(timeoutError), + destroy: vi.fn(), + } as unknown as Dispatcher; + + handler = new UndiciHttpHandler({ dispatcher: mockDispatcher }); + try { + await handler.handle(createMockRequest()); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.name).toBe("TimeoutError"); + expect(err.code).toBe("UND_ERR_HEADERS_TIMEOUT"); + expect(err).toBe(timeoutError); + } + }); + + it("sets name to TimeoutError on UND_ERR_BODY_TIMEOUT and preserves original error", async () => { + const timeoutError = Object.assign(new Error("body timed out"), { + code: "UND_ERR_BODY_TIMEOUT", + }); + const mockDispatcher = { + request: vi.fn().mockRejectedValue(timeoutError), + destroy: vi.fn(), + } as unknown as Dispatcher; + + handler = new UndiciHttpHandler({ dispatcher: mockDispatcher }); + try { + await handler.handle(createMockRequest()); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.name).toBe("TimeoutError"); + expect(err.code).toBe("UND_ERR_BODY_TIMEOUT"); + expect(err).toBe(timeoutError); + } + }); + + it("sets name to TimeoutError on UND_ERR_CONNECT_TIMEOUT and preserves original error", async () => { + const timeoutError = Object.assign(new Error("connect timed out"), { + code: "UND_ERR_CONNECT_TIMEOUT", + }); + const mockDispatcher = { + request: vi.fn().mockRejectedValue(timeoutError), + destroy: vi.fn(), + } as unknown as Dispatcher; + + handler = new UndiciHttpHandler({ dispatcher: mockDispatcher }); + try { + await handler.handle(createMockRequest()); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.name).toBe("TimeoutError"); + expect(err.code).toBe("UND_ERR_CONNECT_TIMEOUT"); + expect(err).toBe(timeoutError); + } + }); + }); + + describe("socket errors", () => { + it("sets name to RequestTimeout on UND_ERR_SOCKET and preserves original error", async () => { + const socketError = Object.assign(new Error("socket error"), { + code: "UND_ERR_SOCKET", + }); + const mockDispatcher = { + request: vi.fn().mockRejectedValue(socketError), + destroy: vi.fn(), + } as unknown as Dispatcher; + + handler = new UndiciHttpHandler({ dispatcher: mockDispatcher }); + try { + await handler.handle(createMockRequest()); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.name).toBe("RequestTimeout"); + expect(err.code).toBe("UND_ERR_SOCKET"); + expect(err).toBe(socketError); + } + }); + }); + + describe("unknown errors", () => { + it("rethrows unknown errors unmodified", async () => { + const unknownError = new Error("something unexpected"); + const mockDispatcher = { + request: vi.fn().mockRejectedValue(unknownError), + destroy: vi.fn(), + } as unknown as Dispatcher; + + handler = new UndiciHttpHandler({ dispatcher: mockDispatcher }); + try { + await handler.handle(createMockRequest()); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err).toBe(unknownError); + expect(err.message).toBe("something unexpected"); + } + }); + }); + + describe("external dispatcher", () => { + it("uses provided dispatcher", async () => { + const mockDispatcher = { + request: vi.fn().mockResolvedValue({ + statusCode: 201, + headers: { "x-custom": "value" }, + body: null, + }), + destroy: vi.fn(), + } as unknown as Dispatcher; + + handler = new UndiciHttpHandler({ dispatcher: mockDispatcher }); + const { response } = await handler.handle(createMockRequest()); + expect(response.statusCode).toBe(201); + expect(response.headers["x-custom"]).toBe("value"); + expect(mockDispatcher.request).toHaveBeenCalledOnce(); + }); + + it("does not destroy external dispatcher on handler destroy", () => { + const mockDispatcher = { + destroy: vi.fn(), + } as unknown as Dispatcher; + + handler = new UndiciHttpHandler({ dispatcher: mockDispatcher }); + handler.destroy(); + expect(mockDispatcher.destroy).not.toHaveBeenCalled(); + }); + }); + + describe("expect header", () => { + it.each(["expect", "Expect"])("strips '%s' header before sending to undici", async (expectHeader) => { + const mockDispatcher = { + request: vi.fn().mockResolvedValue({ + statusCode: 200, + headers: {}, + body: null, + }), + destroy: vi.fn(), + } as unknown as Dispatcher; + + handler = new UndiciHttpHandler({ dispatcher: mockDispatcher }); + await handler.handle( + createMockRequest({ + method: "PUT", + headers: { + "content-type": "application/octet-stream", + [expectHeader]: "100-continue", + }, + }) + ); + + const callArgs = (mockDispatcher.request as any).mock.calls[0][0]; + expect(callArgs.headers).not.toHaveProperty(expectHeader); + }); + }); + + describe("transfer-encoding header", () => { + it.each(["transfer-encoding", "Transfer-Encoding"])( + "strips '%s' header when body is a stream", + async (transferEncodingHeader) => { + const mockDispatcher = { + request: vi.fn().mockResolvedValue({ + statusCode: 200, + headers: {}, + body: null, + }), + destroy: vi.fn(), + } as unknown as Dispatcher; + + // Duck-type a stream-like body (has pipe and on methods) + const streamBody = { + pipe: vi.fn(), + on: vi.fn(), + }; + + handler = new UndiciHttpHandler({ dispatcher: mockDispatcher }); + await handler.handle( + createMockRequest({ + method: "PUT", + headers: { + "content-type": "application/octet-stream", + [transferEncodingHeader]: "chunked", + }, + body: streamBody as any, + }) + ); + + const callArgs = (mockDispatcher.request as any).mock.calls[0][0]; + expect(callArgs.headers).not.toHaveProperty(transferEncodingHeader); + } + ); + }); + + describe("destroy", () => { + it("destroys internal dispatcher", async () => { + handler = new UndiciHttpHandler(); + // Trigger dispatcher creation + await handler.handle(createMockRequest()); + // Should not throw + handler.destroy(); + }); + + it("is safe to call multiple times", () => { + handler = new UndiciHttpHandler(); + handler.destroy(); + handler.destroy(); + }); + }); + + describe("updateHttpClientConfig / httpHandlerConfigs", () => { + it("returns config before first request", () => { + const logger = createMockLogger(); + handler = new UndiciHttpHandler({ logger }); + expect(handler.httpHandlerConfigs()).toEqual({ logger }); + }); + + it("returns config after first request", async () => { + const logger = createMockLogger(); + handler = new UndiciHttpHandler({ logger }); + await handler.handle(createMockRequest()); + const configs = handler.httpHandlerConfigs(); + expect(configs.logger).toBe(logger); + }); + + it("updates config", async () => { + const logger = createMockLogger(); + const updatedLogger = createMockLogger(); + handler = new UndiciHttpHandler({ logger }); + await handler.handle(createMockRequest()); + handler.updateHttpClientConfig("logger", updatedLogger); + // Config is reset, need another request to resolve + await handler.handle(createMockRequest()); + expect(handler.httpHandlerConfigs().logger).toBe(updatedLogger); + }); + + it("retains existing dispatcher if undefined is passed", () => { + handler = new UndiciHttpHandler(); + const configBefore = handler.httpHandlerConfigs(); + handler.updateHttpClientConfig("dispatcher", undefined as any); + const configAfter = handler.httpHandlerConfigs(); + expect(configAfter.dispatcher).toBe(configBefore.dispatcher); + }); + + it("accepts Agent.Options and creates an Agent internally", async () => { + handler = new UndiciHttpHandler(); + handler.updateHttpClientConfig("dispatcher", { connections: 2 } as any); + + const { response } = await handler.handle(createMockRequest()); + expect(response.statusCode).toBe(200); + }); + + it("does not destroy previous dispatcher when validation fails", async () => { + handler = new UndiciHttpHandler(); + // Trigger internal dispatcher creation + await handler.handle(createMockRequest()); + + expect(() => handler.updateHttpClientConfig("dispatcher", "invalid" as any)).toThrow( + "must be an instance of undici Dispatcher or Agent.Options" + ); + + // Handler should still work with its internal dispatcher + const { response } = await handler.handle(createMockRequest()); + expect(response.statusCode).toBe(200); + }); + + it("closes previous internal dispatcher when updating with a new Dispatcher", async () => { + handler = new UndiciHttpHandler(); + // Trigger internal dispatcher creation + await handler.handle(createMockRequest()); + + // Capture the internal dispatcher and spy on its close method + const previousDispatcher = handler.httpHandlerConfigs().dispatcher!; + const closeSpy = vi.spyOn(previousDispatcher, "close"); + + const newDispatcher = new Agent(); + + handler.updateHttpClientConfig("dispatcher", newDispatcher); + + // Assert the previous internal dispatcher's close was called (fire-and-forget) + expect(closeSpy).toHaveBeenCalled(); + closeSpy.mockRestore(); + + // The new dispatcher should be used for subsequent requests + const { response } = await handler.handle(createMockRequest()); + expect(response.statusCode).toBe(200); + + newDispatcher.destroy(); + }); + + it("does not destroy previous external dispatcher when updating", async () => { + const oldDispatcher = new Agent(); + + handler = new UndiciHttpHandler({ dispatcher: oldDispatcher }); + await handler.handle(createMockRequest()); + + const newDispatcher = new Agent(); + + handler.updateHttpClientConfig("dispatcher", newDispatcher); + + // Old external dispatcher should still be usable (not destroyed) + const { statusCode } = await oldDispatcher.request({ + origin: `http://127.0.0.1:${port}`, + path: "/", + method: "GET", + }); + expect(statusCode).toBe(200); + + oldDispatcher.destroy(); + newDispatcher.destroy(); + }); + + it("marks new dispatcher as external after update", async () => { + const newDispatcher = new Agent(); + + handler = new UndiciHttpHandler(); + handler.updateHttpClientConfig("dispatcher", newDispatcher); + + // destroy() should not destroy an external dispatcher — handler still usable after + handler.destroy(); + + // The dispatcher should still be functional since it's external + const { statusCode } = await newDispatcher.request({ + origin: `http://127.0.0.1:${port}`, + path: "/", + method: "GET", + }); + expect(statusCode).toBe(200); + + newDispatcher.destroy(); + }); + }); +}); diff --git a/packages/undici-http-handler/src/undici-http-handler.ts b/packages/undici-http-handler/src/undici-http-handler.ts new file mode 100644 index 00000000000..2dda181dac7 --- /dev/null +++ b/packages/undici-http-handler/src/undici-http-handler.ts @@ -0,0 +1,230 @@ +import type { Readable } from "node:stream"; +import { HttpResponse, buildQueryString, type HttpHandler, type HttpRequest } from "@smithy/core/protocols"; +import type { HttpHandlerOptions, Logger } from "@smithy/types"; +import { Agent, Dispatcher } from "undici"; + +/** + * Duck-type check: returns true if the value looks like a Dispatcher + * (has a `request` method), as opposed to plain Agent.Options. + */ +const isDispatcher = (value: unknown): value is Dispatcher => + value instanceof Dispatcher || + (typeof value === "object" && value !== null && typeof (value as any).request === "function"); + +/** + * Options for the UndiciHttpHandler. + * + * @public + */ +export interface UndiciHttpHandlerOptions { + /** + * You can pass an existing undici Dispatcher (Agent, Pool, Client, etc.) + * or Agent.Options to have one created for you. + */ + dispatcher?: Dispatcher | Agent.Options; + + /** + * Optional logger. + */ + logger?: Logger; +} + +/** + * An HTTP handler that uses undici instead of Node.js native http/https modules. + * Smithy-compatible request handler backed by undici. + * + * @public + */ +export class UndiciHttpHandler implements HttpHandler { + private config: { dispatcher?: Dispatcher; logger?: Logger }; + private externalDispatcher = false; + + constructor(options?: UndiciHttpHandlerOptions) { + if (options?.dispatcher && isDispatcher(options.dispatcher)) { + this.config = { ...options, dispatcher: options.dispatcher }; + this.externalDispatcher = true; + } else if (options?.dispatcher) { + // Caller passed Agent.Options — create an Agent for them. + this.config = { ...options, dispatcher: new Agent({ allowH2: true, ...options.dispatcher }) }; + } else { + this.config = { ...options } as { dispatcher?: Dispatcher; logger?: Logger }; + } + } + + public destroy(): void { + if (this.config.dispatcher && !this.externalDispatcher) { + this.config.dispatcher.destroy(); + this.config.dispatcher = undefined; + } + } + + public async handle( + request: HttpRequest, + { abortSignal, requestTimeout }: HttpHandlerOptions = {} + ): Promise<{ response: HttpResponse }> { + const dispatcher = this.getOrCreateDispatcher(); + + if (abortSignal?.aborted) { + throw Object.assign(new Error("Request aborted"), { + name: "AbortError", + }); + } + + // Build path with query string — skip buildQueryString when query is undefined. + let path = request.path; + if (request.query) { + const queryString = buildQueryString(request.query); + if (queryString) { + path += `?${queryString}`; + } + } + if (request.fragment) { + path += `#${request.fragment}`; + } + + // Build origin string. + const port = request.port ? `:${request.port}` : ""; + let origin: string; + if (request.username != null || request.password != null) { + const username = request.username ?? ""; + const password = request.password ?? ""; + origin = `${request.protocol}//${username}:${password}@${request.hostname}${port}`; + } else { + origin = `${request.protocol}//${request.hostname}${port}`; + } + + // Strip the Expect header — undici does not support 100-continue and + // sends the body immediately, so the header is unnecessary. + const headers = request.headers; + if (headers["Expect"] === "100-continue") delete headers["Expect"]; + if (headers["expect"] === "100-continue") delete headers["expect"]; + + // Strip transfer-encoding header for streaming bodies — undici manages + // chunked encoding internally for streams, so the explicit header is not + // needed and causes issues with content-length negotiation. + // Uses the same duck-typing check as undici's isStream (pipe + on). + const body = request.body as Readable | undefined; + if (body && typeof body.pipe === "function" && typeof body.on === "function") { + if (headers["transfer-encoding"] === "chunked") delete headers["transfer-encoding"]; + if (headers["Transfer-Encoding"] === "chunked") delete headers["Transfer-Encoding"]; + } + + // greater than 0 number or undefined. + const timeout: number | undefined = requestTimeout && requestTimeout > 0 ? requestTimeout : undefined; + const headersTimeout = timeout; + const bodyTimeout = timeout; + + try { + const { + statusCode, + headers: responseHeaders, + body: responseBody, + } = await dispatcher.request({ + origin, + path, + method: request.method as Dispatcher.HttpMethod, + headers, + body: request.body ?? null, + headersTimeout, + bodyTimeout, + signal: abortSignal as AbortSignal | undefined, + }); + + // Transform undici headers (Record) to HeaderBag (Record) + const transformedHeaders: Record = {}; + for (const key in responseHeaders) { + const value = responseHeaders[key]; + if (value !== undefined) { + transformedHeaders[key] = Array.isArray(value) ? value.join(", ") : value; + } + } + + const httpResponse = new HttpResponse({ + statusCode, + headers: transformedHeaders, + body: responseBody, + }); + + return { response: httpResponse }; + } catch (err: any) { + if (err?.code === "UND_ERR_ABORTED") { + throw Object.assign(err, { name: "AbortError" }); + } + + if ( + err?.code === "UND_ERR_BODY_TIMEOUT" || + err?.code === "UND_ERR_CONNECT_TIMEOUT" || + err?.code === "UND_ERR_HEADERS_TIMEOUT" + ) { + throw Object.assign(err, { name: "TimeoutError" }); + } + + if (err?.code === "UND_ERR_SOCKET") { + throw Object.assign(err, { name: "RequestTimeout" }); + } + throw err; + } + } + + public updateHttpClientConfig( + key: K, + value: UndiciHttpHandlerOptions[K] + ): void { + if (key !== "dispatcher") { + (this.config as any)[key] = value; + return; + } + + let newDispatcher: Dispatcher; + let isExternal: boolean; + + if (value === undefined) { + // Retain existing dispatcher, matching constructor behavior. + return; + } else if (isDispatcher(value)) { + newDispatcher = value; + isExternal = true; + } else if (typeof value === "object" && value !== null) { + // Caller passed Agent.Options — create an Agent for them. + newDispatcher = new Agent({ allowH2: true, ...(value as Agent.Options) }); + isExternal = false; + } else { + throw new Error( + "updateHttpClientConfig: value for 'dispatcher' must be an instance of undici Dispatcher or Agent.Options." + ); + } + + // No-op when the same dispatcher instance is reassigned. + if (newDispatcher === this.config.dispatcher) { + return; + } + + // Capture the previous dispatcher before assignment. + const previousDispatcher = this.config.dispatcher; + + // Close the previous dispatcher only if it was internally created. + // Fire-and-forget: let in-flight requests drain without blocking. + if (previousDispatcher && !this.externalDispatcher) { + previousDispatcher.close(); + } + + // Assign the new value and update externalDispatcher based on it. + this.config.dispatcher = newDispatcher; + this.externalDispatcher = isExternal; + } + + public httpHandlerConfigs(): { dispatcher?: Dispatcher; logger?: Logger } { + return { ...this.config }; + } + + private getOrCreateDispatcher(): Dispatcher { + const { config } = this; + const { dispatcher } = config; + + if (dispatcher) { + return dispatcher; + } + + return (config.dispatcher = new Agent({ allowH2: true })); + } +} diff --git a/packages/undici-http-handler/src/undici-http-handler.version.spec.ts b/packages/undici-http-handler/src/undici-http-handler.version.spec.ts new file mode 100644 index 00000000000..97d24941744 --- /dev/null +++ b/packages/undici-http-handler/src/undici-http-handler.version.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { dependencies, engines, peerDependencies } from "../package.json"; + +const EXPECTED_ENGINES_NODE = ">=18.0.0"; + +describe("undici version", () => { + it("dependencies[undici] should match peerDependencies[undici]", () => { + expect(dependencies["undici"]).toEqual(peerDependencies["undici"]); + }); + + it(`engines.node should be ${EXPECTED_ENGINES_NODE}`, () => { + expect( + engines.node, + "Note: If engines.node was updated to drop support for Node.js major version," + + " check if undici version can also be updated with this change." + ).toEqual(EXPECTED_ENGINES_NODE); + }); +}); diff --git a/packages/undici-http-handler/tsconfig.cjs.json b/packages/undici-http-handler/tsconfig.cjs.json new file mode 100644 index 00000000000..b8d89c2bcde --- /dev/null +++ b/packages/undici-http-handler/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist-cjs", + "rootDir": "src" + }, + "extends": "../../tsconfig.cjs.json", + "include": ["src/"], + "exclude": ["src/**/*.bench.ts"] +} diff --git a/packages/undici-http-handler/tsconfig.es.json b/packages/undici-http-handler/tsconfig.es.json new file mode 100644 index 00000000000..a072133b391 --- /dev/null +++ b/packages/undici-http-handler/tsconfig.es.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "lib": [], + "outDir": "dist-es", + "rootDir": "src" + }, + "extends": "../../tsconfig.es.json", + "include": ["src/"], + "exclude": ["src/**/*.bench.ts"] +} diff --git a/packages/undici-http-handler/tsconfig.types.json b/packages/undici-http-handler/tsconfig.types.json new file mode 100644 index 00000000000..ac30cf8de5e --- /dev/null +++ b/packages/undici-http-handler/tsconfig.types.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "declarationDir": "dist-types", + "rootDir": "src", + "skipLibCheck": true + }, + "extends": "../../tsconfig.types.json", + "include": ["src/"], + "exclude": ["src/**/*.bench.ts"] +} diff --git a/packages/undici-http-handler/vitest.config.bench.mts b/packages/undici-http-handler/vitest.config.bench.mts new file mode 100644 index 00000000000..245c1bf59f6 --- /dev/null +++ b/packages/undici-http-handler/vitest.config.bench.mts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["**/*.bench.ts"], + environment: "node", + }, +}); diff --git a/packages/undici-http-handler/vitest.config.mts b/packages/undici-http-handler/vitest.config.mts new file mode 100644 index 00000000000..82a0517935e --- /dev/null +++ b/packages/undici-http-handler/vitest.config.mts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["**/*.spec.ts"], + environment: "node", + }, +}); diff --git a/scripts/check-dependencies.js b/scripts/check-dependencies.js index 6237084c898..a460bd490f0 100644 --- a/scripts/check-dependencies.js +++ b/scripts/check-dependencies.js @@ -50,7 +50,7 @@ const node_libraries = [ for await (const file of walk(srcPath, ["node_modules"])) { const contents = fs.readFileSync(file); - if (file.endsWith(".spec.ts")) { + if (file.endsWith(".spec.ts") || file.endsWith(".bench.ts")) { continue; } diff --git a/yarn.lock b/yarn.lock index 33365cd111e..5d107c87461 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3530,6 +3530,26 @@ __metadata: languageName: unknown linkType: soft +"@smithy/undici-http-handler@workspace:packages/undici-http-handler": + version: 0.0.0-use.local + resolution: "@smithy/undici-http-handler@workspace:packages/undici-http-handler" + dependencies: + "@smithy/abort-controller": "workspace:^" + "@smithy/core": "workspace:^" + "@smithy/node-http-handler": "workspace:^" + "@smithy/types": "workspace:^" + "@types/node": "npm:^18.11.9" + concurrently: "npm:7.0.0" + downlevel-dts: "npm:0.10.1" + premove: "npm:4.0.0" + tslib: "npm:^2.6.2" + typedoc: "npm:0.23.23" + undici: "npm:^6.0.0" + peerDependencies: + undici: ^6.0.0 + languageName: unknown + linkType: soft + "@smithy/url-parser@workspace:packages/url-parser": version: 0.0.0-use.local resolution: "@smithy/url-parser@workspace:packages/url-parser" @@ -11769,6 +11789,13 @@ __metadata: languageName: node linkType: hard +"undici@npm:^6.0.0": + version: 6.25.0 + resolution: "undici@npm:6.25.0" + checksum: 10c0/2597cc6689bdb02c210c557b1f85febbfda65becae6e6fc1061508e2f33734d25207f81cd8af56ada9956329eb3a7bd7431e87dcfeceba20ee87059b57dcf985 + languageName: node + linkType: hard + "unique-filename@npm:^2.0.0": version: 2.0.1 resolution: "unique-filename@npm:2.0.1"