From e8f2069f3b033572cf09b93a52f1890a10e84629 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Mon, 10 Aug 2026 14:20:24 -0400 Subject: [PATCH 01/11] feat(http-specs): add SSE protocol scenarios Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ee116a23-26c2-4be5-b305-6c2733ad0790 --- packages/http-specs/spec-summary.md | 81 ++++++++++++++++ .../http-specs/specs/streaming/sse/main.tsp | 94 +++++++++++++++++++ .../http-specs/specs/streaming/sse/mockapi.ts | 89 +++++++++++++++++- 3 files changed, 263 insertions(+), 1 deletion(-) diff --git a/packages/http-specs/spec-summary.md b/packages/http-specs/spec-summary.md index 77e08a1490f..995ab1a9d86 100644 --- a/packages/http-specs/spec-summary.md +++ b/packages/http-specs/spec-summary.md @@ -5387,6 +5387,87 @@ data: [DONE] ``` +### Streaming_Sse_Protocol_id + +- Endpoint: `get /streaming/sse/protocol/id` + +An SSE event with an `id` field. The event ID is envelope metadata and is not +part of the typed event data. + +Expected response body (content type `text/event-stream`): + +``` +id: event-1 +data: {"message": "hello"} + +``` + +### Streaming_Sse_Protocol_invalidId + +- Endpoint: `get /streaming/sse/protocol/invalid-id` + +An SSE event with an `id` field containing U+0000 NULL. The field is ignored +according to the SSE parsing rules. + +Expected response body (content type `text/event-stream`): + +``` +id: invalid\u0000id +data: {"message": "hello"} + +``` + +### Streaming_Sse_Protocol_invalidRetry + +- Endpoint: `get /streaming/sse/protocol/invalid-retry` + +An SSE event with an invalid `retry` field. Since the value contains +non-ASCII-digit characters, the field is ignored. + +Expected response body (content type `text/event-stream`): + +``` +retry: not-a-number +data: {"message": "hello"} + +``` + +### Streaming_Sse_Protocol_reconnect + +- Endpoint: `get /streaming/sse/protocol/reconnect` + +An SSE stream that resumes after a reconnect. The client sends the most +recently received event ID in the `Last-Event-ID` request header. + +Expected request header: + +``` +Last-Event-ID: event-1 +``` + +Expected response body (content type `text/event-stream`): + +``` +id: event-2 +data: {"message": "world"} + +``` + +### Streaming_Sse_Protocol_retry + +- Endpoint: `get /streaming/sse/protocol/retry` + +An SSE event with a valid `retry` field containing only ASCII digits. The field +sets the client's reconnection delay and is not part of the typed event data. + +Expected response body (content type `text/event-stream`): + +``` +retry: 1000 +data: {"message": "hello"} + +``` + ### Streaming_Sse_Unnamed_receive - Endpoint: `get /streaming/sse/unnamed/receive` diff --git a/packages/http-specs/specs/streaming/sse/main.tsp b/packages/http-specs/specs/streaming/sse/main.tsp index 16e81a33deb..95264225c2f 100644 --- a/packages/http-specs/specs/streaming/sse/main.tsp +++ b/packages/http-specs/specs/streaming/sse/main.tsp @@ -151,3 +151,97 @@ namespace Retrieve { @route("stream") op stream(@body request: RetrievalRequest): SSEStream; } + +@route("protocol") +namespace Protocol { + model Info { + message: string; + } + + @events + union ProtocolEvents { + @Events.contentType("application/json") + Info, + } + + @scenario + @scenarioDoc(""" + An SSE event with an `id` field. The event ID is envelope metadata and is + not part of the typed event data. + + Expected response body (content type `text/event-stream`): + ``` + id: event-1 + data: {"message": "hello"} + + ``` + """) + @route("id") + op id(): SSEStream; + + @scenario + @scenarioDoc(""" + An SSE event with an `id` field containing U+0000 NULL. The field is + ignored according to the SSE parsing rules. + + Expected response body (content type `text/event-stream`): + ``` + id: invalid\u0000id + data: {"message": "hello"} + + ``` + """) + @route("invalid-id") + op invalidId(): SSEStream; + + @scenario + @scenarioDoc(""" + An SSE event with a valid `retry` field containing only ASCII digits. The + field sets the client's reconnection delay and is not part of the typed + event data. + + Expected response body (content type `text/event-stream`): + ``` + retry: 1000 + data: {"message": "hello"} + + ``` + """) + @route("retry") + op retry(): SSEStream; + + @scenario + @scenarioDoc(""" + An SSE event with an invalid `retry` field. Since the value contains + non-ASCII-digit characters, the field is ignored. + + Expected response body (content type `text/event-stream`): + ``` + retry: not-a-number + data: {"message": "hello"} + + ``` + """) + @route("invalid-retry") + op invalidRetry(): SSEStream; + + @scenario + @scenarioDoc(""" + An SSE stream that resumes after a reconnect. The client sends the most + recently received event ID in the `Last-Event-ID` request header. + + Expected request header: + ``` + Last-Event-ID: event-1 + ``` + + Expected response body (content type `text/event-stream`): + ``` + id: event-2 + data: {"message": "world"} + + ``` + """) + @route("reconnect") + op reconnect(): SSEStream; +} diff --git a/packages/http-specs/specs/streaming/sse/mockapi.ts b/packages/http-specs/specs/streaming/sse/mockapi.ts index d74f321d927..1484539d2f7 100644 --- a/packages/http-specs/specs/streaming/sse/mockapi.ts +++ b/packages/http-specs/specs/streaming/sse/mockapi.ts @@ -3,7 +3,11 @@ import { passOnSuccess } from "@typespec/spec-api"; export const Scenarios: Record = {}; -const unnamedStream = ['data: {"desc": "one"}', 'data: {"desc": "two"}', 'data: {"desc": "three"}'] +const unnamedStream = [ + 'data: {"desc": "one"}', + 'data: {"desc": "two"}', + 'data: {"desc": "three"}', +] .map((event) => `${event}\n\n`) .join(""); @@ -70,3 +74,86 @@ Scenarios.Streaming_Sse_Retrieve_stream = passOnSuccess({ }, kind: "MockApiDefinition", }); + +const protocolEvent = (fields: string[]) => + Buffer.from(`${fields.join("\n")}\n\n`); + +Scenarios.Streaming_Sse_Protocol_id = passOnSuccess({ + uri: "/streaming/sse/protocol/id", + method: "get", + request: {}, + response: { + status: 200, + body: { + rawContent: protocolEvent(["id: event-1", 'data: {"message": "hello"}']), + contentType: "text/event-stream", + }, + }, + kind: "MockApiDefinition", +}); + +Scenarios.Streaming_Sse_Protocol_invalidId = passOnSuccess({ + uri: "/streaming/sse/protocol/invalid-id", + method: "get", + request: {}, + response: { + status: 200, + body: { + rawContent: protocolEvent([ + "id: invalid\u0000id", + 'data: {"message": "hello"}', + ]), + contentType: "text/event-stream", + }, + }, + kind: "MockApiDefinition", +}); + +Scenarios.Streaming_Sse_Protocol_retry = passOnSuccess({ + uri: "/streaming/sse/protocol/retry", + method: "get", + request: {}, + response: { + status: 200, + body: { + rawContent: protocolEvent(["retry: 1000", 'data: {"message": "hello"}']), + contentType: "text/event-stream", + }, + }, + kind: "MockApiDefinition", +}); + +Scenarios.Streaming_Sse_Protocol_invalidRetry = passOnSuccess({ + uri: "/streaming/sse/protocol/invalid-retry", + method: "get", + request: {}, + response: { + status: 200, + body: { + rawContent: protocolEvent([ + "retry: not-a-number", + 'data: {"message": "hello"}', + ]), + contentType: "text/event-stream", + }, + }, + kind: "MockApiDefinition", +}); + +Scenarios.Streaming_Sse_Protocol_reconnect = passOnSuccess({ + uri: "/streaming/sse/protocol/reconnect", + method: "get", + request: { + headers: { + "last-event-id": "event-1", + }, + }, + response: { + status: 200, + body: { + rawContent: protocolEvent(["id: event-2", 'data: {"message": "world"}']), + contentType: "text/event-stream", + }, + }, + kind: "MockApiDefinition", +}); From 2645da88cefc4cd6379b06b6a57738ee6a184b76 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Mon, 10 Aug 2026 14:24:05 -0400 Subject: [PATCH 02/11] chore: add SSE specs changeset Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ee116a23-26c2-4be5-b305-6c2733ad0790 --- ...ai-msft-add-sse-spector-scenarios-2026-7-10-14-23-58.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .chronus/changes/iscai-msft-add-sse-spector-scenarios-2026-7-10-14-23-58.md diff --git a/.chronus/changes/iscai-msft-add-sse-spector-scenarios-2026-7-10-14-23-58.md b/.chronus/changes/iscai-msft-add-sse-spector-scenarios-2026-7-10-14-23-58.md new file mode 100644 index 00000000000..0cfa45d7805 --- /dev/null +++ b/.chronus/changes/iscai-msft-add-sse-spector-scenarios-2026-7-10-14-23-58.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/http-specs" +--- + +Add SSE protocol coverage for event IDs, retry fields, and reconnection \ No newline at end of file From d35f7beb27a7b56c9c59f1a0172d9f70155b3672 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Mon, 10 Aug 2026 14:31:52 -0400 Subject: [PATCH 03/11] fix(http-specs): format SSE scenarios Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ee116a23-26c2-4be5-b305-6c2733ad0790 --- packages/http-specs/spec-summary.md | 77 ++++++++++--------- .../http-specs/specs/streaming/sse/main.tsp | 24 +++--- .../http-specs/specs/streaming/sse/mockapi.ts | 19 +---- 3 files changed, 55 insertions(+), 65 deletions(-) diff --git a/packages/http-specs/spec-summary.md b/packages/http-specs/spec-summary.md index 995ab1a9d86..2ae36494582 100644 --- a/packages/http-specs/spec-summary.md +++ b/packages/http-specs/spec-summary.md @@ -5356,43 +5356,12 @@ data: [DONE] ``` -### Streaming_Sse_Retrieve_stream - -- Endpoint: `post /streaming/sse/retrieve/stream` - -A POST request with a JSON body whose response is an SSE stream, modeled -after a knowledge-retrieval service. The server streams `partialResult` -events as results become available, a final `finalResult` event, and a -terminal `[DONE]` event. - -Expected request body (content type `application/json`): - -``` -{"query": "what is typespec?"} -``` - -Expected response body (content type `text/event-stream`): - -``` -event: partialResult -data: {"text": "partial one"} - -event: partialResult -data: {"text": "partial two"} - -event: finalResult -data: {"references": ["doc1", "doc2"]} - -data: [DONE] - -``` - ### Streaming_Sse_Protocol_id - Endpoint: `get /streaming/sse/protocol/id` -An SSE event with an `id` field. The event ID is envelope metadata and is not -part of the typed event data. +An SSE event with an `id` field. The event ID is envelope metadata and is +not part of the typed event data. Expected response body (content type `text/event-stream`): @@ -5406,13 +5375,13 @@ data: {"message": "hello"} - Endpoint: `get /streaming/sse/protocol/invalid-id` -An SSE event with an `id` field containing U+0000 NULL. The field is ignored -according to the SSE parsing rules. +An SSE event with an `id` field containing U+0000 NULL. The field is +ignored according to the SSE parsing rules. Expected response body (content type `text/event-stream`): ``` -id: invalid\u0000id +id: invalidid data: {"message": "hello"} ``` @@ -5457,8 +5426,9 @@ data: {"message": "world"} - Endpoint: `get /streaming/sse/protocol/retry` -An SSE event with a valid `retry` field containing only ASCII digits. The field -sets the client's reconnection delay and is not part of the typed event data. +An SSE event with a valid `retry` field containing only ASCII digits. The +field sets the client's reconnection delay and is not part of the typed +event data. Expected response body (content type `text/event-stream`): @@ -5468,6 +5438,37 @@ data: {"message": "hello"} ``` +### Streaming_Sse_Retrieve_stream + +- Endpoint: `post /streaming/sse/retrieve/stream` + +A POST request with a JSON body whose response is an SSE stream, modeled +after a knowledge-retrieval service. The server streams `partialResult` +events as results become available, a final `finalResult` event, and a +terminal `[DONE]` event. + +Expected request body (content type `application/json`): + +``` +{"query": "what is typespec?"} +``` + +Expected response body (content type `text/event-stream`): + +``` +event: partialResult +data: {"text": "partial one"} + +event: partialResult +data: {"text": "partial two"} + +event: finalResult +data: {"references": ["doc1", "doc2"]} + +data: [DONE] + +``` + ### Streaming_Sse_Unnamed_receive - Endpoint: `get /streaming/sse/unnamed/receive` diff --git a/packages/http-specs/specs/streaming/sse/main.tsp b/packages/http-specs/specs/streaming/sse/main.tsp index 95264225c2f..66f314f9e94 100644 --- a/packages/http-specs/specs/streaming/sse/main.tsp +++ b/packages/http-specs/specs/streaming/sse/main.tsp @@ -168,12 +168,12 @@ namespace Protocol { @scenarioDoc(""" An SSE event with an `id` field. The event ID is envelope metadata and is not part of the typed event data. - + Expected response body (content type `text/event-stream`): ``` id: event-1 data: {"message": "hello"} - + ``` """) @route("id") @@ -183,12 +183,12 @@ namespace Protocol { @scenarioDoc(""" An SSE event with an `id` field containing U+0000 NULL. The field is ignored according to the SSE parsing rules. - + Expected response body (content type `text/event-stream`): ``` - id: invalid\u0000id + id: invalidid data: {"message": "hello"} - + ``` """) @route("invalid-id") @@ -199,12 +199,12 @@ namespace Protocol { An SSE event with a valid `retry` field containing only ASCII digits. The field sets the client's reconnection delay and is not part of the typed event data. - + Expected response body (content type `text/event-stream`): ``` retry: 1000 data: {"message": "hello"} - + ``` """) @route("retry") @@ -214,12 +214,12 @@ namespace Protocol { @scenarioDoc(""" An SSE event with an invalid `retry` field. Since the value contains non-ASCII-digit characters, the field is ignored. - + Expected response body (content type `text/event-stream`): ``` retry: not-a-number data: {"message": "hello"} - + ``` """) @route("invalid-retry") @@ -229,17 +229,17 @@ namespace Protocol { @scenarioDoc(""" An SSE stream that resumes after a reconnect. The client sends the most recently received event ID in the `Last-Event-ID` request header. - + Expected request header: ``` Last-Event-ID: event-1 ``` - + Expected response body (content type `text/event-stream`): ``` id: event-2 data: {"message": "world"} - + ``` """) @route("reconnect") diff --git a/packages/http-specs/specs/streaming/sse/mockapi.ts b/packages/http-specs/specs/streaming/sse/mockapi.ts index 1484539d2f7..3bab7b4598b 100644 --- a/packages/http-specs/specs/streaming/sse/mockapi.ts +++ b/packages/http-specs/specs/streaming/sse/mockapi.ts @@ -3,11 +3,7 @@ import { passOnSuccess } from "@typespec/spec-api"; export const Scenarios: Record = {}; -const unnamedStream = [ - 'data: {"desc": "one"}', - 'data: {"desc": "two"}', - 'data: {"desc": "three"}', -] +const unnamedStream = ['data: {"desc": "one"}', 'data: {"desc": "two"}', 'data: {"desc": "three"}'] .map((event) => `${event}\n\n`) .join(""); @@ -75,8 +71,7 @@ Scenarios.Streaming_Sse_Retrieve_stream = passOnSuccess({ kind: "MockApiDefinition", }); -const protocolEvent = (fields: string[]) => - Buffer.from(`${fields.join("\n")}\n\n`); +const protocolEvent = (fields: string[]) => Buffer.from(`${fields.join("\n")}\n\n`); Scenarios.Streaming_Sse_Protocol_id = passOnSuccess({ uri: "/streaming/sse/protocol/id", @@ -99,10 +94,7 @@ Scenarios.Streaming_Sse_Protocol_invalidId = passOnSuccess({ response: { status: 200, body: { - rawContent: protocolEvent([ - "id: invalid\u0000id", - 'data: {"message": "hello"}', - ]), + rawContent: protocolEvent(["id: invalid\u0000id", 'data: {"message": "hello"}']), contentType: "text/event-stream", }, }, @@ -130,10 +122,7 @@ Scenarios.Streaming_Sse_Protocol_invalidRetry = passOnSuccess({ response: { status: 200, body: { - rawContent: protocolEvent([ - "retry: not-a-number", - 'data: {"message": "hello"}', - ]), + rawContent: protocolEvent(["retry: not-a-number", 'data: {"message": "hello"}']), contentType: "text/event-stream", }, }, From 968005eaeb0db02773b91f9da7780940f5a6325f Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Fri, 21 Aug 2026 14:04:27 -0400 Subject: [PATCH 04/11] fix(http-specs): address SSE protocol review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/http-specs/spec-summary.md | 12 +++- .../http-specs/specs/streaming/sse/main.tsp | 16 +++-- .../http-specs/specs/streaming/sse/mockapi.ts | 65 +++++++++++++++---- 3 files changed, 74 insertions(+), 19 deletions(-) diff --git a/packages/http-specs/spec-summary.md b/packages/http-specs/spec-summary.md index 3b81e0b50cd..e5649746f7d 100644 --- a/packages/http-specs/spec-summary.md +++ b/packages/http-specs/spec-summary.md @@ -5396,6 +5396,7 @@ Expected response body (content type `text/event-stream`): ``` id: event-1 +event: message data: {"message": "hello"} ``` @@ -5411,6 +5412,7 @@ Expected response body (content type `text/event-stream`): ``` id: invalidid +event: message data: {"message": "hello"} ``` @@ -5426,6 +5428,7 @@ Expected response body (content type `text/event-stream`): ``` retry: not-a-number +event: message data: {"message": "hello"} ``` @@ -5434,10 +5437,11 @@ data: {"message": "hello"} - Endpoint: `get /streaming/sse/protocol/reconnect` -An SSE stream that resumes after a reconnect. The client sends the most -recently received event ID in the `Last-Event-ID` request header. +An SSE stream that resumes after a reconnect. The first response closes after +sending `event-1`. On reconnect, the client sends the most recently received +event ID in the `Last-Event-ID` request header. -Expected request header: +Expected request header on reconnect: ``` Last-Event-ID: event-1 @@ -5447,6 +5451,7 @@ Expected response body (content type `text/event-stream`): ``` id: event-2 +event: message data: {"message": "world"} ``` @@ -5463,6 +5468,7 @@ Expected response body (content type `text/event-stream`): ``` retry: 1000 +event: message data: {"message": "hello"} ``` diff --git a/packages/http-specs/specs/streaming/sse/main.tsp b/packages/http-specs/specs/streaming/sse/main.tsp index 66f314f9e94..971542af46a 100644 --- a/packages/http-specs/specs/streaming/sse/main.tsp +++ b/packages/http-specs/specs/streaming/sse/main.tsp @@ -161,7 +161,7 @@ namespace Protocol { @events union ProtocolEvents { @Events.contentType("application/json") - Info, + message: Info, } @scenario @@ -172,6 +172,7 @@ namespace Protocol { Expected response body (content type `text/event-stream`): ``` id: event-1 + event: message data: {"message": "hello"} ``` @@ -187,6 +188,7 @@ namespace Protocol { Expected response body (content type `text/event-stream`): ``` id: invalidid + event: message data: {"message": "hello"} ``` @@ -203,6 +205,7 @@ namespace Protocol { Expected response body (content type `text/event-stream`): ``` retry: 1000 + event: message data: {"message": "hello"} ``` @@ -218,6 +221,7 @@ namespace Protocol { Expected response body (content type `text/event-stream`): ``` retry: not-a-number + event: message data: {"message": "hello"} ``` @@ -227,10 +231,11 @@ namespace Protocol { @scenario @scenarioDoc(""" - An SSE stream that resumes after a reconnect. The client sends the most - recently received event ID in the `Last-Event-ID` request header. - - Expected request header: + An SSE stream that resumes after a reconnect. The first response closes after + sending `event-1`. On reconnect, the client sends the most recently received + event ID in the `Last-Event-ID` request header. + + Expected request header on reconnect: ``` Last-Event-ID: event-1 ``` @@ -238,6 +243,7 @@ namespace Protocol { Expected response body (content type `text/event-stream`): ``` id: event-2 + event: message data: {"message": "world"} ``` diff --git a/packages/http-specs/specs/streaming/sse/mockapi.ts b/packages/http-specs/specs/streaming/sse/mockapi.ts index 3bab7b4598b..6875e170640 100644 --- a/packages/http-specs/specs/streaming/sse/mockapi.ts +++ b/packages/http-specs/specs/streaming/sse/mockapi.ts @@ -1,9 +1,13 @@ -import type { ScenarioMockApi } from "@typespec/spec-api"; +import type { MockRequest, ScenarioMockApi } from "@typespec/spec-api"; import { passOnSuccess } from "@typespec/spec-api"; export const Scenarios: Record = {}; -const unnamedStream = ['data: {"desc": "one"}', 'data: {"desc": "two"}', 'data: {"desc": "three"}'] +const unnamedStream = [ + 'data: {"desc": "one"}', + 'data: {"desc": "two"}', + 'data: {"desc": "three"}', +] .map((event) => `${event}\n\n`) .join(""); @@ -71,7 +75,8 @@ Scenarios.Streaming_Sse_Retrieve_stream = passOnSuccess({ kind: "MockApiDefinition", }); -const protocolEvent = (fields: string[]) => Buffer.from(`${fields.join("\n")}\n\n`); +const protocolEvent = (fields: string[]) => + Buffer.from(`${fields.join("\n")}\n\n`); Scenarios.Streaming_Sse_Protocol_id = passOnSuccess({ uri: "/streaming/sse/protocol/id", @@ -94,7 +99,10 @@ Scenarios.Streaming_Sse_Protocol_invalidId = passOnSuccess({ response: { status: 200, body: { - rawContent: protocolEvent(["id: invalid\u0000id", 'data: {"message": "hello"}']), + rawContent: protocolEvent([ + "id: invalid\u0000id", + 'data: {"message": "hello"}', + ]), contentType: "text/event-stream", }, }, @@ -122,7 +130,10 @@ Scenarios.Streaming_Sse_Protocol_invalidRetry = passOnSuccess({ response: { status: 200, body: { - rawContent: protocolEvent(["retry: not-a-number", 'data: {"message": "hello"}']), + rawContent: protocolEvent([ + "retry: not-a-number", + 'data: {"message": "hello"}', + ]), contentType: "text/event-stream", }, }, @@ -132,17 +143,49 @@ Scenarios.Streaming_Sse_Protocol_invalidRetry = passOnSuccess({ Scenarios.Streaming_Sse_Protocol_reconnect = passOnSuccess({ uri: "/streaming/sse/protocol/reconnect", method: "get", - request: { - headers: { - "last-event-id": "event-1", - }, - }, + request: {}, response: { status: 200, body: { - rawContent: protocolEvent(["id: event-2", 'data: {"message": "world"}']), + rawContent: protocolEvent([ + "id: event-1", + "event: message", + 'data: {"message": "hello"}', + ]), contentType: "text/event-stream", }, }, + handler: (() => { + let reconnected = false; + return (req: MockRequest) => { + if (reconnected) { + req.expect.containsHeader("last-event-id", "event-1"); + return { + status: 200, + body: { + rawContent: protocolEvent([ + "id: event-2", + "event: message", + 'data: {"message": "world"}', + ]), + contentType: "text/event-stream", + }, + }; + } + + reconnected = true; + return { + status: 200, + body: { + rawContent: protocolEvent([ + "id: event-1", + "event: message", + 'data: {"message": "hello"}', + ]), + contentType: "text/event-stream", + }, + }; + }; + })(), kind: "MockApiDefinition", }); From 74c2f2f190ea7f9c96bca8a3d93f91597adcac64 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Fri, 21 Aug 2026 14:25:35 -0400 Subject: [PATCH 05/11] fix(http-specs): tighten SSE reconnect scenario Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5f920be-9f18-40f9-af98-d8d3bae093df --- ...se-spector-scenarios-2026-7-10-14-23-58.md | 6 ++++- .../http-specs/specs/streaming/sse/mockapi.ts | 23 +++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/.chronus/changes/iscai-msft-add-sse-spector-scenarios-2026-7-10-14-23-58.md b/.chronus/changes/iscai-msft-add-sse-spector-scenarios-2026-7-10-14-23-58.md index 0cfa45d7805..47a907457c6 100644 --- a/.chronus/changes/iscai-msft-add-sse-spector-scenarios-2026-7-10-14-23-58.md +++ b/.chronus/changes/iscai-msft-add-sse-spector-scenarios-2026-7-10-14-23-58.md @@ -4,4 +4,8 @@ packages: - "@typespec/http-specs" --- -Add SSE protocol coverage for event IDs, retry fields, and reconnection \ No newline at end of file +Add SSE protocol coverage for event IDs, retry fields, and reconnection + +```tsp +op reconnect(): SSEStream; +``` diff --git a/packages/http-specs/specs/streaming/sse/mockapi.ts b/packages/http-specs/specs/streaming/sse/mockapi.ts index 6875e170640..249fa6108b9 100644 --- a/packages/http-specs/specs/streaming/sse/mockapi.ts +++ b/packages/http-specs/specs/streaming/sse/mockapi.ts @@ -1,5 +1,5 @@ import type { MockRequest, ScenarioMockApi } from "@typespec/spec-api"; -import { passOnSuccess } from "@typespec/spec-api"; +import { passOnSuccess, withServiceKeys } from "@typespec/spec-api"; export const Scenarios: Record = {}; @@ -85,7 +85,11 @@ Scenarios.Streaming_Sse_Protocol_id = passOnSuccess({ response: { status: 200, body: { - rawContent: protocolEvent(["id: event-1", 'data: {"message": "hello"}']), + rawContent: protocolEvent([ + "id: event-1", + "event: message", + 'data: {"message": "hello"}', + ]), contentType: "text/event-stream", }, }, @@ -101,6 +105,7 @@ Scenarios.Streaming_Sse_Protocol_invalidId = passOnSuccess({ body: { rawContent: protocolEvent([ "id: invalid\u0000id", + "event: message", 'data: {"message": "hello"}', ]), contentType: "text/event-stream", @@ -116,7 +121,11 @@ Scenarios.Streaming_Sse_Protocol_retry = passOnSuccess({ response: { status: 200, body: { - rawContent: protocolEvent(["retry: 1000", 'data: {"message": "hello"}']), + rawContent: protocolEvent([ + "retry: 1000", + "event: message", + 'data: {"message": "hello"}', + ]), contentType: "text/event-stream", }, }, @@ -132,6 +141,7 @@ Scenarios.Streaming_Sse_Protocol_invalidRetry = passOnSuccess({ body: { rawContent: protocolEvent([ "retry: not-a-number", + "event: message", 'data: {"message": "hello"}', ]), contentType: "text/event-stream", @@ -140,7 +150,10 @@ Scenarios.Streaming_Sse_Protocol_invalidRetry = passOnSuccess({ kind: "MockApiDefinition", }); -Scenarios.Streaming_Sse_Protocol_reconnect = passOnSuccess({ +Scenarios.Streaming_Sse_Protocol_reconnect = withServiceKeys([ + "initial", + "reconnect", +]).pass({ uri: "/streaming/sse/protocol/reconnect", method: "get", request: {}, @@ -161,6 +174,7 @@ Scenarios.Streaming_Sse_Protocol_reconnect = passOnSuccess({ if (reconnected) { req.expect.containsHeader("last-event-id", "event-1"); return { + pass: "reconnect", status: 200, body: { rawContent: protocolEvent([ @@ -175,6 +189,7 @@ Scenarios.Streaming_Sse_Protocol_reconnect = passOnSuccess({ reconnected = true; return { + pass: "initial", status: 200, body: { rawContent: protocolEvent([ From b2774d330788e496e50a0d21d1f8fc4d49035c16 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Fri, 21 Aug 2026 14:36:48 -0400 Subject: [PATCH 06/11] feat(sse): support data event envelopes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5f920be-9f18-40f9-af98-d8d3bae093df --- ...add-sse-data-envelope-support-2026-8-21.md | 17 +++++++++++ packages/sse/lib/main.tsp | 1 + packages/sse/lib/types.tsp | 18 +++++++++++ packages/sse/test/models.test.ts | 30 ++++++++++++++++++- 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 .chronus/changes/add-sse-data-envelope-support-2026-8-21.md diff --git a/.chronus/changes/add-sse-data-envelope-support-2026-8-21.md b/.chronus/changes/add-sse-data-envelope-support-2026-8-21.md new file mode 100644 index 00000000000..7a5cb7673f8 --- /dev/null +++ b/.chronus/changes/add-sse-data-envelope-support-2026-8-21.md @@ -0,0 +1,17 @@ +--- +changeKind: feature +packages: + - "@typespec/sse" +--- + +Support `@data` payloads in SSE event envelopes. + +```tsp +@events +union MixedEvents { + withEnvelope: { + metadata: Record, + @data contents: string, + }, +} +``` diff --git a/packages/sse/lib/main.tsp b/packages/sse/lib/main.tsp index 39e7b447c2a..7480b60c6a0 100644 --- a/packages/sse/lib/main.tsp +++ b/packages/sse/lib/main.tsp @@ -1,3 +1,4 @@ import "../dist/src/tsp-index.js"; +import "@typespec/events"; import "./decorators.tsp"; import "./types.tsp"; diff --git a/packages/sse/lib/types.tsp b/packages/sse/lib/types.tsp index 1a0d2f927d5..1460a8ad943 100644 --- a/packages/sse/lib/types.tsp +++ b/packages/sse/lib/types.tsp @@ -47,6 +47,24 @@ namespace TypeSpec.SSE; * * op subscribeToChannel(): SSEStream; * ``` + * + * @example Event envelopes with a separate `@data` payload + * + * ```typespec + * @TypeSpec.Events.events + * union MixedEvents { + * withEnvelope: { + * metadata: Record, + * @TypeSpec.Events.data contents: string, + * }, + * withoutEnvelope: { + * metadata: Record, + * contents: string, + * }, + * } + * + * op subscribeToMixedEvents(): SSEStream; + * ``` */ @doc("") model SSEStream is HttpStream; diff --git a/packages/sse/test/models.test.ts b/packages/sse/test/models.test.ts index 7022ce0286a..e4d833bf430 100644 --- a/packages/sse/test/models.test.ts +++ b/packages/sse/test/models.test.ts @@ -16,13 +16,41 @@ it("sets streamOf, contentType ('text/event-stream'), and body", async () => { model ${t.model("Foo")} is SSEStream; `); expect(getStreamOf(program, Foo)).toBe(TestEvents); - expect(getContentTypes(Foo.properties.get("contentType")!)[0]).toEqual(["text/event-stream"]); + expect(getContentTypes(Foo.properties.get("contentType")!)[0]).toEqual([ + "text/event-stream", + ]); expect(Foo.properties.get("body")!.type).toMatchObject({ kind: "Scalar", name: "string", }); }); +it("supports @data event envelopes in SSE streams", async () => { + const { MixedEvents, MixedStream, program } = await Tester.compile(t.code` + @events + union ${t.union("MixedEvents")} { + withEnvelope: { + metadata: Record, + @data contents: string, + }, + withoutEnvelope: { + metadata: Record, + contents: string, + }, + + model ${t.model("MixedStream")} is SSEStream; + `); + + expect(getStreamOf(program, MixedStream)).toBe(MixedEvents); + const withEnvelope = MixedEvents.variants.get("withEnvelope")!.type; + expect(withEnvelope.kind).toBe("Model"); + if (withEnvelope.kind !== "Model") return; + expect(withEnvelope.properties.get("contents")).toMatchObject({ + name: "contents", + type: { kind: "Scalar", name: "string" }, + }); +}); + it("should fail when union is not decorated with @events", async () => { const diagnostics = await Tester.diagnose(` model UserConnect { From 5b1ec1014a5e82893d6a5bee3e86f8ca3236a8de Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Fri, 21 Aug 2026 14:45:21 -0400 Subject: [PATCH 07/11] Revert "feat(sse): support data event envelopes" This reverts commit b2774d330788e496e50a0d21d1f8fc4d49035c16. --- ...add-sse-data-envelope-support-2026-8-21.md | 17 ----------- packages/sse/lib/main.tsp | 1 - packages/sse/lib/types.tsp | 18 ----------- packages/sse/test/models.test.ts | 30 +------------------ 4 files changed, 1 insertion(+), 65 deletions(-) delete mode 100644 .chronus/changes/add-sse-data-envelope-support-2026-8-21.md diff --git a/.chronus/changes/add-sse-data-envelope-support-2026-8-21.md b/.chronus/changes/add-sse-data-envelope-support-2026-8-21.md deleted file mode 100644 index 7a5cb7673f8..00000000000 --- a/.chronus/changes/add-sse-data-envelope-support-2026-8-21.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -changeKind: feature -packages: - - "@typespec/sse" ---- - -Support `@data` payloads in SSE event envelopes. - -```tsp -@events -union MixedEvents { - withEnvelope: { - metadata: Record, - @data contents: string, - }, -} -``` diff --git a/packages/sse/lib/main.tsp b/packages/sse/lib/main.tsp index 7480b60c6a0..39e7b447c2a 100644 --- a/packages/sse/lib/main.tsp +++ b/packages/sse/lib/main.tsp @@ -1,4 +1,3 @@ import "../dist/src/tsp-index.js"; -import "@typespec/events"; import "./decorators.tsp"; import "./types.tsp"; diff --git a/packages/sse/lib/types.tsp b/packages/sse/lib/types.tsp index 1460a8ad943..1a0d2f927d5 100644 --- a/packages/sse/lib/types.tsp +++ b/packages/sse/lib/types.tsp @@ -47,24 +47,6 @@ namespace TypeSpec.SSE; * * op subscribeToChannel(): SSEStream; * ``` - * - * @example Event envelopes with a separate `@data` payload - * - * ```typespec - * @TypeSpec.Events.events - * union MixedEvents { - * withEnvelope: { - * metadata: Record, - * @TypeSpec.Events.data contents: string, - * }, - * withoutEnvelope: { - * metadata: Record, - * contents: string, - * }, - * } - * - * op subscribeToMixedEvents(): SSEStream; - * ``` */ @doc("") model SSEStream is HttpStream; diff --git a/packages/sse/test/models.test.ts b/packages/sse/test/models.test.ts index e4d833bf430..7022ce0286a 100644 --- a/packages/sse/test/models.test.ts +++ b/packages/sse/test/models.test.ts @@ -16,41 +16,13 @@ it("sets streamOf, contentType ('text/event-stream'), and body", async () => { model ${t.model("Foo")} is SSEStream; `); expect(getStreamOf(program, Foo)).toBe(TestEvents); - expect(getContentTypes(Foo.properties.get("contentType")!)[0]).toEqual([ - "text/event-stream", - ]); + expect(getContentTypes(Foo.properties.get("contentType")!)[0]).toEqual(["text/event-stream"]); expect(Foo.properties.get("body")!.type).toMatchObject({ kind: "Scalar", name: "string", }); }); -it("supports @data event envelopes in SSE streams", async () => { - const { MixedEvents, MixedStream, program } = await Tester.compile(t.code` - @events - union ${t.union("MixedEvents")} { - withEnvelope: { - metadata: Record, - @data contents: string, - }, - withoutEnvelope: { - metadata: Record, - contents: string, - }, - - model ${t.model("MixedStream")} is SSEStream; - `); - - expect(getStreamOf(program, MixedStream)).toBe(MixedEvents); - const withEnvelope = MixedEvents.variants.get("withEnvelope")!.type; - expect(withEnvelope.kind).toBe("Model"); - if (withEnvelope.kind !== "Model") return; - expect(withEnvelope.properties.get("contents")).toMatchObject({ - name: "contents", - type: { kind: "Scalar", name: "string" }, - }); -}); - it("should fail when union is not decorated with @events", async () => { const diagnostics = await Tester.diagnose(` model UserConnect { From 72d31ca10a2a201c1bf946cb1e87af0df43cb1f0 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Fri, 21 Aug 2026 14:45:53 -0400 Subject: [PATCH 08/11] feat(http-specs): add SSE data envelope scenario Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5f920be-9f18-40f9-af98-d8d3bae093df --- packages/http-specs/spec-summary.md | 20 +++++++++++ .../http-specs/specs/streaming/sse/main.tsp | 33 +++++++++++++++++++ .../http-specs/specs/streaming/sse/mockapi.ts | 24 ++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/packages/http-specs/spec-summary.md b/packages/http-specs/spec-summary.md index e5649746f7d..a97743f6fdf 100644 --- a/packages/http-specs/spec-summary.md +++ b/packages/http-specs/spec-summary.md @@ -5401,6 +5401,26 @@ data: {"message": "hello"} ``` +### Streaming_Sse_Protocol_data + +- Endpoint: `get /streaming/sse/protocol/data` + +SSE events with and without an explicit `@data` payload. The `withEnvelope` +event keeps `metadata` in the event envelope and sends only `contents` in +the SSE `data` field. The `withoutEnvelope` event sends the complete model +in the `data` field. + +Expected response body (content type `text/event-stream`): + +``` +event: withEnvelope +data: hello + +event: withoutEnvelope +data: {"metadata": {"source": "test"}, "contents": "world"} + +``` + ### Streaming_Sse_Protocol_invalidId - Endpoint: `get /streaming/sse/protocol/invalid-id` diff --git a/packages/http-specs/specs/streaming/sse/main.tsp b/packages/http-specs/specs/streaming/sse/main.tsp index 971542af46a..6314cea6d5d 100644 --- a/packages/http-specs/specs/streaming/sse/main.tsp +++ b/packages/http-specs/specs/streaming/sse/main.tsp @@ -164,6 +164,39 @@ namespace Protocol { message: Info, } + @events + union MixedEvents { + withEnvelope: { + metadata: Record, + @data contents: string, + }, + + withoutEnvelope: { + metadata: Record, + contents: string, + }, + } + + @scenario + @scenarioDoc(""" + SSE events with and without an explicit `@data` payload. The `withEnvelope` + event keeps `metadata` in the event envelope and sends only `contents` in + the SSE `data` field. The `withoutEnvelope` event sends the complete model + in the `data` field. + + Expected response body (content type `text/event-stream`): + ``` + event: withEnvelope + data: hello + + event: withoutEnvelope + data: {"metadata": {"source": "test"}, "contents": "world"} + + ``` + """) + @route("data") + op data(): SSEStream; + @scenario @scenarioDoc(""" An SSE event with an `id` field. The event ID is envelope metadata and is diff --git a/packages/http-specs/specs/streaming/sse/mockapi.ts b/packages/http-specs/specs/streaming/sse/mockapi.ts index 249fa6108b9..df4f52a1d02 100644 --- a/packages/http-specs/specs/streaming/sse/mockapi.ts +++ b/packages/http-specs/specs/streaming/sse/mockapi.ts @@ -78,6 +78,30 @@ Scenarios.Streaming_Sse_Retrieve_stream = passOnSuccess({ const protocolEvent = (fields: string[]) => Buffer.from(`${fields.join("\n")}\n\n`); +Scenarios.Streaming_Sse_Protocol_data = passOnSuccess({ + uri: "/streaming/sse/protocol/data", + method: "get", + request: {}, + response: { + status: 200, + body: { + rawContent: Buffer.from( + [ + "event: withEnvelope", + "data: hello", + "", + "event: withoutEnvelope", + 'data: {"metadata": {"source": "test"}, "contents": "world"}', + "", + "", + ].join("\n"), + ), + contentType: "text/event-stream", + }, + }, + kind: "MockApiDefinition", +}); + Scenarios.Streaming_Sse_Protocol_id = passOnSuccess({ uri: "/streaming/sse/protocol/id", method: "get", From 8b3f7ee485159fc3e37dbcb93e9db590e36f6ac6 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Fri, 21 Aug 2026 15:18:33 -0400 Subject: [PATCH 09/11] fix(http-specs): align SSE data scenario Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5f920be-9f18-40f9-af98-d8d3bae093df --- packages/http-specs/spec-summary.md | 32 +++++++++---------- .../http-specs/specs/streaming/sse/main.tsp | 14 ++++---- .../http-specs/specs/streaming/sse/mockapi.ts | 32 ++++--------------- 3 files changed, 30 insertions(+), 48 deletions(-) diff --git a/packages/http-specs/spec-summary.md b/packages/http-specs/spec-summary.md index a97743f6fdf..4401667bc92 100644 --- a/packages/http-specs/spec-summary.md +++ b/packages/http-specs/spec-summary.md @@ -5385,22 +5385,6 @@ data: [DONE] ``` -### Streaming_Sse_Protocol_id - -- Endpoint: `get /streaming/sse/protocol/id` - -An SSE event with an `id` field. The event ID is envelope metadata and is -not part of the typed event data. - -Expected response body (content type `text/event-stream`): - -``` -id: event-1 -event: message -data: {"message": "hello"} - -``` - ### Streaming_Sse_Protocol_data - Endpoint: `get /streaming/sse/protocol/data` @@ -5421,6 +5405,22 @@ data: {"metadata": {"source": "test"}, "contents": "world"} ``` +### Streaming_Sse_Protocol_id + +- Endpoint: `get /streaming/sse/protocol/id` + +An SSE event with an `id` field. The event ID is envelope metadata and is +not part of the typed event data. + +Expected response body (content type `text/event-stream`): + +``` +id: event-1 +event: message +data: {"message": "hello"} + +``` + ### Streaming_Sse_Protocol_invalidId - Endpoint: `get /streaming/sse/protocol/invalid-id` diff --git a/packages/http-specs/specs/streaming/sse/main.tsp b/packages/http-specs/specs/streaming/sse/main.tsp index 6314cea6d5d..6ec88e7e330 100644 --- a/packages/http-specs/specs/streaming/sse/main.tsp +++ b/packages/http-specs/specs/streaming/sse/main.tsp @@ -30,15 +30,15 @@ namespace Unnamed { `message` events, each carrying a JSON `Info` payload, then closes the connection. Since the union variant is unnamed, no `event:` field is emitted and each event defaults to the `message` type. - + Expected response body (content type `text/event-stream`): ``` data: {"desc": "one"} - + data: {"desc": "two"} - + data: {"desc": "three"} - + ``` """) @route("receive") @@ -168,9 +168,11 @@ namespace Protocol { union MixedEvents { withEnvelope: { metadata: Record, - @data contents: string, - }, + @Events.contentType("text/plain") + @data + contents: string, + }, withoutEnvelope: { metadata: Record, contents: string, diff --git a/packages/http-specs/specs/streaming/sse/mockapi.ts b/packages/http-specs/specs/streaming/sse/mockapi.ts index df4f52a1d02..acc0924a50a 100644 --- a/packages/http-specs/specs/streaming/sse/mockapi.ts +++ b/packages/http-specs/specs/streaming/sse/mockapi.ts @@ -3,11 +3,7 @@ import { passOnSuccess, withServiceKeys } from "@typespec/spec-api"; export const Scenarios: Record = {}; -const unnamedStream = [ - 'data: {"desc": "one"}', - 'data: {"desc": "two"}', - 'data: {"desc": "three"}', -] +const unnamedStream = ['data: {"desc": "one"}', 'data: {"desc": "two"}', 'data: {"desc": "three"}'] .map((event) => `${event}\n\n`) .join(""); @@ -75,8 +71,7 @@ Scenarios.Streaming_Sse_Retrieve_stream = passOnSuccess({ kind: "MockApiDefinition", }); -const protocolEvent = (fields: string[]) => - Buffer.from(`${fields.join("\n")}\n\n`); +const protocolEvent = (fields: string[]) => Buffer.from(`${fields.join("\n")}\n\n`); Scenarios.Streaming_Sse_Protocol_data = passOnSuccess({ uri: "/streaming/sse/protocol/data", @@ -109,11 +104,7 @@ Scenarios.Streaming_Sse_Protocol_id = passOnSuccess({ response: { status: 200, body: { - rawContent: protocolEvent([ - "id: event-1", - "event: message", - 'data: {"message": "hello"}', - ]), + rawContent: protocolEvent(["id: event-1", "event: message", 'data: {"message": "hello"}']), contentType: "text/event-stream", }, }, @@ -145,11 +136,7 @@ Scenarios.Streaming_Sse_Protocol_retry = passOnSuccess({ response: { status: 200, body: { - rawContent: protocolEvent([ - "retry: 1000", - "event: message", - 'data: {"message": "hello"}', - ]), + rawContent: protocolEvent(["retry: 1000", "event: message", 'data: {"message": "hello"}']), contentType: "text/event-stream", }, }, @@ -174,21 +161,14 @@ Scenarios.Streaming_Sse_Protocol_invalidRetry = passOnSuccess({ kind: "MockApiDefinition", }); -Scenarios.Streaming_Sse_Protocol_reconnect = withServiceKeys([ - "initial", - "reconnect", -]).pass({ +Scenarios.Streaming_Sse_Protocol_reconnect = withServiceKeys(["initial", "reconnect"]).pass({ uri: "/streaming/sse/protocol/reconnect", method: "get", request: {}, response: { status: 200, body: { - rawContent: protocolEvent([ - "id: event-1", - "event: message", - 'data: {"message": "hello"}', - ]), + rawContent: protocolEvent(["id: event-1", "event: message", 'data: {"message": "hello"}']), contentType: "text/event-stream", }, }, From a5d3fdfc8a29c046d929c5237663e920f9a6ade7 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Fri, 21 Aug 2026 15:46:35 -0400 Subject: [PATCH 10/11] fix(http-specs): address latest SSE review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5f920be-9f18-40f9-af98-d8d3bae093df --- packages/http-specs/spec-summary.md | 15 +++++-- .../http-specs/specs/streaming/sse/main.tsp | 32 ++++++++------- .../http-specs/specs/streaming/sse/mockapi.ts | 40 ++++++++----------- 3 files changed, 45 insertions(+), 42 deletions(-) diff --git a/packages/http-specs/spec-summary.md b/packages/http-specs/spec-summary.md index 4401667bc92..32243308160 100644 --- a/packages/http-specs/spec-summary.md +++ b/packages/http-specs/spec-summary.md @@ -5390,9 +5390,8 @@ data: [DONE] - Endpoint: `get /streaming/sse/protocol/data` SSE events with and without an explicit `@data` payload. The `withEnvelope` -event keeps `metadata` in the event envelope and sends only `contents` in -the SSE `data` field. The `withoutEnvelope` event sends the complete model -in the `data` field. +event sends only the `contents` property in the SSE `data` field. The +`withoutEnvelope` event sends the complete model in the `data` field. Expected response body (content type `text/event-stream`): @@ -5461,13 +5460,21 @@ An SSE stream that resumes after a reconnect. The first response closes after sending `event-1`. On reconnect, the client sends the most recently received event ID in the `Last-Event-ID` request header. +Expected initial response body (content type `text/event-stream`): + +``` +id: event-1 +event: message +data: {"message": "hello"} +``` + Expected request header on reconnect: ``` Last-Event-ID: event-1 ``` -Expected response body (content type `text/event-stream`): +Expected reconnect response body (content type `text/event-stream`): ``` id: event-2 diff --git a/packages/http-specs/specs/streaming/sse/main.tsp b/packages/http-specs/specs/streaming/sse/main.tsp index 6ec88e7e330..419b26a9d58 100644 --- a/packages/http-specs/specs/streaming/sse/main.tsp +++ b/packages/http-specs/specs/streaming/sse/main.tsp @@ -30,15 +30,15 @@ namespace Unnamed { `message` events, each carrying a JSON `Info` payload, then closes the connection. Since the union variant is unnamed, no `event:` field is emitted and each event defaults to the `message` type. - + Expected response body (content type `text/event-stream`): ``` data: {"desc": "one"} - + data: {"desc": "two"} - + data: {"desc": "three"} - + ``` """) @route("receive") @@ -167,8 +167,6 @@ namespace Protocol { @events union MixedEvents { withEnvelope: { - metadata: Record, - @Events.contentType("text/plain") @data contents: string, @@ -182,18 +180,17 @@ namespace Protocol { @scenario @scenarioDoc(""" SSE events with and without an explicit `@data` payload. The `withEnvelope` - event keeps `metadata` in the event envelope and sends only `contents` in - the SSE `data` field. The `withoutEnvelope` event sends the complete model - in the `data` field. - + event sends only the `contents` property in the SSE `data` field. The + `withoutEnvelope` event sends the complete model in the `data` field. + Expected response body (content type `text/event-stream`): ``` event: withEnvelope data: hello - + event: withoutEnvelope data: {"metadata": {"source": "test"}, "contents": "world"} - + ``` """) @route("data") @@ -269,13 +266,20 @@ namespace Protocol { An SSE stream that resumes after a reconnect. The first response closes after sending `event-1`. On reconnect, the client sends the most recently received event ID in the `Last-Event-ID` request header. - + + Expected initial response body (content type `text/event-stream`): + ``` + id: event-1 + event: message + data: {"message": "hello"} + ``` + Expected request header on reconnect: ``` Last-Event-ID: event-1 ``` - Expected response body (content type `text/event-stream`): + Expected reconnect response body (content type `text/event-stream`): ``` id: event-2 event: message diff --git a/packages/http-specs/specs/streaming/sse/mockapi.ts b/packages/http-specs/specs/streaming/sse/mockapi.ts index acc0924a50a..bdf8d49c295 100644 --- a/packages/http-specs/specs/streaming/sse/mockapi.ts +++ b/packages/http-specs/specs/streaming/sse/mockapi.ts @@ -172,39 +172,31 @@ Scenarios.Streaming_Sse_Protocol_reconnect = withServiceKeys(["initial", "reconn contentType: "text/event-stream", }, }, - handler: (() => { - let reconnected = false; - return (req: MockRequest) => { - if (reconnected) { - req.expect.containsHeader("last-event-id", "event-1"); - return { - pass: "reconnect", - status: 200, - body: { - rawContent: protocolEvent([ - "id: event-2", - "event: message", - 'data: {"message": "world"}', - ]), - contentType: "text/event-stream", - }, - }; - } - - reconnected = true; + handler: (req: MockRequest) => { + if (req.headers["last-event-id"] !== undefined) { + req.expect.containsHeader("last-event-id", "event-1"); return { - pass: "initial", + pass: "reconnect", status: 200, body: { rawContent: protocolEvent([ - "id: event-1", + "id: event-2", "event: message", - 'data: {"message": "hello"}', + 'data: {"message": "world"}', ]), contentType: "text/event-stream", }, }; + } + + return { + pass: "initial", + status: 200, + body: { + rawContent: protocolEvent(["id: event-1", "event: message", 'data: {"message": "hello"}']), + contentType: "text/event-stream", + }, }; - })(), + }, kind: "MockApiDefinition", }); From 81d2ab4a1ecc5a5e33c926c60e7cfc4d4979d19e Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Fri, 21 Aug 2026 16:17:50 -0400 Subject: [PATCH 11/11] refactor(http-specs): split SSE data scenarios Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5f920be-9f18-40f9-af98-d8d3bae093df --- packages/http-specs/spec-summary.md | 21 ++++-- .../http-specs/specs/streaming/sse/main.tsp | 70 +++++++++++-------- .../http-specs/specs/streaming/sse/mockapi.ts | 33 +++++---- 3 files changed, 74 insertions(+), 50 deletions(-) diff --git a/packages/http-specs/spec-summary.md b/packages/http-specs/spec-summary.md index 32243308160..9c7f6485dc6 100644 --- a/packages/http-specs/spec-summary.md +++ b/packages/http-specs/spec-summary.md @@ -5385,23 +5385,30 @@ data: [DONE] ``` -### Streaming_Sse_Protocol_data +### Streaming_Sse_Protocol_Data_withEnvelope -- Endpoint: `get /streaming/sse/protocol/data` - -SSE events with and without an explicit `@data` payload. The `withEnvelope` -event sends only the `contents` property in the SSE `data` field. The -`withoutEnvelope` event sends the complete model in the `data` field. +- Endpoint: `get /streaming/sse/protocol/data/with-envelope` +SSE event with an explicit `@data` payload. The `withEnvelope` event sends +only the `contents` property in the SSE `data` field. Expected response body (content type `text/event-stream`): ``` event: withEnvelope data: hello +``` + +### Streaming_Sse_Protocol_Data_withoutEnvelope + +- Endpoint: `get /streaming/sse/protocol/data/without-envelope` +SSE event without an explicit `@data` payload. The `withoutEnvelope` event +sends the complete model in the SSE `data` field. +Expected response body (content type `text/event-stream`): + +``` event: withoutEnvelope data: {"metadata": {"source": "test"}, "contents": "world"} - ``` ### Streaming_Sse_Protocol_id diff --git a/packages/http-specs/specs/streaming/sse/main.tsp b/packages/http-specs/specs/streaming/sse/main.tsp index 419b26a9d58..2293746ff09 100644 --- a/packages/http-specs/specs/streaming/sse/main.tsp +++ b/packages/http-specs/specs/streaming/sse/main.tsp @@ -164,37 +164,47 @@ namespace Protocol { message: Info, } - @events - union MixedEvents { - withEnvelope: { - @Events.contentType("text/plain") - @data - contents: string, - }, - withoutEnvelope: { - metadata: Record, - contents: string, - }, - } - - @scenario - @scenarioDoc(""" - SSE events with and without an explicit `@data` payload. The `withEnvelope` - event sends only the `contents` property in the SSE `data` field. The - `withoutEnvelope` event sends the complete model in the `data` field. - - Expected response body (content type `text/event-stream`): - ``` - event: withEnvelope - data: hello - - event: withoutEnvelope - data: {"metadata": {"source": "test"}, "contents": "world"} - - ``` - """) @route("data") - op data(): SSEStream; + namespace Data { + @events + union DataEvents { + withEnvelope: { + @Events.contentType("text/plain") + @data + contents: string, + }, + withoutEnvelope: { + metadata: Record, + contents: string, + }, + } + + @scenario + @scenarioDoc(""" + SSE event with an explicit `@data` payload. The `withEnvelope` event sends + only the `contents` property in the SSE `data` field. + Expected response body (content type `text/event-stream`): + ``` + event: withEnvelope + data: hello + ``` + """) + @route("with-envelope") + op withEnvelope(): SSEStream; + + @scenario + @scenarioDoc(""" + SSE event without an explicit `@data` payload. The `withoutEnvelope` event + sends the complete model in the SSE `data` field. + Expected response body (content type `text/event-stream`): + ``` + event: withoutEnvelope + data: {"metadata": {"source": "test"}, "contents": "world"} + ``` + """) + @route("without-envelope") + op withoutEnvelope(): SSEStream; + } @scenario @scenarioDoc(""" diff --git a/packages/http-specs/specs/streaming/sse/mockapi.ts b/packages/http-specs/specs/streaming/sse/mockapi.ts index bdf8d49c295..2ecc6aa6878 100644 --- a/packages/http-specs/specs/streaming/sse/mockapi.ts +++ b/packages/http-specs/specs/streaming/sse/mockapi.ts @@ -73,24 +73,31 @@ Scenarios.Streaming_Sse_Retrieve_stream = passOnSuccess({ const protocolEvent = (fields: string[]) => Buffer.from(`${fields.join("\n")}\n\n`); -Scenarios.Streaming_Sse_Protocol_data = passOnSuccess({ - uri: "/streaming/sse/protocol/data", +Scenarios.Streaming_Sse_Protocol_Data_withEnvelope = passOnSuccess({ + uri: "/streaming/sse/protocol/data/with-envelope", method: "get", request: {}, response: { status: 200, body: { - rawContent: Buffer.from( - [ - "event: withEnvelope", - "data: hello", - "", - "event: withoutEnvelope", - 'data: {"metadata": {"source": "test"}, "contents": "world"}', - "", - "", - ].join("\n"), - ), + rawContent: protocolEvent(["event: withEnvelope", "data: hello"]), + contentType: "text/event-stream", + }, + }, + kind: "MockApiDefinition", +}); + +Scenarios.Streaming_Sse_Protocol_Data_withoutEnvelope = passOnSuccess({ + uri: "/streaming/sse/protocol/data/without-envelope", + method: "get", + request: {}, + response: { + status: 200, + body: { + rawContent: protocolEvent([ + "event: withoutEnvelope", + 'data: {"metadata": {"source": "test"}, "contents": "world"}', + ]), contentType: "text/event-stream", }, },