Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .chronus/changes/specs-addSse-2026-6-13-16-37-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/http-specs"
---

Add SSE tests
7 changes: 7 additions & 0 deletions .chronus/changes/specs-addSse-2026-7-17-13-4-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/spec-api"
---

Add `streamChunks` support to `MockBody` for chunked SSE streaming in mock responses
7 changes: 7 additions & 0 deletions .chronus/changes/specs-addSse-2026-7-17-13-4-1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/spector"
---

Support chunked streaming via `streamChunks` in mock response body
2 changes: 2 additions & 0 deletions packages/http-specs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@
},
"peerDependencies": {
"@typespec/compiler": "workspace:^",
"@typespec/events": "workspace:^",
"@typespec/http": "workspace:^",
"@typespec/rest": "workspace:^",
"@typespec/sse": "workspace:^",
"@typespec/versioning": "workspace:^",
"@typespec/xml": "workspace:^"
}
Expand Down
75 changes: 75 additions & 0 deletions packages/http-specs/spec-summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -5332,6 +5332,81 @@ Basic jsonl streaming for response.

Basic jsonl streaming for request.

### Streaming_Sse_Named_receive

- Endpoint: `get /streaming/sse/named/receive`

SSE streaming with multiple named events and a terminal event, modeled after
an OpenAI-style streaming response. Each named union variant sets the SSE
`event:` field; the terminal `[DONE]` event signals the client to disconnect.

Expected response body (content type `text/event-stream`):

```
event: responseCreated
data: {"id": "resp_1"}

event: responseDelta
data: {"delta": "Hello"}

event: responseDelta
data: {"delta": " world"}

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_Unnamed_receive

- Endpoint: `get /streaming/sse/unnamed/receive`

SSE streaming with unnamed events. The server streams a sequence of 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"}

```

### Type_Array_BooleanValue_get

- Endpoint: `get /type/array/boolean`
Expand Down
153 changes: 153 additions & 0 deletions packages/http-specs/specs/streaming/sse/main.tsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import "@typespec/http";
import "@typespec/sse";
import "@typespec/events";
import "@typespec/spector";

using Http;
using Events;
using SSE;
using Spector;

@doc("Test of server-sent events (SSE) streaming.")
@scenarioService("/streaming/sse")
namespace Streaming.Sse;

