Feat: typescript middleware (#3066)

* feat: typed middleware

* feat: chaining

* feat: typed global input

* feat: typed global output

* feat: inferred types from middleware

* feat: with chaining

* docs: initial pass

* feat: implicit chaining

* fix: implicit spread

* docs: separate examples

* refactor: rename middleware hooks from `pre`/`post` to `before`/`after` for consistency

* fix: search

* chore: lint

* fix: tests

* Update frontend/docs/pages/home/middleware.mdx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* release: 1.13.0

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Gabe Ruttner
2026-02-23 04:16:00 -08:00
committed by GitHub
parent c97b967e2b
commit de68e1375a
24 changed files with 1390 additions and 94 deletions
+3 -1
View File
@@ -118,6 +118,9 @@ export default {
streaming: {
title: "Streaming",
},
middleware: {
title: "Middleware & Dependency Injection",
},
"--v1-migration-guides": {
title: "V1 Migration Guides",
type: "separator",
@@ -136,6 +139,5 @@ export default {
asyncio: "Asyncio",
pydantic: "Pydantic",
lifespans: "Lifespans",
"dependency-injection": "Dependency Injection",
dataclasses: "Dataclass Support",
};
@@ -1,43 +0,0 @@
import { snippets } from "@/lib/generated/snippets";
import { Snippet } from "@/components/code";
import { Callout, Card, Cards, Steps, Tabs } from "nextra/components";
import UniversalTabs from "@/components/UniversalTabs";
# Dependency Injection
<Callout type="error" emoji="🚨">
Dependency injection is an **experimental feature** in Hatchet, and is subject
to change.
</Callout>
Hatchet's Python SDK allows you to inject **_dependencies_** into your tasks, FastAPI style. These dependencies can be either synchronous or asynchronous functions. They are executed before the task is triggered, and their results are injected into the task as parameters.
This behaves almost identically to [FastAPI's dependency injection](https://fastapi.tiangolo.com/tutorial/dependencies/), and is intended to be used in the same way. Dependencies are useful for sharing logic between tasks that you'd like to avoid repeating, or would like to factor out of the task logic itself (e.g. to make testing easier).
<Callout type="warning" emoji="⚠️">
Since dependencies are run before tasks are executed, having many dependencies (or any that take a long time to evaluate) can cause tasks to experience significantly delayed start times, as they must wait for all dependencies to finish evaluating.
</Callout>
## Usage
To add dependencies to your tasks, import `Depends` from the `hatchet_sdk`. Then:
<Snippet
src={snippets.python.dependency_injection.worker.declare_dependencies}
/>
In this example, we've declared two dependencies: one synchronous and one asynchronous. You can do anything you like in your dependencies, such as creating database sessions, managing configuration, sharing instances of service-layer logic, and more.
Once you've defined your dependency functions, inject them into your tasks as follows:
<Snippet
src={snippets.python.dependency_injection.worker.inject_dependencies}
/>
<Callout type="warning" emoji="⚠️">
Important note: Your dependency functions must take two positional arguments:
the workflow input and the `Context` (the same as any other task).
</Callout>
That's it! Now, whenever your task is triggered, its dependencies will be evaluated, and the results will be injected into the task at runtime for you to use as needed.
+293
View File
@@ -0,0 +1,293 @@
import { snippets } from "@/lib/generated/snippets";
import { Snippet } from "@/components/code";
import { Callout, Card, Cards, Steps, Tabs } from "nextra/components";
import UniversalTabs from "@/components/UniversalTabs";
# Middleware & Dependency Injection
Middleware lets you run logic **before** and **after** every task on a client, without touching individual task definitions. Common uses include injecting request IDs, enriching inputs with shared data, encrypting/decrypting payloads, and normalizing or augmenting outputs.
<UniversalTabs items={["Python", "Typescript", "Go", "Ruby"]}>
<Tabs.Tab title="Python">
Hatchet's Python SDK uses FastAPI-style dependency injection to run logic
before tasks and inject the results as parameters. Dependencies are declared
as functions and wired into tasks with `Depends`.
</Tabs.Tab>
<Tabs.Tab title="Typescript">
Middleware hooks are registered on the client with `withMiddleware` and are
fully type-safe — TypeScript sees the union of fields from the task input
type and any values returned by `before` hooks, and similarly for task
outputs and `after` hooks.
</Tabs.Tab>
<Tabs.Tab title="Go">
<Callout type="info">
Middleware support for the Go SDK is coming soon. Join our
[Discord](https://hatchet.run/discord) to stay up to date.
</Callout>
</Tabs.Tab>
<Tabs.Tab title="Ruby">
In Ruby, this pattern uses callable objects (lambdas/procs) passed as `deps`
when defining tasks. Dependencies are evaluated before each task run and
made available via `ctx.deps`.
</Tabs.Tab>
</UniversalTabs>
## Defining Middleware
<UniversalTabs items={["Python", "Typescript", "Go", "Ruby"]}>
<Tabs.Tab title="Python">
Define your dependency functions — they receive the workflow input and context, and their return values are injected into the task as parameters.
<Snippet src={snippets.python.dependency_injection.worker.declare_dependencies} />
</Tabs.Tab>
<Tabs.Tab title="Typescript">
Create a client and attach middleware with `before` and `after` hooks.
- **`before(input, ctx)`** runs before the task. Its return value **replaces** the task input.
- **`after(output, ctx, input)`** runs after the task. Its return value **replaces** the task output.
<Snippet src={snippets.typescript.middleware.client.init_a_client_with_middleware} />
<Callout type="warning" emoji="⚠️">
**Spread the original value if you want to keep it.** The return value of each hook **replaces** the input (or output) entirely — it does not shallow-merge. If you omit `...input` in a `before` hook, the original fields are lost. The same applies to `...output` in an `after` hook.
```typescript
// ✅ Keeps original fields and adds `requestId`
before: (input) => ({ ...input, requestId: crypto.randomUUID() })
// ❌ Replaces input entirely — task only receives { requestId }
before: (input) => ({ requestId: crypto.randomUUID() })
```
</Callout>
### Chaining Middleware
You can chain multiple `.withMiddleware()` calls to run hooks in sequence. Each `before` hook receives the return value of the previous `before` hook (or the original input for the first hook), and each `after` hook receives the return value of the previous `after` hook.
<Snippet src={snippets.typescript.middleware.client.chaining_middleware} />
</Tabs.Tab>
<Tabs.Tab title="Go">
<Callout type="info">
Middleware support for the Go SDK is coming soon. Join our [Discord](https://hatchet.run/discord) to stay up to date.
</Callout>
</Tabs.Tab>
<Tabs.Tab title="Ruby">
Define your dependencies as callable objects (lambdas). They receive the input, context, and optionally a hash of previously resolved dependencies for chaining.
<Snippet src={snippets.ruby.dependency_injection.worker.declare_dependencies_ruby_uses_callable_objects_instead_of_pythons_depends} />
</Tabs.Tab>
</UniversalTabs>
## How Middleware Executes
<UniversalTabs items={["Python", "Typescript", "Go", "Ruby"]}>
<Tabs.Tab title="Python">
Dependencies are resolved before each task execution. Each dependency function receives the original workflow input and the task context, and its return value is injected as a named parameter to the task function.
</Tabs.Tab>
<Tabs.Tab title="Typescript">
When a task runs, the worker applies middleware hooks in this order:
<Steps>
### Before hooks run in registration order
Each `before` hook receives the current input and the task `Context`. Its return value **replaces** the input for the next hook (or the task itself). Returning `undefined` (or `void`) skips replacement and passes the input through unchanged.
### The task function executes
The task receives the final input after all `before` hooks have run.
### After hooks run in registration order
Each `after` hook receives the current output, the task `Context`, and the final input. Its return value **replaces** the output for the next hook (or the final result). Returning `undefined` skips replacement.
</Steps>
Both `before` and `after` hooks can be **async** — return a `Promise` and it will be awaited before proceeding.
<Callout type="info">
If a middleware hook throws an error, the task run fails with that error. There is no built-in error recovery within middleware — use try/catch inside your hooks if you need graceful fallback.
</Callout>
### The `ctx` Parameter
The second parameter of both `before` and `after` hooks is the task `Context` object. This gives middleware access to:
- `ctx.workflowRunId` — the ID of the current workflow run
- `ctx.stepRunId` — the ID of the current step run
- `ctx.log()` — emit structured logs visible in the Hatchet dashboard
- `ctx.cancel()` — cancel the current run from within middleware
### Global Types vs Middleware Types
There are two ways extra fields end up on a task's input:
| Mechanism | Set via | Required at call site? | Available at runtime? |
|---|---|---|---|
| **Global input type** | `HatchetClient.init<T>()` | Yes — callers must provide these fields | Yes |
| **Middleware before hook** | `.withMiddleware({ before })` | No — injected automatically by the worker | Yes |
Global input types (`T` in `init<T>()`) represent fields that **callers must supply** when triggering a task. This is useful when you know every task must always receive certain parameters — for example, a `userId` for authentication or a `tenantId` for multi-tenant routing. By declaring these as the global type, TypeScript enforces that every caller provides them.
Middleware `before` hooks, on the other hand, inject fields that are **computed at runtime** (e.g. request IDs, decrypted secrets, fetched config) and are **not** required from callers.
```typescript
type RequiredContext = { userId: string; orgId: string };
const client = HatchetClient.init<RequiredContext>()
.withMiddleware({
before: (input) => ({
...input,
resolvedAt: Date.now(), // injected, not required from caller
permissions: lookupPerms(input.userId), // derived from global type
}),
});
// Callers MUST provide userId and orgId — TypeScript enforces this
await myTask.run({ userId: 'usr_123', orgId: 'org_456', /* ...task fields */ });
```
</Tabs.Tab>
<Tabs.Tab title="Go">
<Callout type="info">
Middleware support for the Go SDK is coming soon. Join our [Discord](https://hatchet.run/discord) to stay up to date.
</Callout>
</Tabs.Tab>
<Tabs.Tab title="Ruby">
Dependencies are resolved in the order they are declared in the `deps` hash. Each dependency function can optionally receive already-resolved dependencies as its third argument, enabling chaining.
</Tabs.Tab>
</UniversalTabs>
## Using Middleware in Tasks
<UniversalTabs items={["Python", "Typescript", "Go", "Ruby"]}>
<Tabs.Tab title="Python">
Inject dependencies into your tasks using `Depends` and type annotations. The dependency results are passed directly as function parameters.
<Snippet src={snippets.python.dependency_injection.worker.inject_dependencies} />
<Callout type="warning" emoji="⚠️">
Your dependency functions must take two positional arguments: the workflow input and the `Context` (the same as any other task).
</Callout>
</Tabs.Tab>
<Tabs.Tab title="Typescript">
Tasks created from a middleware-enabled client automatically receive the merged input and output types. There is no extra configuration needed on the task itself.
<Snippet src={snippets.typescript.middleware.workflow.all} />
The task's `input` type is the intersection of `TaskInput`, `GlobalInputType`, and the return type of the `before` middleware hook. The task's return type must satisfy `TaskOutput` and `GlobalOutputType`, while the caller receives the intersection of those with the `after` middleware return type.
</Tabs.Tab>
<Tabs.Tab title="Go">
<Callout type="info">
Middleware support for the Go SDK is coming soon. Join our [Discord](https://hatchet.run/discord) to stay up to date.
</Callout>
</Tabs.Tab>
<Tabs.Tab title="Ruby">
Pass a `deps` hash when defining a task. The resolved dependency values are available inside the task block via `ctx.deps`.
<Snippet src={snippets.ruby.dependency_injection.worker.inject_dependencies} />
</Tabs.Tab>
</UniversalTabs>
## Running a Worker
<UniversalTabs items={["Python", "Typescript", "Go", "Ruby"]}>
<Tabs.Tab title="Python">
No special worker configuration is needed — dependencies are evaluated automatically each time a task runs.
</Tabs.Tab>
<Tabs.Tab title="Typescript">
Workers are created from the same middleware-enabled client. No special setup is required — the middleware hooks are applied automatically when tasks execute.
<Snippet src={snippets.typescript.middleware.worker.all} />
</Tabs.Tab>
<Tabs.Tab title="Go">
<Callout type="info">
Middleware support for the Go SDK is coming soon. Join our [Discord](https://hatchet.run/discord) to stay up to date.
</Callout>
</Tabs.Tab>
<Tabs.Tab title="Ruby">
No special worker configuration is needed — dependencies are resolved automatically before each task execution.
</Tabs.Tab>
</UniversalTabs>
## Practical Examples
The examples below show TypeScript middleware for common production patterns. Each can be adapted to the Python dependency injection model by extracting the same logic into a dependency function.
### End-to-End Encryption
Encrypt sensitive input fields before they reach the Hatchet server, and decrypt the output on the way back. This ensures plaintext data never leaves your worker process.
<Snippet src={snippets.typescript.middleware.recipes.end_to_end_encryption} />
<Callout type="info">
The `before` hook decrypts incoming data so your task function works with
plaintext. The `after` hook encrypts the output before it is stored. The
encryption key never leaves the worker environment.
</Callout>
### Offloading Large Payloads to S3
When task inputs or outputs exceed Hatchet's payload size limit (or you simply want to keep large blobs out of the control plane), upload them to S3 and pass a signed URL instead.
<Snippet
src={snippets.typescript.middleware.recipes.offloading_large_payloads_to_s3}
/>
<Callout type="warning" emoji="⚠️">
The caller is responsible for uploading oversized inputs to S3 before triggering the task. The `before` hook only handles the download side. You can use the same `uploadToS3` helper on the caller side to upload the input and pass `{ __s3Url: url }` as the task input.
</Callout>
## FAQ
### What is Hatchet middleware and how does it differ from Express middleware?
Hatchet middleware runs **inside the worker process** around each task invocation — not on an HTTP request path. A `before` hook transforms input before the task runs, and an `after` hook transforms output after. Unlike Express middleware, there is no `next()` function; hooks return their result directly and the runner chains them automatically.
### Can I use middleware with both tasks and workflows?
Yes. Middleware is registered on the `HatchetClient` instance, so it applies to every task created from that client — whether the task is a standalone `client.task()` or part of a multi-step `client.workflow()`. Each step in a workflow will have middleware applied independently.
### Does middleware run on the server or on the worker?
Middleware runs entirely **on the worker**. The Hatchet server never sees or executes your middleware code. This is what makes patterns like end-to-end encryption possible — plaintext data stays within your infrastructure.
### What happens if my middleware throws an error?
If a `before` or `after` hook throws (or returns a rejected `Promise`), the task run fails with that error. There is no automatic retry of middleware itself, but the task's configured retry policy will still apply, re-running the task (and its middleware) from scratch.
### Can I use async/await in middleware hooks?
Yes. Both `before` and `after` hooks can be synchronous or asynchronous. If a hook returns a `Promise`, the worker will `await` it before proceeding to the next hook or the task function.
### How do I share state between `before` and `after` hooks?
The `after` hook receives the task input (after `before` hooks have run) as its third argument. Add fields in `before` (e.g. `startedAt`, `traceId`) and read them from `input` in `after`. There is no separate shared context object — the input itself is the carrier.
### Does middleware apply to child tasks spawned via fanout?
Middleware is scoped to the **client instance**. If a child task is defined on the same middleware-enabled client, its middleware will run when that child task executes. If the child task uses a different client instance, only that client's middleware (if any) applies.
### Can I selectively skip middleware for certain tasks?
Middleware applies to **all** tasks on a given client. To skip middleware for specific tasks, create a second client without middleware and define those tasks on it. This is a deliberate design choice — middleware is a cross-cutting concern, and selective opt-out is handled at the client boundary.
### Is there a performance overhead to using middleware?
Middleware hooks are plain JavaScript functions that run in-process on the worker. The overhead is the execution time of your hook code. For lightweight operations (adding a field, logging), the overhead is negligible. For heavier operations (network calls like S3 uploads or decryption), the task's total duration will include that time, so keep hooks as efficient as possible.
### What is the difference between global types and middleware types in TypeScript?
Global types (`HatchetClient.init<GlobalInput, GlobalOutput>()`) define fields that **callers must provide** when triggering a task. Middleware types (inferred from `withMiddleware` return values) define fields that are **injected at runtime** by the worker. Both end up on the task's `input` type, but only global types appear in the caller-facing `run()` signature.
### Can I use middleware for rate limiting or authentication?
Yes. A `before` hook can check rate limits, validate API keys, or verify JWTs before the task runs. If the check fails, throw an error to abort the task. However, for rate limiting specifically, consider using Hatchet's built-in [rate limiting](/home/rate-limits) feature, which operates at the scheduling layer and is more efficient than in-worker checks.
### How do I test middleware in isolation?
Middleware hooks are plain functions — you can unit-test them directly by calling them with mock input and a mock context object. For integration tests, the e2e test pattern of creating a client, attaching middleware, defining a task, starting a worker, and asserting on the result works well. See the [middleware example on GitHub](https://github.com/hatchet-dev/hatchet/tree/main/examples/typescript/middleware) for a complete test setup.
+1 -1
View File
@@ -369,7 +369,7 @@ const TEST_CASES: SearchTestCase[] = [
{
name: "dependency injection",
query: "dependency injection",
expectAnyOf: ["home/dependency-injection"],
expectAnyOf: ["home/middleware"],
},
{
name: "dataclass",