Skip to content

feat(sdk): middleware setup/dispose lifecycle hooks - #702

Merged
blove merged 1 commit into
mainfrom
blove/middleware-lifecycle-hooks
Sep 17, 2026
Merged

blove merged 1 commit into
mainfrom
blove/middleware-lifecycle-hooks

Conversation

@blove

@blove blove commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Closes #683.

What

defineMiddleware keeps its function form and gains an object form:

export default defineMiddleware({
  async setup(ctx) { pool = new Pool(...) },
  async dispose() { await pool?.end() },
  handle(req) { ... },
})
  • setup(ctx) runs at most once per runtime, lazily, before the first gated request. Concurrent first requests share one in-flight call. A rejection fails only the request that awaited it (the existing opaque 500, cause on stderr) and clears the memo, so the next request retries. handle never runs before a successful setup. ctx carries appRoot and deliberately no shutdown AbortSignal (the fetch core mints signals per request for workerd; the same hazard applies here).
  • dispose() runs once from the fetch core's close() after the request drain, which is the shutdown path b4 dev, b4 start and the generated server.mjs all funnel into (SIGTERM/SIGINT). It runs when there is no setup or after a successful one, never for a setup that did not run or only ever failed. A rejection is logged and does not wedge shutdown. Hono/edge app.mjs has no shutdown hook, so it is documented as not invoked there.

How

  • @b4run/sdk: B4Middleware becomes MiddlewareHandler | MiddlewareDefinition; defineMiddleware<T extends B4Middleware>(m: T): T is type-preserving, so the function form is source-compatible.
  • @b4run/cli: bindMiddleware (pure, edge-safe dev/middleware.ts) folds the lazy setup into a plain handler and owns dispose; the fetch core binds once at boot and every route handler dependency is now typed MiddlewareHandler, so a definition cannot reach a request unbound. selectMiddlewareExport and the static-manifest guard accept both shapes so dev and built targets bind identically.
  • Docs: middleware guide (new "Lifecycle hooks" section with the per-target dispose table), SDK API page, SDK README. Spec in docs/superpowers/specs/2026-09-16-middleware-lifecycle-hooks-design.md. Patch changeset.

Tests

  • SDK: both forms round-trip through defineMiddleware.
  • CLI unit (test/middleware.test.ts): lazy setup, single-flight concurrency, failure retry with handle skipped, dispose ordering/idempotence, dispose skipped when setup never ran or only failed, dispose awaiting an in-flight setup, request-after-dispose rejection, selectMiddlewareExport on the object form.
  • CLI integration (test/middleware-lifecycle.test.ts): a real fetch handler proves setup runs on the first gated request (not boot), close() runs dispose once, a failed setup answers 500 and retries, a throwing dispose is logged and does not fail close().
  • Static manifest: object entries load; object entries without handle are rejected as malformed.

pnpm lint, pnpm build, pnpm typecheck, node scripts/check-docs.mjs green. Full pnpm test: 9 failures under the parallel load of the whole suite (5 timeouts, the docker smoke watchdog, a spawn ETIMEDOUT in the docs inventory, and the stale SEO lastmod manifest, which is regenerated here); each reruns green in isolation.

Found in passing, not fixed here

reject(401) with no body has been answered with a 500 since #373: statusResponse calls Response.json(undefined), which throws. The docs show body-less reject(403). Flagged as a separate task.

🤖 Generated with Claude Code

`defineMiddleware` gains an object form with optional `setup(ctx)` and
`dispose()` around `handle`. `setup` runs once per runtime, lazily, before
the first gated request; concurrent first requests share one in-flight
call, and a rejection fails only that request and is retried by the next,
so a transient outage never poisons the process. `dispose` runs from the
fetch core's `close()` after the request drain, which is the path `b4 dev`,
`b4 start` and the generated server.mjs all shut down through. The function
form is unchanged.

The runtime binds a definition once at boot (`bindMiddleware`); route
handlers only ever receive the bound handler, so `setup` cannot be bypassed
by construction. `selectMiddlewareExport` accepts both shapes so dev and the
static manifest bind identically.

Closes #683

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
b4-run Ready Ready Preview Sep 17, 2026 5:48am UTC

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@blove
blove merged commit c9a4d87 into main Sep 17, 2026
30 of 31 checks passed
@blove
blove deleted the blove/middleware-lifecycle-hooks branch September 17, 2026 16:24

This branch was successfully deployed

1 active and 1 inactive deployments
vercel-preview — ec67f179 Deployed Sep 17, 2026 by blove via vercel-native #1939
Preview — ec67f179 Deployed Sep 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

middleware: init/dispose lifecycle hooks for connection pools

1 participant