From 1bc88d97eed7621f39e86bd310a1c6db005916eb Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Sat, 6 Jun 2026 14:27:00 +0700 Subject: [PATCH] fix(plugins): chain payload between emitHookBlocking handlers (#3286) (#3286) Integrated into release/v3.8.12. Salvaged the emitHookBlocking payload-chaining fix from the now-closed plugins-v4 branch (#3221) and adapted it to the shipped release hooks.ts: each blocking handler now sees the body/metadata as mutated by previous handlers. TDD regression test included (RED before, GREEN after); existing plugins-hooks suites green (19+5), typecheck + lint clean. --- src/lib/plugins/hooks.ts | 6 +- ...plugins-hook-payload-chaining-3286.test.ts | 86 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 tests/unit/plugins-hook-payload-chaining-3286.test.ts diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts index aabd400796..87c4a3440f 100644 --- a/src/lib/plugins/hooks.ts +++ b/src/lib/plugins/hooks.ts @@ -190,7 +190,11 @@ export async function emitHookBlocking( continue; } try { - const result = await reg.handler(payload); + // Chain the payload: each handler must see the body/metadata as mutated by + // previous handlers, not the original static payload — otherwise plugin B + // can't observe plugin A's changes. (#3286) + const currentPayload = { ...ctx, body: mergedBody, metadata: mergedMetadata }; + const result = await reg.handler(currentPayload); if (result && typeof result === "object") { if ("body" in result) mergedBody = (result as Record).body; if ("metadata" in result) diff --git a/tests/unit/plugins-hook-payload-chaining-3286.test.ts b/tests/unit/plugins-hook-payload-chaining-3286.test.ts new file mode 100644 index 0000000000..18b53226c6 --- /dev/null +++ b/tests/unit/plugins-hook-payload-chaining-3286.test.ts @@ -0,0 +1,86 @@ +/** + * tests/unit/plugins-hook-payload-chaining-3286.test.ts + * + * Regression for #3286 — emitHookBlocking must CHAIN the payload between + * handlers. Before the fix, every handler received the original static + * `payload`, so plugin B could not observe plugin A's `body`/`metadata` + * mutations (the accumulated mergedBody/mergedMetadata were only used for the + * final return value, never fed forward to the next handler). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + registerHook, + emitHookBlocking, + resetHooks, +} from "../../src/lib/plugins/hooks.ts"; + +test.afterEach(() => { + resetHooks(); +}); + +test("emitHookBlocking feeds each handler the body mutated by previous handlers", async () => { + const seenByB: unknown[] = []; + + // Plugin A: rewrites the body. + registerHook( + "onRequest", + "plugin-a", + (p: unknown) => { + const ctx = p as { body?: { value?: number } }; + return { body: { value: (ctx.body?.value ?? 0) + 1 } }; + }, + 10 + ); + + // Plugin B (lower priority → runs after A): records what body it received. + registerHook( + "onRequest", + "plugin-b", + (p: unknown) => { + const ctx = p as { body?: { value?: number } }; + seenByB.push(ctx.body); + return { body: { value: (ctx.body?.value ?? 0) + 1 } }; + }, + 20 + ); + + const result = await emitHookBlocking("onRequest", { body: { value: 0 } }); + + // Plugin B must have seen plugin A's mutation ({value:1}), not the original {value:0}. + assert.deepEqual(seenByB, [{ value: 1 }]); + // Final merged body reflects BOTH handlers: 0 → 1 (A) → 2 (B). + assert.deepEqual((result as { body?: { value?: number } }).body, { value: 2 }); +}); + +test("emitHookBlocking chains metadata across handlers", async () => { + let metadataSeenByB: Record | undefined; + + registerHook( + "onRequest", + "meta-a", + () => ({ metadata: { a: true } }), + 10 + ); + + registerHook( + "onRequest", + "meta-b", + (p: unknown) => { + metadataSeenByB = (p as { metadata?: Record }).metadata; + return { metadata: { b: true } }; + }, + 20 + ); + + const result = await emitHookBlocking("onRequest", { body: {}, metadata: {} }); + + // Plugin B sees plugin A's metadata. + assert.equal(metadataSeenByB?.a, true); + // Final metadata carries both. + const meta = (result as { metadata?: Record }).metadata; + assert.equal(meta?.a, true); + assert.equal(meta?.b, true); +});