Skip to content

fix: forward TResponse generic through SDK Options type for SSE endpo…#3466

Open
bilby91 wants to merge 1 commit intohey-api:mainfrom
crunchloop:fix/sse-onsseevent-response-typing
Open

fix: forward TResponse generic through SDK Options type for SSE endpo…#3466
bilby91 wants to merge 1 commit intohey-api:mainfrom
crunchloop:fix/sse-onsseevent-response-typing

Conversation

@bilby91
Copy link
Contributor

@bilby91 bilby91 commented Feb 26, 2026

…ints

The onSseEvent callback was typed as StreamEvent because the response type was never threaded through the generated Options type on the non-Nuxt path. This adds a TResponse generic to the SDK Options type alias and forwards it to the client Options, then passes the unwrapped response type for SSE endpoints so event.data is correctly typed.

Closes #3463

@bolt-new-by-stackblitz
Copy link

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@pullfrog
Copy link

pullfrog bot commented Feb 26, 2026

Leaping into action...

Pullfrog  | View workflow run | Using OpenCode | Triggered by Pullfrogpullfrog.com𝕏

@changeset-bot
Copy link

changeset-bot bot commented Feb 26, 2026

⚠️ No Changeset found

Latest commit: 32c0ce3

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link

vercel bot commented Feb 26, 2026

@bilby91 is attempting to deploy a commit to the Hey API Team on Vercel.

A member of the Team first needs to authorize it.

@dosubot dosubot bot added size:M This PR changes 30-99 lines, ignoring generated files. bug 🔥 Broken or incorrect behavior. labels Feb 26, 2026
@codecov
Copy link

codecov bot commented Feb 27, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 39.25%. Comparing base (c3e497c) to head (32c0ce3).
⚠️ Report is 6 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3466      +/-   ##
==========================================
+ Coverage   38.99%   39.25%   +0.26%     
==========================================
  Files         513      513              
  Lines       18768    18781      +13     
  Branches     5565     5577      +12     
