@slates/aws-sdk-http-handler 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # @slates/aws-sdk-http-handler
2
+
3
+ Axios-backed HTTP handler for AWS SDK for JavaScript v3 clients running inside Slates integrations.
4
+
5
+ ```ts
6
+ import { S3Client } from '@aws-sdk/client-s3';
7
+ import { createSlatesAwsSdkHttpHandler } from '@slates/aws-sdk-http-handler';
8
+
9
+ let s3 = new S3Client({
10
+ region,
11
+ credentials,
12
+ requestHandler: createSlatesAwsSdkHttpHandler()
13
+ });
14
+ ```
15
+
16
+ The handler uses Slates `createAxios()` by default, so SDK requests participate in Slates request context, logging, and sanitized HTTP tracing.
17
+
18
+ Do not use Axios interceptors to mutate AWS signed request material after the SDK signs the request. That includes method, path, query string, body, `Authorization`, `Host`, `x-amz-*`, and any headers listed in the request's signed headers. Future secret injection that changes signed material should happen through AWS SDK config or middleware before signing.
package/dist/index.cjs ADDED
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ SlatesAwsSdkHttpHandler: () => SlatesAwsSdkHttpHandler,
24
+ createSlatesAwsSdkHttpHandler: () => createSlatesAwsSdkHttpHandler
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+ var import_protocol_http = require("@smithy/protocol-http");
28
+ var import_querystring_builder = require("@smithy/querystring-builder");
29
+ var import_slates = require("slates");
30
+ var NODE_TIMEOUT_CODES = /* @__PURE__ */ new Set(["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT"]);
31
+ var NODE_NETWORK_CODES = /* @__PURE__ */ new Set(["EHOSTUNREACH", "ENETUNREACH", "ENOTFOUND"]);
32
+ var isRecord = (value) => typeof value === "object" && value !== null;
33
+ var normalizeHeaders = (headers) => {
34
+ let raw = headers && typeof headers.toJSON === "function" ? headers.toJSON() : headers;
35
+ let out = {};
36
+ for (let [key, value] of Object.entries(raw ?? {})) {
37
+ if (value == null) continue;
38
+ out[key.toLowerCase()] = Array.isArray(value) ? value.map(String).join(", ") : String(value);
39
+ }
40
+ return out;
41
+ };
42
+ var hasHeader = (headers, name) => Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
43
+ var buildRequestHeaders = (headers) => {
44
+ let out = { ...headers };
45
+ if (!hasHeader(out, "accept")) {
46
+ out.accept = "*/*";
47
+ }
48
+ return out;
49
+ };
50
+ var getSlateCode = (error) => {
51
+ if (!isRecord(error)) return void 0;
52
+ return typeof error.code === "string" ? error.code : void 0;
53
+ };
54
+ var getTransportCode = (error) => {
55
+ if (!isRecord(error)) return void 0;
56
+ let directCode = typeof error.code === "string" ? error.code : void 0;
57
+ if (directCode && !directCode.startsWith("upstream.")) return directCode;
58
+ let baggage = isRecord(error.data) && isRecord(error.data.baggage) ? error.data.baggage : void 0;
59
+ let axiosCode = baggage && typeof baggage.axiosCode === "string" ? baggage.axiosCode : void 0;
60
+ if (axiosCode) return axiosCode;
61
+ return getTransportCode(error.cause);
62
+ };
63
+ var messageForError = (error) => error instanceof Error ? error.message : String(error);
64
+ var createSdkTransportError = (error, name, code) => {
65
+ let sdkError = new Error(messageForError(error), { cause: error });
66
+ sdkError.name = name;
67
+ if (code) {
68
+ Object.assign(sdkError, { code });
69
+ }
70
+ return sdkError;
71
+ };
72
+ var normalizeTransportError = (error) => {
73
+ let slateCode = getSlateCode(error);
74
+ let transportCode = getTransportCode(error);
75
+ if (slateCode === "upstream.timeout" || transportCode === "ECONNABORTED" || transportCode && NODE_TIMEOUT_CODES.has(transportCode)) {
76
+ return createSdkTransportError(error, "TimeoutError", transportCode ?? "ETIMEDOUT");
77
+ }
78
+ if (slateCode === "upstream.network_error" || transportCode && NODE_NETWORK_CODES.has(transportCode)) {
79
+ return createSdkTransportError(error, "Error", transportCode);
80
+ }
81
+ return error;
82
+ };
83
+ var SlatesAwsSdkHttpHandler = class {
84
+ axiosClient;
85
+ config;
86
+ constructor(config = {}) {
87
+ this.config = config;
88
+ this.axiosClient = config.axiosInstance ?? (0, import_slates.createAxios)();
89
+ }
90
+ updateHttpClientConfig(key, value) {
91
+ this.config = { ...this.config, [key]: value };
92
+ }
93
+ httpHandlerConfigs() {
94
+ return this.config;
95
+ }
96
+ destroy() {
97
+ }
98
+ async handle(request, options = {}) {
99
+ let queryString = request.query ? (0, import_querystring_builder.buildQueryString)(request.query) : "";
100
+ let port = request.port ? `:${request.port}` : "";
101
+ let url = `${request.protocol}//${request.hostname}${port}${request.path}` + (queryString ? `?${queryString}` : "");
102
+ let method = request.method.toUpperCase();
103
+ try {
104
+ let axiosResponse = await this.axiosClient.request({
105
+ url,
106
+ method,
107
+ headers: buildRequestHeaders(request.headers),
108
+ data: method === "GET" || method === "HEAD" ? void 0 : request.body,
109
+ validateStatus: () => true,
110
+ responseType: "stream",
111
+ transformRequest: [(data) => data],
112
+ transformResponse: [(data) => data],
113
+ maxRedirects: 0,
114
+ decompress: false,
115
+ timeout: options.requestTimeout ?? this.config.requestTimeout,
116
+ signal: options.abortSignal
117
+ });
118
+ return {
119
+ response: new import_protocol_http.HttpResponse({
120
+ statusCode: axiosResponse.status,
121
+ reason: axiosResponse.statusText,
122
+ headers: normalizeHeaders(axiosResponse.headers),
123
+ body: axiosResponse.data
124
+ })
125
+ };
126
+ } catch (error) {
127
+ throw normalizeTransportError(error);
128
+ }
129
+ }
130
+ };
131
+ var createSlatesAwsSdkHttpHandler = (config) => new SlatesAwsSdkHttpHandler(config);
132
+ // Annotate the CommonJS export names for ESM import in node:
133
+ 0 && (module.exports = {
134
+ SlatesAwsSdkHttpHandler,
135
+ createSlatesAwsSdkHttpHandler
136
+ });
@@ -0,0 +1,22 @@
1
+ import { AxiosInstance } from 'axios';
2
+ import { HttpHandler, HttpRequest, HttpResponse } from '@smithy/protocol-http';
3
+ import { HttpHandlerOptions } from '@smithy/types';
4
+
5
+ interface SlatesAwsSdkHttpHandlerConfig {
6
+ axiosInstance?: AxiosInstance;
7
+ requestTimeout?: number;
8
+ }
9
+ declare class SlatesAwsSdkHttpHandler implements HttpHandler<SlatesAwsSdkHttpHandlerConfig> {
10
+ private readonly axiosClient;
11
+ private config;
12
+ constructor(config?: SlatesAwsSdkHttpHandlerConfig);
13
+ updateHttpClientConfig<K extends keyof SlatesAwsSdkHttpHandlerConfig>(key: K, value: SlatesAwsSdkHttpHandlerConfig[K]): void;
14
+ httpHandlerConfigs(): SlatesAwsSdkHttpHandlerConfig;
15
+ destroy(): void;
16
+ handle(request: HttpRequest, options?: HttpHandlerOptions): Promise<{
17
+ response: HttpResponse;
18
+ }>;
19
+ }
20
+ declare let createSlatesAwsSdkHttpHandler: (config?: SlatesAwsSdkHttpHandlerConfig) => SlatesAwsSdkHttpHandler;
21
+
22
+ export { SlatesAwsSdkHttpHandler, type SlatesAwsSdkHttpHandlerConfig, createSlatesAwsSdkHttpHandler };
@@ -0,0 +1,22 @@
1
+ import { AxiosInstance } from 'axios';
2
+ import { HttpHandler, HttpRequest, HttpResponse } from '@smithy/protocol-http';
3
+ import { HttpHandlerOptions } from '@smithy/types';
4
+
5
+ interface SlatesAwsSdkHttpHandlerConfig {
6
+ axiosInstance?: AxiosInstance;
7
+ requestTimeout?: number;
8
+ }
9
+ declare class SlatesAwsSdkHttpHandler implements HttpHandler<SlatesAwsSdkHttpHandlerConfig> {
10
+ private readonly axiosClient;
11
+ private config;
12
+ constructor(config?: SlatesAwsSdkHttpHandlerConfig);
13
+ updateHttpClientConfig<K extends keyof SlatesAwsSdkHttpHandlerConfig>(key: K, value: SlatesAwsSdkHttpHandlerConfig[K]): void;
14
+ httpHandlerConfigs(): SlatesAwsSdkHttpHandlerConfig;
15
+ destroy(): void;
16
+ handle(request: HttpRequest, options?: HttpHandlerOptions): Promise<{
17
+ response: HttpResponse;
18
+ }>;
19
+ }
20
+ declare let createSlatesAwsSdkHttpHandler: (config?: SlatesAwsSdkHttpHandlerConfig) => SlatesAwsSdkHttpHandler;
21
+
22
+ export { SlatesAwsSdkHttpHandler, type SlatesAwsSdkHttpHandlerConfig, createSlatesAwsSdkHttpHandler };
@@ -0,0 +1,110 @@
1
+ // src/index.ts
2
+ import { HttpResponse } from "@smithy/protocol-http";
3
+ import { buildQueryString } from "@smithy/querystring-builder";
4
+ import { createAxios } from "slates";
5
+ var NODE_TIMEOUT_CODES = /* @__PURE__ */ new Set(["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT"]);
6
+ var NODE_NETWORK_CODES = /* @__PURE__ */ new Set(["EHOSTUNREACH", "ENETUNREACH", "ENOTFOUND"]);
7
+ var isRecord = (value) => typeof value === "object" && value !== null;
8
+ var normalizeHeaders = (headers) => {
9
+ let raw = headers && typeof headers.toJSON === "function" ? headers.toJSON() : headers;
10
+ let out = {};
11
+ for (let [key, value] of Object.entries(raw ?? {})) {
12
+ if (value == null) continue;
13
+ out[key.toLowerCase()] = Array.isArray(value) ? value.map(String).join(", ") : String(value);
14
+ }
15
+ return out;
16
+ };
17
+ var hasHeader = (headers, name) => Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
18
+ var buildRequestHeaders = (headers) => {
19
+ let out = { ...headers };
20
+ if (!hasHeader(out, "accept")) {
21
+ out.accept = "*/*";
22
+ }
23
+ return out;
24
+ };
25
+ var getSlateCode = (error) => {
26
+ if (!isRecord(error)) return void 0;
27
+ return typeof error.code === "string" ? error.code : void 0;
28
+ };
29
+ var getTransportCode = (error) => {
30
+ if (!isRecord(error)) return void 0;
31
+ let directCode = typeof error.code === "string" ? error.code : void 0;
32
+ if (directCode && !directCode.startsWith("upstream.")) return directCode;
33
+ let baggage = isRecord(error.data) && isRecord(error.data.baggage) ? error.data.baggage : void 0;
34
+ let axiosCode = baggage && typeof baggage.axiosCode === "string" ? baggage.axiosCode : void 0;
35
+ if (axiosCode) return axiosCode;
36
+ return getTransportCode(error.cause);
37
+ };
38
+ var messageForError = (error) => error instanceof Error ? error.message : String(error);
39
+ var createSdkTransportError = (error, name, code) => {
40
+ let sdkError = new Error(messageForError(error), { cause: error });
41
+ sdkError.name = name;
42
+ if (code) {
43
+ Object.assign(sdkError, { code });
44
+ }
45
+ return sdkError;
46
+ };
47
+ var normalizeTransportError = (error) => {
48
+ let slateCode = getSlateCode(error);
49
+ let transportCode = getTransportCode(error);
50
+ if (slateCode === "upstream.timeout" || transportCode === "ECONNABORTED" || transportCode && NODE_TIMEOUT_CODES.has(transportCode)) {
51
+ return createSdkTransportError(error, "TimeoutError", transportCode ?? "ETIMEDOUT");
52
+ }
53
+ if (slateCode === "upstream.network_error" || transportCode && NODE_NETWORK_CODES.has(transportCode)) {
54
+ return createSdkTransportError(error, "Error", transportCode);
55
+ }
56
+ return error;
57
+ };
58
+ var SlatesAwsSdkHttpHandler = class {
59
+ axiosClient;
60
+ config;
61
+ constructor(config = {}) {
62
+ this.config = config;
63
+ this.axiosClient = config.axiosInstance ?? createAxios();
64
+ }
65
+ updateHttpClientConfig(key, value) {
66
+ this.config = { ...this.config, [key]: value };
67
+ }
68
+ httpHandlerConfigs() {
69
+ return this.config;
70
+ }
71
+ destroy() {
72
+ }
73
+ async handle(request, options = {}) {
74
+ let queryString = request.query ? buildQueryString(request.query) : "";
75
+ let port = request.port ? `:${request.port}` : "";
76
+ let url = `${request.protocol}//${request.hostname}${port}${request.path}` + (queryString ? `?${queryString}` : "");
77
+ let method = request.method.toUpperCase();
78
+ try {
79
+ let axiosResponse = await this.axiosClient.request({
80
+ url,
81
+ method,
82
+ headers: buildRequestHeaders(request.headers),
83
+ data: method === "GET" || method === "HEAD" ? void 0 : request.body,
84
+ validateStatus: () => true,
85
+ responseType: "stream",
86
+ transformRequest: [(data) => data],
87
+ transformResponse: [(data) => data],
88
+ maxRedirects: 0,
89
+ decompress: false,
90
+ timeout: options.requestTimeout ?? this.config.requestTimeout,
91
+ signal: options.abortSignal
92
+ });
93
+ return {
94
+ response: new HttpResponse({
95
+ statusCode: axiosResponse.status,
96
+ reason: axiosResponse.statusText,
97
+ headers: normalizeHeaders(axiosResponse.headers),
98
+ body: axiosResponse.data
99
+ })
100
+ };
101
+ } catch (error) {
102
+ throw normalizeTransportError(error);
103
+ }
104
+ }
105
+ };
106
+ var createSlatesAwsSdkHttpHandler = (config) => new SlatesAwsSdkHttpHandler(config);
107
+ export {
108
+ SlatesAwsSdkHttpHandler,
109
+ createSlatesAwsSdkHttpHandler
110
+ };
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@slates/aws-sdk-http-handler",
3
+ "version": "1.0.0-rc.1",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "files": [
8
+ "src/**",
9
+ "dist/**",
10
+ "README.md",
11
+ "package.json"
12
+ ],
13
+ "author": "Tobias Herber",
14
+ "license": "FSL 1.1",
15
+ "type": "module",
16
+ "source": "src/index.ts",
17
+ "exports": {
18
+ "bun": "./src/index.ts",
19
+ "types": "./dist/index.d.ts",
20
+ "require": "./dist/index.cjs",
21
+ "import": "./dist/index.module.js",
22
+ "default": "./dist/index.module.js"
23
+ },
24
+ "main": "./dist/index.cjs",
25
+ "module": "./dist/index.module.js",
26
+ "types": "dist/index.d.ts",
27
+ "unpkg": "./dist/index.module.js",
28
+ "scripts": {
29
+ "test": "vitest run --config vitest.config.ts --passWithNoTests",
30
+ "lint": "prettier src/**/*.ts --check",
31
+ "build": "tsup --config ../../tsup.packages.config.ts",
32
+ "typecheck": "tsc --noEmit"
33
+ },
34
+ "dependencies": {
35
+ "@smithy/protocol-http": "^5.3.14",
36
+ "@smithy/querystring-builder": "^4.2.14",
37
+ "@smithy/types": "^4.14.1",
38
+ "axios": "^1.13.2",
39
+ "slates": "1.0.0-rc.10"
40
+ },
41
+ "devDependencies": {
42
+ "@slates/tsconfig": "1.0.0-rc.1",
43
+ "@types/node": "^20",
44
+ "typescript": "5.8.2",
45
+ "vitest": "^3.1.2"
46
+ }
47
+ }
@@ -0,0 +1,297 @@
1
+ import {
2
+ createServer,
3
+ type IncomingMessage,
4
+ type Server,
5
+ type ServerResponse
6
+ } from 'node:http';
7
+ import type { AddressInfo } from 'node:net';
8
+ import { HttpRequest } from '@smithy/protocol-http';
9
+ import { SlateContext, runWithContext } from 'slates';
10
+ import { describe, expect, it } from 'vitest';
11
+ import { SlatesAwsSdkHttpHandler } from './index';
12
+
13
+ let logger = {
14
+ info() {},
15
+ warn() {},
16
+ error() {},
17
+ progress() {}
18
+ };
19
+
20
+ let withSlateContext = async <T>(
21
+ run: (context: SlateContext<any, any, any>) => Promise<T>
22
+ ) => {
23
+ let context = new SlateContext(
24
+ {},
25
+ {},
26
+ {},
27
+ { key: 'aws-sdk-http-handler-test' } as any,
28
+ logger as any
29
+ );
30
+ let result = await runWithContext(context, () => run(context));
31
+
32
+ return { context, result };
33
+ };
34
+
35
+ let listen = async (
36
+ handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
37
+ ) => {
38
+ let server = createServer((req, res) => {
39
+ void handler(req, res);
40
+ });
41
+
42
+ await new Promise<void>((resolve, reject) => {
43
+ server.once('error', reject);
44
+ server.listen(0, '127.0.0.1', resolve);
45
+ });
46
+
47
+ return {
48
+ server,
49
+ port: (server.address() as AddressInfo).port
50
+ };
51
+ };
52
+
53
+ let closeServer = (server: Server) =>
54
+ new Promise<void>((resolve, reject) => {
55
+ server.close(error => (error ? reject(error) : resolve()));
56
+ });
57
+
58
+ let readBody = (req: IncomingMessage) =>
59
+ new Promise<string>((resolve, reject) => {
60
+ let chunks: Buffer[] = [];
61
+ req.on('data', chunk => chunks.push(Buffer.from(chunk)));
62
+ req.on('error', reject);
63
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
64
+ });
65
+
66
+ let requestFor = (
67
+ port: number,
68
+ options: Partial<ConstructorParameters<typeof HttpRequest>[0]>
69
+ ) =>
70
+ new HttpRequest({
71
+ protocol: 'http:',
72
+ hostname: '127.0.0.1',
73
+ port,
74
+ path: '/',
75
+ method: 'GET',
76
+ headers: {},
77
+ ...options
78
+ });
79
+
80
+ describe('@slates/aws-sdk-http-handler', () => {
81
+ it('forwards AWS SDK HTTP requests through Axios', async () => {
82
+ let captured:
83
+ | {
84
+ method?: string;
85
+ url?: string;
86
+ headers: IncomingMessage['headers'];
87
+ body: string;
88
+ }
89
+ | undefined;
90
+ let { server, port } = await listen(async (req, res) => {
91
+ captured = {
92
+ method: req.method,
93
+ url: req.url,
94
+ headers: req.headers,
95
+ body: await readBody(req)
96
+ };
97
+ res.statusCode = 201;
98
+ res.setHeader('X-Result', 'accepted');
99
+ res.end('ok');
100
+ });
101
+
102
+ try {
103
+ let handler = new SlatesAwsSdkHttpHandler();
104
+
105
+ let { result } = await withSlateContext(() =>
106
+ handler.handle(
107
+ requestFor(port, {
108
+ path: '/things',
109
+ method: 'POST',
110
+ query: {
111
+ z: 'last',
112
+ a: ['one', 'two'],
113
+ bare: null,
114
+ empty: ''
115
+ },
116
+ headers: {
117
+ 'content-type': 'text/plain',
118
+ 'x-custom': 'custom'
119
+ },
120
+ body: 'payload'
121
+ })
122
+ )
123
+ );
124
+
125
+ expect(result.response.statusCode).toBe(201);
126
+ expect(result.response.headers['x-result']).toBe('accepted');
127
+ expect(captured).toMatchObject({
128
+ method: 'POST',
129
+ url: '/things?a=one&a=two&bare&empty=&z=last',
130
+ body: 'payload'
131
+ });
132
+ expect(captured?.headers['x-custom']).toBe('custom');
133
+ expect(captured?.headers.accept).toBe('*/*');
134
+ expect(captured?.headers['x-slates-provider']).toBe('aws-sdk-http-handler-test');
135
+ } finally {
136
+ await closeServer(server);
137
+ }
138
+ });
139
+
140
+ it('preserves an AWS SDK provided Accept header', async () => {
141
+ let capturedAccept: string | string[] | undefined;
142
+ let { server, port } = await listen((req, res) => {
143
+ capturedAccept = req.headers.accept;
144
+ res.end();
145
+ });
146
+
147
+ try {
148
+ let handler = new SlatesAwsSdkHttpHandler();
149
+ await withSlateContext(() =>
150
+ handler.handle(
151
+ requestFor(port, {
152
+ headers: {
153
+ Accept: 'application/xml'
154
+ }
155
+ })
156
+ )
157
+ );
158
+
159
+ expect(capturedAccept).toBe('application/xml');
160
+ } finally {
161
+ await closeServer(server);
162
+ }
163
+ });
164
+
165
+ it('returns upstream error responses to the AWS SDK', async () => {
166
+ let { server, port } = await listen((_req, res) => {
167
+ res.statusCode = 503;
168
+ res.statusMessage = 'Service Unavailable';
169
+ res.end('try later');
170
+ });
171
+
172
+ try {
173
+ let handler = new SlatesAwsSdkHttpHandler();
174
+ let { result } = await withSlateContext(() =>
175
+ handler.handle(requestFor(port, { path: '/unavailable' }))
176
+ );
177
+
178
+ expect(result.response.statusCode).toBe(503);
179
+ expect(result.response.reason).toBe('Service Unavailable');
180
+ } finally {
181
+ await closeServer(server);
182
+ }
183
+ });
184
+
185
+ it('records sanitized Slates HTTP traces', async () => {
186
+ let { server, port } = await listen(async (req, res) => {
187
+ await readBody(req);
188
+ res.statusCode = 200;
189
+ res.setHeader('Content-Type', 'application/json');
190
+ res.end(JSON.stringify({ ok: true, token: 'server-secret' }));
191
+ });
192
+
193
+ try {
194
+ let handler = new SlatesAwsSdkHttpHandler();
195
+ let { context } = await withSlateContext(async () => {
196
+ await handler.handle(
197
+ requestFor(port, {
198
+ path: '/trace',
199
+ method: 'POST',
200
+ query: {
201
+ visible: 'yes',
202
+ token: 'client-token'
203
+ },
204
+ headers: {
205
+ 'content-type': 'application/json',
206
+ authorization: 'Bearer client-secret',
207
+ 'x-api-key': 'client-api-key'
208
+ },
209
+ body: JSON.stringify({
210
+ visible: 'yes',
211
+ secret: 'client-secret'
212
+ })
213
+ })
214
+ );
215
+ });
216
+
217
+ let trace = context.getHttpTraces()[0];
218
+ expect(trace?.request.method).toBe('POST');
219
+ expect(trace?.request.url).toContain('visible=yes');
220
+ expect(trace?.request.url).not.toContain('client-token');
221
+ expect(trace?.request.headers).toMatchObject({
222
+ 'x-slates-provider': 'aws-sdk-http-handler-test'
223
+ });
224
+ expect(trace?.request.headers).not.toHaveProperty('authorization');
225
+ expect(trace?.request.headers).not.toHaveProperty('x-api-key');
226
+ expect(trace?.request.body?.text).toContain('[redacted]');
227
+ expect(trace?.request.body?.text).not.toContain('client-secret');
228
+ expect(trace?.response?.status).toBe(200);
229
+ } finally {
230
+ await closeServer(server);
231
+ }
232
+ });
233
+
234
+ it('does not follow redirects for signed AWS requests', async () => {
235
+ let targetHit = false;
236
+ let { server, port } = await listen((req, res) => {
237
+ if (req.url === '/target') {
238
+ targetHit = true;
239
+ }
240
+
241
+ res.statusCode = 302;
242
+ res.setHeader('Location', '/target');
243
+ res.end();
244
+ });
245
+
246
+ try {
247
+ let handler = new SlatesAwsSdkHttpHandler();
248
+ let { result } = await withSlateContext(() =>
249
+ handler.handle(requestFor(port, { path: '/start' }))
250
+ );
251
+
252
+ expect(result.response.statusCode).toBe(302);
253
+ expect(targetHit).toBe(false);
254
+ } finally {
255
+ await closeServer(server);
256
+ }
257
+ });
258
+
259
+ it('maps Axios timeouts back to Smithy-classifiable errors', async () => {
260
+ let { server, port } = await listen(() => {
261
+ // Keep the request open until Axios aborts it.
262
+ });
263
+
264
+ try {
265
+ let handler = new SlatesAwsSdkHttpHandler({ requestTimeout: 5 });
266
+ let error = await withSlateContext(() =>
267
+ handler.handle(requestFor(port, { path: '/timeout' }))
268
+ )
269
+ .then(() => null)
270
+ .catch(err => err as Error & { code?: string; cause?: unknown });
271
+
272
+ expect(error).toBeInstanceOf(Error);
273
+ expect(error?.name).toBe('TimeoutError');
274
+ expect(error?.cause).toBeTruthy();
275
+ } finally {
276
+ await closeServer(server);
277
+ }
278
+ });
279
+
280
+ it('preserves Node network error codes for Smithy retry classification', async () => {
281
+ let { server, port } = await listen((_req, res) => {
282
+ res.end();
283
+ });
284
+ await closeServer(server);
285
+
286
+ let handler = new SlatesAwsSdkHttpHandler({ requestTimeout: 100 });
287
+ let error = await withSlateContext(() =>
288
+ handler.handle(requestFor(port, { path: '/refused' }))
289
+ )
290
+ .then(() => null)
291
+ .catch(err => err as Error & { code?: string; cause?: unknown });
292
+
293
+ expect(error).toBeInstanceOf(Error);
294
+ expect(error?.code).toBe('ECONNREFUSED');
295
+ expect(error?.cause).toBeTruthy();
296
+ });
297
+ });
package/src/index.ts ADDED
@@ -0,0 +1,174 @@
1
+ import type { AxiosInstance } from 'axios';
2
+ import { HttpResponse, type HttpHandler, type HttpRequest } from '@smithy/protocol-http';
3
+ import { buildQueryString } from '@smithy/querystring-builder';
4
+ import type { HttpHandlerOptions } from '@smithy/types';
5
+ import { createAxios } from 'slates';
6
+
7
+ export interface SlatesAwsSdkHttpHandlerConfig {
8
+ axiosInstance?: AxiosInstance;
9
+ requestTimeout?: number;
10
+ }
11
+
12
+ let NODE_TIMEOUT_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT']);
13
+ let NODE_NETWORK_CODES = new Set(['EHOSTUNREACH', 'ENETUNREACH', 'ENOTFOUND']);
14
+
15
+ let isRecord = (value: unknown): value is Record<string, any> =>
16
+ typeof value === 'object' && value !== null;
17
+
18
+ let normalizeHeaders = (headers: unknown): Record<string, string> => {
19
+ let raw =
20
+ headers && typeof (headers as { toJSON?: () => unknown }).toJSON === 'function'
21
+ ? (headers as { toJSON: () => unknown }).toJSON()
22
+ : headers;
23
+
24
+ let out: Record<string, string> = {};
25
+
26
+ for (let [key, value] of Object.entries((raw ?? {}) as Record<string, unknown>)) {
27
+ if (value == null) continue;
28
+ out[key.toLowerCase()] = Array.isArray(value)
29
+ ? value.map(String).join(', ')
30
+ : String(value);
31
+ }
32
+
33
+ return out;
34
+ };
35
+
36
+ let hasHeader = (headers: Record<string, string>, name: string) =>
37
+ Object.keys(headers).some(key => key.toLowerCase() === name.toLowerCase());
38
+
39
+ let buildRequestHeaders = (headers: Record<string, string>) => {
40
+ let out = { ...headers };
41
+
42
+ if (!hasHeader(out, 'accept')) {
43
+ // Axios defaults to asking for JSON, which makes AWS Query services such as
44
+ // STS, IAM, and SNS return JSON even though the SDK deserializers expect XML.
45
+ out.accept = '*/*';
46
+ }
47
+
48
+ return out;
49
+ };
50
+
51
+ let getSlateCode = (error: unknown): string | undefined => {
52
+ if (!isRecord(error)) return undefined;
53
+
54
+ return typeof error.code === 'string' ? error.code : undefined;
55
+ };
56
+
57
+ let getTransportCode = (error: unknown): string | undefined => {
58
+ if (!isRecord(error)) return undefined;
59
+
60
+ let directCode = typeof error.code === 'string' ? error.code : undefined;
61
+ if (directCode && !directCode.startsWith('upstream.')) return directCode;
62
+
63
+ let baggage =
64
+ isRecord(error.data) && isRecord(error.data.baggage) ? error.data.baggage : undefined;
65
+ let axiosCode =
66
+ baggage && typeof baggage.axiosCode === 'string' ? baggage.axiosCode : undefined;
67
+ if (axiosCode) return axiosCode;
68
+
69
+ return getTransportCode(error.cause);
70
+ };
71
+
72
+ let messageForError = (error: unknown) =>
73
+ error instanceof Error ? error.message : String(error);
74
+
75
+ let createSdkTransportError = (error: unknown, name: string, code?: string) => {
76
+ let sdkError = new Error(messageForError(error), { cause: error });
77
+ sdkError.name = name;
78
+
79
+ if (code) {
80
+ Object.assign(sdkError, { code });
81
+ }
82
+
83
+ return sdkError;
84
+ };
85
+
86
+ let normalizeTransportError = (error: unknown) => {
87
+ let slateCode = getSlateCode(error);
88
+ let transportCode = getTransportCode(error);
89
+
90
+ if (
91
+ slateCode === 'upstream.timeout' ||
92
+ transportCode === 'ECONNABORTED' ||
93
+ (transportCode && NODE_TIMEOUT_CODES.has(transportCode))
94
+ ) {
95
+ return createSdkTransportError(error, 'TimeoutError', transportCode ?? 'ETIMEDOUT');
96
+ }
97
+
98
+ if (
99
+ slateCode === 'upstream.network_error' ||
100
+ (transportCode && NODE_NETWORK_CODES.has(transportCode))
101
+ ) {
102
+ return createSdkTransportError(error, 'Error', transportCode);
103
+ }
104
+
105
+ return error;
106
+ };
107
+
108
+ export class SlatesAwsSdkHttpHandler implements HttpHandler<SlatesAwsSdkHttpHandlerConfig> {
109
+ private readonly axiosClient: AxiosInstance;
110
+ private config: SlatesAwsSdkHttpHandlerConfig;
111
+
112
+ constructor(config: SlatesAwsSdkHttpHandlerConfig = {}) {
113
+ this.config = config;
114
+ this.axiosClient = config.axiosInstance ?? createAxios();
115
+ }
116
+
117
+ updateHttpClientConfig<K extends keyof SlatesAwsSdkHttpHandlerConfig>(
118
+ key: K,
119
+ value: SlatesAwsSdkHttpHandlerConfig[K]
120
+ ): void {
121
+ this.config = { ...this.config, [key]: value };
122
+ }
123
+
124
+ httpHandlerConfigs(): SlatesAwsSdkHttpHandlerConfig {
125
+ return this.config;
126
+ }
127
+
128
+ destroy(): void {
129
+ // The default Slates Axios client does not own sockets or agents.
130
+ }
131
+
132
+ async handle(
133
+ request: HttpRequest,
134
+ options: HttpHandlerOptions = {}
135
+ ): Promise<{ response: HttpResponse }> {
136
+ let queryString = request.query ? buildQueryString(request.query) : '';
137
+ let port = request.port ? `:${request.port}` : '';
138
+ let url =
139
+ `${request.protocol}//${request.hostname}${port}${request.path}` +
140
+ (queryString ? `?${queryString}` : '');
141
+ let method = request.method.toUpperCase();
142
+
143
+ try {
144
+ let axiosResponse = await this.axiosClient.request({
145
+ url,
146
+ method,
147
+ headers: buildRequestHeaders(request.headers),
148
+ data: method === 'GET' || method === 'HEAD' ? undefined : request.body,
149
+ validateStatus: () => true,
150
+ responseType: 'stream',
151
+ transformRequest: [data => data],
152
+ transformResponse: [data => data],
153
+ maxRedirects: 0,
154
+ decompress: false,
155
+ timeout: options.requestTimeout ?? this.config.requestTimeout,
156
+ signal: options.abortSignal as AbortSignal | undefined
157
+ });
158
+
159
+ return {
160
+ response: new HttpResponse({
161
+ statusCode: axiosResponse.status,
162
+ reason: axiosResponse.statusText,
163
+ headers: normalizeHeaders(axiosResponse.headers),
164
+ body: axiosResponse.data
165
+ })
166
+ };
167
+ } catch (error) {
168
+ throw normalizeTransportError(error);
169
+ }
170
+ }
171
+ }
172
+
173
+ export let createSlatesAwsSdkHttpHandler = (config?: SlatesAwsSdkHttpHandlerConfig) =>
174
+ new SlatesAwsSdkHttpHandler(config);