diff --git a/.changeset/tall-cameras-reply.md b/.changeset/tall-cameras-reply.md new file mode 100644 index 00000000000..6e02321c650 --- /dev/null +++ b/.changeset/tall-cameras-reply.md @@ -0,0 +1,6 @@ +--- +"@smithy/middleware-stack": minor +"@smithy/smithy-client": minor +--- + +add client handler caching diff --git a/packages/middleware-stack/src/MiddlewareStack.ts b/packages/middleware-stack/src/MiddlewareStack.ts index 52caaac9ad1..bfc6f18584b 100644 --- a/packages/middleware-stack/src/MiddlewareStack.ts +++ b/packages/middleware-stack/src/MiddlewareStack.ts @@ -32,7 +32,14 @@ const getMiddlewareNameWithAliases = (name: string | undefined, aliases: Array 0 ? ` (a.k.a. ${aliases.join(",")})` : ""}`; }; -export const constructStack = (): MiddlewareStack => { +export const constructStack = ( + stackOptions: { + /** + * Optional change listener, called with stack instance when middleware added/removed. + */ + onChange?: (middlewareStack: MiddlewareStack) => void; + } = {} +): MiddlewareStack => { let absoluteEntries: AbsoluteMiddlewareEntry[] = []; let relativeEntries: RelativeMiddlewareEntry[] = []; let identifyOnResolve = false; @@ -222,6 +229,7 @@ export const constructStack = (): M } } absoluteEntries.push(entry); + stackOptions.onChange?.(stack); }, addRelativeTo: (middleware: MiddlewareType, options: HandlerOptions & RelativeLocation) => { @@ -258,6 +266,7 @@ export const constructStack = (): M } } relativeEntries.push(entry); + stackOptions.onChange?.(stack); }, clone: () => cloneTo(constructStack()), @@ -267,8 +276,14 @@ export const constructStack = (): M }, remove: (toRemove: MiddlewareType | string): boolean => { - if (typeof toRemove === "string") return removeByName(toRemove); - else return removeByReference(toRemove); + let isRemoved: boolean; + if (typeof toRemove === "string") { + isRemoved = removeByName(toRemove); + } else { + isRemoved = removeByReference(toRemove); + } + stackOptions.onChange?.(stack); + return isRemoved; }, removeByTag: (toRemove: string): boolean => { @@ -287,6 +302,7 @@ export const constructStack = (): M }; absoluteEntries = absoluteEntries.filter(filterCb); relativeEntries = relativeEntries.filter(filterCb); + stackOptions?.onChange?.(stack); return isRemoved; }, diff --git a/packages/smithy-client/src/client.spec.ts b/packages/smithy-client/src/client.spec.ts index 56ed3944e00..6dcde8cdfe3 100644 --- a/packages/smithy-client/src/client.spec.ts +++ b/packages/smithy-client/src/client.spec.ts @@ -50,4 +50,28 @@ describe("SmithyClient", () => { }; client.send(getCommandWithOutput("foo") as any, options, callback); }); + + describe("handler caching", () => { + beforeEach(() => { + delete (client as any).handlers; + }); + + const privateAccess = () => (client as any).handlers; + + it("should cache the resolved handler", async () => { + await expect(client.send(getCommandWithOutput("foo") as any)).resolves.toEqual("foo"); + expect(privateAccess().get({}.constructor)).toBeDefined(); + }); + + it("should not cache the resolved handler if called with request options", async () => { + await expect(client.send(getCommandWithOutput("foo") as any, {})).resolves.toEqual("foo"); + expect(privateAccess()).toBeUndefined(); + }); + + it("should clear the handler cache when the middleareStack is modified", async () => { + await expect(client.send(getCommandWithOutput("foo") as any)).resolves.toEqual("foo"); + client.middlewareStack.add((n) => (a) => n(a)); + expect(privateAccess()).toBeUndefined(); + }); + }); }); diff --git a/packages/smithy-client/src/client.ts b/packages/smithy-client/src/client.ts index c0fd856b53c..1afcb911ad2 100644 --- a/packages/smithy-client/src/client.ts +++ b/packages/smithy-client/src/client.ts @@ -3,6 +3,7 @@ import { Client as IClient, Command, FetchHttpHandlerOptions, + Handler, MetadataBearer, MiddlewareStack, NodeHttpHandlerOptions, @@ -44,11 +45,21 @@ export class Client< ResolvedClientConfiguration extends SmithyResolvedConfiguration, > implements IClient { - public middlewareStack: MiddlewareStack = constructStack(); - readonly config: ResolvedClientConfiguration; - constructor(config: ResolvedClientConfiguration) { - this.config = config; + public middlewareStack: MiddlewareStack = constructStack({ + onChange: () => { + delete this.handlers; + }, + }); + /** + * May be used to cache the resolved handler function for a Command class. + */ + private handlers?: WeakMap> | undefined; + private configRef?: ResolvedClientConfiguration | undefined; + + constructor(public readonly config: ResolvedClientConfiguration) { + this.configRef = this.config; } + send( command: Command>, options?: HandlerOptions @@ -69,7 +80,29 @@ export class Client< ): Promise | void { const options = typeof optionsOrCb !== "function" ? optionsOrCb : undefined; const callback = typeof optionsOrCb === "function" ? (optionsOrCb as (err: any, data?: OutputType) => void) : cb; - const handler = command.resolveMiddleware(this.middlewareStack as any, this.config, options); + + const useHandlerCache = options === undefined && this.config === this.configRef; + + let handler: Handler; + + if (useHandlerCache) { + if (!this.handlers) { + this.handlers = new WeakMap(); + } + const handlers = this.handlers!; + + if (handlers.has(command.constructor)) { + handler = handlers.get(command.constructor)!; + } else { + handler = command.resolveMiddleware(this.middlewareStack as any, this.config, options); + handlers.set(command.constructor, handler); + } + } else { + delete this.handlers; + handler = command.resolveMiddleware(this.middlewareStack as any, this.config, options); + this.configRef = this.config; + } + if (callback) { handler(command) .then( @@ -87,6 +120,7 @@ export class Client< } destroy() { - if (this.config.requestHandler.destroy) this.config.requestHandler.destroy(); + this.config.requestHandler.destroy?.(); + delete this.handlers; } }