==========================================
+ Hits         7319     7373      +54     
+ Misses       9252     9219      -33     
+ Partials     2197     2189       -8     
Flag Coverage Δ
unittests 39.25% <ø> (+0.26%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment on lines +74 to +83
if (throwOnError) {
return $.type(symbolOptions)
.generic(isDataAllowed ? (symbolDataType ?? 'unknown') : 'never')
.generic(throwOnError)
.generic(symbolResponseType ?? 'unknown');
}
return $.type(symbolOptions)
.generic(isDataAllowed ? (symbolDataType ?? 'unknown') : 'never')
.generic('boolean')
.generic(symbolResponseType ?? 'unknown');
Copy link
Member

@mrlubos mrlubos Feb 27, 2026

Choose a reason for hiding this comment

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

@bilby91 can you use .$if here? It should be more readable then

EDIT: no need for .$if, .generic(throwOnError !== undefined ? throwOnError : 'boolean') should work

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This should be resolved.

* Get events
*/
export const eventSubscribe = <ThrowOnError extends boolean = false>(options?: Options<EventSubscribeData, ThrowOnError>) => (options?.client ?? client).sse.get<EventSubscribeResponses, unknown, ThrowOnError>({ url: '/event', ...options });
export const eventSubscribe = <ThrowOnError extends boolean = false>(options?: Options<EventSubscribeData, ThrowOnError, EventSubscribeResponse>) => (options?.client ?? client).sse.get<EventSubscribeResponse, unknown, ThrowOnError>({ url: '/event', ...options });
Copy link
Member

Choose a reason for hiding this comment

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

@bilby91 is this client.sse.get<T> change expected? Seems it was already typed and this changes the type

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I went back and reviewed the code more carefully. I believe this change is intentional.

Before: client.sse.get<EventSubscribeResponses, ...> where EventSubscribeResponses = { 200: Event } — a status-code-keyed object. This flows as TData into StreamEvent, so onSseEvent gets event.data: { 200: Event } instead of event.data: Event.

After: client.sse.get<EventSubscribeResponse, ...> where EventSubscribeResponse = Event — the unwrapped success type. Now onSseEvent correctly gets event.data: Event.

From what I can tell, the stream async generator wasn't affected because ServerSentEventsResult already handles the unwrapping via TData extends Record<string, unknown> ? TData[keyof TData] : TData. But StreamEvent (used by onSseEvent) has no such unwrapping — it uses data: TData directly. So using Response instead of Responses seems needed to get the correct type for onSseEvent.

Let me know if I'm missing something!

Copy link
Member

Choose a reason for hiding this comment

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

@bilby91 this change breaks typing for the stream. The current version works like this:

Image

Notice onSseEvent() is not typed and the stream is typed. Compare to your pull request:

Image

Now onSseEvent() is typed as you correctly claim, but the stream type is broken

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I see what you mean. Will get it fixed. We definitely don't want to break opencode, we also using it :)

@bilby91
Copy link
Contributor Author

bilby91 commented Feb 27, 2026

@mrlubos Let me review this again. There is so much autogeneration here is kind of hard sometimes. This is the patch that I'm currently running in my project to fix the three bugs I reported yesterday.

This patch on top of 0.92.3 is working fine for me.

diff --git a/dist/clients/fetch/client.ts b/dist/clients/fetch/client.ts
index 1b05a0ffdc77c2c0c24ab69119417f1329db9694..65519b66e45071dc5d918e35662e53c6aaa8b84b 100644
--- a/dist/clients/fetch/client.ts
+++ b/dist/clients/fetch/client.ts
@@ -239,7 +239,7 @@ export const createClient = (config: Config = {}): Client => {
     const { opts, url } = await beforeRequest(options);
     return createSseClient({
       ...opts,
-      body: opts.body as BodyInit | null | undefined,
+      body: opts.serializedBody as BodyInit | null | undefined,
       headers: opts.headers as unknown as Record<string, string>,
       method,
       onRequest: async (url, init) => {
diff --git a/dist/init-W7OZahc5.mjs b/dist/init-W7OZahc5.mjs
index ea4d72b9c856c5221730235d591b1f0c0672b9b3..e9bb3972d61af7128f1ed0aba9dded4c0acba66f 100644
--- a/dist/init-W7OZahc5.mjs
+++ b/dist/init-W7OZahc5.mjs
@@ -4472,6 +4472,7 @@ const createResponseValidator = ({ operation, plugin }) => {
 /** TODO: needs complete refactor */
 const operationOptionsType = ({ isDataAllowed = true, operation, plugin, throwOnError }) => {
 	const isNuxtClient = getClientPlugin(getTypedConfig(plugin)).name === "@hey-api/client-nuxt";
+	const isSse = hasOperationSse({ operation });
 	const symbolDataType = isDataAllowed ? plugin.querySymbol({
 		category: "type",
 		resource: "operation",
@@ -4493,6 +4494,16 @@ const operationOptionsType = ({ isDataAllowed = true, operation, plugin, throwOn
 		});
 		return $.type(symbolOptions).generic(nuxtTypeComposable).generic(isDataAllowed ? symbolDataType ?? "unknown" : "never").generic(symbolResponseType ?? "unknown").generic(nuxtTypeDefault);
 	}
+	if (isSse) {
+		const symbolResponseType = plugin.querySymbol({
+			category: "type",
+			resource: "operation",
+			resourceId: operation.id,
+			role: "response"
+		});
+		if (throwOnError) return $.type(symbolOptions).generic(isDataAllowed ? symbolDataType ?? "unknown" : "never").generic(throwOnError).generic(symbolResponseType ?? "unknown");
+		return $.type(symbolOptions).generic(isDataAllowed ? symbolDataType ?? "unknown" : "never").generic("boolean").generic(symbolResponseType ?? "unknown");
+	}
 	if (throwOnError) return $.type(symbolOptions).generic(isDataAllowed ? symbolDataType ?? "unknown" : "never").generic(throwOnError);
 	return $.type(symbolOptions).$if(!isDataAllowed || symbolDataType, (t) => t.generic(isDataAllowed ? symbolDataType : "never"));
 };
@@ -4559,11 +4570,12 @@ const getResponseType = (contentType) => {
 function operationStatements({ isRequiredOptions, opParameters, operation, plugin }) {
 	const client = getClientPlugin(getTypedConfig(plugin));
 	const isNuxtClient = client.name === "@hey-api/client-nuxt";
+	const isSse = hasOperationSse({ operation });
 	const symbolResponseType = plugin.querySymbol({
 		category: "type",
 		resource: "operation",
 		resourceId: operation.id,
-		role: isNuxtClient ? "response" : "responses"
+		role: (isNuxtClient || isSse) ? "response" : "responses"
 	});
 	const symbolErrorType = plugin.querySymbol({
 		category: "type",
@@ -6107,7 +6119,7 @@ const createTypeOptions = ({ plugin }) => {
 		resource: "client-options",
 		tool: "sdk"
 	} });
-	const typeOptions = $.type.alias(symbolOptions).export().$if(isNuxtClient, (t) => t.generic("TComposable", (g) => g.extends(plugin.external("client.Composable")).default($.type.literal("$fetch"))).generic("TData", (g) => g.extends(symbolTDataShape).default(symbolTDataShape)).generic(nuxtTypeResponse, (g) => g.default("unknown")).generic(nuxtTypeDefault, (g) => g.default("undefined")), (t) => t.generic("TData", (g) => g.extends(symbolTDataShape).default(symbolTDataShape)).generic("ThrowOnError", (g) => g.extends("boolean").default("boolean"))).type($.type.and($.type(symbolClientOptions).$if(isNuxtClient, (t) => t.generic("TComposable").generic("TData").generic(nuxtTypeResponse).generic(nuxtTypeDefault), (t) => t.generic("TData").generic("ThrowOnError")), $.type.object().prop("client", (p) => p.doc([
+	const typeOptions = $.type.alias(symbolOptions).export().$if(isNuxtClient, (t) => t.generic("TComposable", (g) => g.extends(plugin.external("client.Composable")).default($.type.literal("$fetch"))).generic("TData", (g) => g.extends(symbolTDataShape).default(symbolTDataShape)).generic(nuxtTypeResponse, (g) => g.default("unknown")).generic(nuxtTypeDefault, (g) => g.default("undefined")), (t) => t.generic("TData", (g) => g.extends(symbolTDataShape).default(symbolTDataShape)).generic("ThrowOnError", (g) => g.extends("boolean").default("boolean")).generic("TResponse", (g) => g.default("unknown"))).type($.type.and($.type(symbolClientOptions).$if(isNuxtClient, (t) => t.generic("TComposable").generic("TData").generic(nuxtTypeResponse).generic(nuxtTypeDefault), (t) => t.generic("TData").generic("ThrowOnError").generic("TResponse")), $.type.object().prop("client", (p) => p.doc([
 		"You can provide a client instance returned by `createClient()` instead of",
 		"individual options. This might be also useful if you want to implement a",
 		"custom client."
@@ -7814,6 +7826,7 @@ const createInfiniteQueryOptions = ({ operation, plugin }) => {
 //#endregion
 //#region src/plugins/@tanstack/query-core/v5/mutationOptions.ts
 const createMutationOptions = ({ operation, plugin }) => {
+	if (hasOperationSse({ operation })) return;
 	const symbolMutationOptionsType = plugin.external(`${plugin.name}.MutationOptions`);
 	const typeData = useTypeData({
 		operation,

@mrlubos
Copy link
Member

mrlubos commented Feb 27, 2026

There is so much autogeneration here is kind of hard sometimes

I can relate!

@bilby91 bilby91 force-pushed the fix/sse-onsseevent-response-typing branch 3 times, most recently from e7c2994 to f30daba Compare March 23, 2026 20:16
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 23, 2026

Open in StackBlitz

@hey-api/codegen-core

npm i https://pkg.pr.new/@hey-api/codegen-core@3466

@hey-api/json-schema-ref-parser

npm i https://pkg.pr.new/@hey-api/json-schema-ref-parser@3466

@hey-api/nuxt

npm i https://pkg.pr.new/@hey-api/nuxt@3466

@hey-api/openapi-ts

npm i https://pkg.pr.new/@hey-api/openapi-ts@3466

@hey-api/shared

npm i https://pkg.pr.new/@hey-api/shared@3466

@hey-api/types

npm i https://pkg.pr.new/@hey-api/types@3466

@hey-api/vite-plugin

npm i https://pkg.pr.new/@hey-api/vite-plugin@3466

commit: f30daba

@bilby91
Copy link
Contributor Author

bilby91 commented Mar 23, 2026

@mrlubos PR should be passing now. I finally had more time to look at this with Claude :)

Copy link
Member

@mrlubos mrlubos left a comment

Choose a reason for hiding this comment

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

@bilby91 left you a comment. The intended change looks good, but it breaks some of the existing types! We definitely don't want to break OpenCode's SDK, so that's the only change request

* Get events
*/
export const eventSubscribe = <ThrowOnError extends boolean = false>(options?: Options<EventSubscribeData, ThrowOnError>) => (options?.client ?? client).sse.get<EventSubscribeResponses, unknown, ThrowOnError>({ url: '/event', ...options });
export const eventSubscribe = <ThrowOnError extends boolean = false>(options?: Options<EventSubscribeData, ThrowOnError, EventSubscribeResponse>) => (options?.client ?? client).sse.get<EventSubscribeResponse, unknown, ThrowOnError>({ url: '/event', ...options });
Copy link
Member

Choose a reason for hiding this comment

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

@bilby91 this change breaks typing for the stream. The current version works like this:

Image

Notice onSseEvent() is not typed and the stream is typed. Compare to your pull request:

Image

Now onSseEvent() is typed as you correctly claim, but the stream type is broken

@bilby91 bilby91 force-pushed the fix/sse-onsseevent-response-typing branch from f30daba to 3a260ef Compare March 24, 2026 03:14
…ints

The onSseEvent callback was typed as StreamEvent<unknown> because the
response type was never threaded through the generated Options type on
the non-Nuxt path. This adds a TResponse generic to the SDK Options
type alias and forwards it to the client Options, then passes the
unwrapped response type for SSE endpoints so event.data is correctly
typed.

Closes hey-api#3463

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@bilby91 bilby91 force-pushed the fix/sse-onsseevent-response-typing branch from 3a260ef to 32c0ce3 Compare March 24, 2026 03:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug 🔥 Broken or incorrect behavior. size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSE onSseEvent callback is typed as StreamEvent<unknown> — TResponse not forwarded through Options

2 participants