From 67439dc7fc4875226d475bc8619389336fdb7d04 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 06:34:49 +0100 Subject: [PATCH] fix(plugins): parse a subscription's input once in the plugin process server.handle wrapped subscription handlers in a second input parse, so a transforming input schema rejected its own parsed output before the handler ran. --- .../src/server/plugins/plugin-process.ts | 5 ++--- .../plugins/plugin-subscriptions.e2e.test.ts | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 8e2c41deda..9afbddba5a 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -135,9 +135,8 @@ export function createPluginWorker(options: { handler: (input: unknown, context: never) => unknown, ) => { if ("event" in contract) { - registerSubscription(contract, (input, context) => - handler(contract.input.parse(input), context as never), - ); + // The subscription handlers parse the input once and pass the result here. + registerSubscription(contract, handler as PluginSubscriptionHandler); return; } register(contract, (input, context) => handler(contract.input.parse(input), context as never)); diff --git a/packages/server/src/server/plugins/plugin-subscriptions.e2e.test.ts b/packages/server/src/server/plugins/plugin-subscriptions.e2e.test.ts index 4a3af2daad..e5f427eae2 100644 --- a/packages/server/src/server/plugins/plugin-subscriptions.e2e.test.ts +++ b/packages/server/src/server/plugins/plugin-subscriptions.e2e.test.ts @@ -34,6 +34,11 @@ const nestedPush = defineSubscription({ input: z.object({}), event: z.object({ data: z.instanceof(Uint8Array) }), }); +const repeated = defineSubscription({ + name: "repeated", + input: z.object({ times: z.string().transform(Number) }), + event: z.string().transform((text) => text.length), +}); const watchers = defineRpc({ name: "watchers", input: z.object({}), @@ -73,6 +78,9 @@ export default function contribute(server: PluginServerContext) { console.error("nested push", error.message); }); }); + server.handle(repeated, async ({ times }, { push }) => { + await push(typeof times === "number" ? "x".repeat(times) : "input arrived untransformed"); + }); server.handle(watchers, () => ({ starts, active })); server.handle(thumbnail, ({ size }) => Buffer.alloc(size, 7)); server.handle(nestedOutput, () => ({ data: new Uint8Array(1) })); @@ -200,6 +208,19 @@ test("a subscription delivers JSON and binary events in order and release aborts } }, 60_000); +test("a schema transform runs once: the handler gets the parsed input and the wire carries the pushed event", async () => { + const harness = await startHarness(); + try { + const subscription = harness.client.observePluginRpc(PLUGIN_ID, "repeated", { times: "2" }); + const received = collect(subscription); + await subscription.ready; + // The client parses "xx" into 2; parsing it on the plugin side too would reject it. + await expect.poll(() => received).toEqual([{ type: "event", event: "xx" }, { type: "closed" }]); + } finally { + await harness.close(); + } +}, 60_000); + test("dropping the connection releases subscriptions and reconnecting runs the handler again", async () => { const harness = await startHarness(); const { client, sockets } = harness;