@route("unnamed")
namespace Unnamed {
model Info {
desc: string;
}

@events
union UnnamedEvents {
@Events.contentType("application/json")
Info,
}

@scenario
@scenarioDoc("""
SSE streaming with unnamed events. The server streams a sequence of 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")
op receive(): SSEStream<UnnamedEvents>;
}

@route("named")
Comment thread
iscai-msft marked this conversation as resolved.
namespace Named {
model ResponseCreated {
id: string;
}

model ResponseDelta {
delta: string;
}

@events
union ResponseEvents {
@Events.contentType("application/json")
responseCreated: ResponseCreated,

@Events.contentType("application/json")
responseDelta: ResponseDelta,

@Events.contentType("text/plain")
@terminalEvent
"[DONE]",
}

@scenario
@scenarioDoc("""
SSE streaming with multiple named events and a terminal event, modeled after
an OpenAI-style streaming response. Each named union variant sets the SSE
`event:` field; the terminal `[DONE]` event signals the client to disconnect.

Expected response body (content type `text/event-stream`):
```
event: responseCreated
data: {"id": "resp_1"}

event: responseDelta
data: {"delta": "Hello"}

event: responseDelta
data: {"delta": " world"}

data: [DONE]

```
""")
@route("receive")
op receive(): SSEStream<ResponseEvents>;
}

@route("retrieve")
namespace Retrieve {
model RetrievalRequest {
query: string;
}

model PartialResult {
text: string;
}

model FinalResult {
references: string[];
}

@events
union RetrievalEvents {
@Events.contentType("application/json")
partialResult: PartialResult,

@Events.contentType("application/json")
finalResult: FinalResult,

@Events.contentType("text/plain")
@terminalEvent
"[DONE]",
}

@scenario
@scenarioDoc("""
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]

```
""")
@post
@route("stream")
op stream(@body request: RetrievalRequest): SSEStream<RetrievalEvents>;
}
71 changes: 71 additions & 0 deletions packages/http-specs/specs/streaming/sse/mockapi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { passOnSuccess, ScenarioMockApi } from "@typespec/spec-api";

export const Scenarios: Record<string, ScenarioMockApi> = {};

const unnamedStream = ['data: {"desc": "one"}', 'data: {"desc": "two"}', 'data: {"desc": "three"}']
.map((event) => `${event}\n\n`)
.join("");

Scenarios.Streaming_Sse_Unnamed_receive = passOnSuccess({
uri: "/streaming/sse/unnamed/receive",
method: "get",
request: {},
response: {
status: 200,
body: {
rawContent: Buffer.from(unnamedStream),
contentType: "text/event-stream",
},
},
kind: "MockApiDefinition",
});

const namedChunks = [
'event: responseCreated\ndata: {"id": "resp_1"}\n\n',
'event: responseDelta\ndata: {"delta": "Hello"}\n\n',
'event: responseDelta\ndata: {"delta": " world"}\n\n',
"data: [DONE]\n\n",
].map((event) => Buffer.from(event));

Scenarios.Streaming_Sse_Named_receive = passOnSuccess({
uri: "/streaming/sse/named/receive",
method: "get",
request: {},
response: {
status: 200,
body: {
streamChunks: namedChunks,
contentType: "text/event-stream",
rawContent: Buffer.from(namedChunks.map((c) => c.toString()).join("")),
},
},
kind: "MockApiDefinition",
});

const retrieveStream = [
'event: partialResult\ndata: {"text": "partial one"}',
'event: partialResult\ndata: {"text": "partial two"}',
'event: finalResult\ndata: {"references": ["doc1", "doc2"]}',
"data: [DONE]",
]
.map((event) => `${event}\n\n`)
.join("");

Scenarios.Streaming_Sse_Retrieve_stream = passOnSuccess({
uri: "/streaming/sse/retrieve/stream",
method: "post",
request: {
body: {
rawContent: JSON.stringify({ query: "what is typespec?" }),
contentType: "application/json",
},
},
response: {
status: 200,
body: {
rawContent: Buffer.from(retrieveStream),
Comment thread
iscai-msft marked this conversation as resolved.
contentType: "text/event-stream",
},
},
kind: "MockApiDefinition",
});
2 changes: 2 additions & 0 deletions packages/spec-api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ export interface KeyedMockResponse<K extends string = string> extends MockRespon
export interface MockBody {
contentType: string;
rawContent: string | Buffer | Resolver | undefined;
/** When set, the response body is streamed as separate chunks instead of sent as a single buffer. */
streamChunks?: Buffer[];
}

export interface ResolverConfig {
Expand Down
20 changes: 14 additions & 6 deletions packages/spector/src/app/request-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,20 @@ const processResponse = (
}

if (mockResponse.body) {
const raw =
typeof mockResponse.body.rawContent === "string" ||
Buffer.isBuffer(mockResponse.body.rawContent)
? mockResponse.body.rawContent
: mockResponse.body.rawContent?.serialize(resolverConfig);
response.contentType(mockResponse.body.contentType).send(raw);
response.contentType(mockResponse.body.contentType);

if (mockResponse.body.streamChunks) {
for (const chunk of mockResponse.body.streamChunks) {
response.write(chunk);
}
} else {
const raw =
typeof mockResponse.body.rawContent === "string" ||
Buffer.isBuffer(mockResponse.body.rawContent)
? mockResponse.body.rawContent
: mockResponse.body.rawContent?.serialize(resolverConfig);
response.send(raw);
}
}

response.end();
Expand Down
Loading
Loading