Skip to content

Commit 3e9e18f

Browse files
authored
Merge pull request #1 from malcolm-kee/claude/validate-sse-example-FIuJh
Improve examples
2 parents 461b1e8 + a8c8657 commit 3e9e18f

File tree

9 files changed

+550
-18
lines changed

9 files changed

+550
-18
lines changed

docs/openapi-ts/clients/angular.md

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,13 +210,79 @@ When your OpenAPI spec defines endpoints with `text/event-stream` responses, the
210210

211211
### Consuming a stream
212212

213-
```js
213+
Create a service to manage the SSE connection and expose state via signals.
214+
215+
```ts
216+
import { Injectable, signal } from '@angular/core';
214217
import { watchStockPrices } from './client/sdk.gen';
218+
import type { StockUpdate } from './client/types.gen';
219+
220+
@Injectable({ providedIn: 'root' })
221+
export class StockService {
222+
readonly updates = signal<StockUpdate[]>([]);
223+
readonly status = signal<'connected' | 'disconnected' | 'error'>('disconnected');
224+
225+
#controller: AbortController | null = null;
226+
227+
async connect() {
228+
this.#controller = new AbortController();
229+
this.status.set('connected');
230+
this.updates.set([]);
231+
232+
try {
233+
const { stream } = await watchStockPrices({
234+
signal: this.#controller.signal,
235+
});
236+
237+
for await (const event of stream) {
238+
this.updates.update((prev) => [...prev, event]);
239+
}
240+
} catch {
241+
if (!this.#controller?.signal.aborted) {
242+
this.status.set('error');
243+
return;
244+
}
245+
}
246+
this.status.set('disconnected');
247+
}
248+
249+
disconnect() {
250+
this.#controller?.abort();
251+
this.#controller = null;
252+
this.status.set('disconnected');
253+
}
254+
}
255+
```
215256

216-
const { stream } = await watchStockPrices();
257+
Then inject the service in your component and clean up on destroy.
217258

218-
for await (const event of stream) {
219-
console.log(event);
259+
```ts
260+
import { Component, inject, OnDestroy } from '@angular/core';
261+
import { StockService } from './stock.service';
262+
263+
@Component({
264+
selector: 'app-stock-ticker',
265+
template: `
266+
<button (click)="connect()">Connect</button>
267+
<button (click)="stockService.disconnect()">Disconnect</button>
268+
<p>Status: {{ stockService.status() }}</p>
269+
<ul>
270+
@for (update of stockService.updates(); track $index) {
271+
<li>{{ update | json }}</li>
272+
}
273+
</ul>
274+
`,
275+
})
276+
export class StockTickerComponent implements OnDestroy {
277+
stockService = inject(StockService);
278+
279+
connect() {
280+
this.stockService.connect();
281+
}
282+
283+
ngOnDestroy() {
284+
this.stockService.disconnect();
285+
}
220286
}
221287
```
222288

@@ -262,6 +328,41 @@ const { stream } = await watchStockPrices({
262328
});
263329
```
264330

331+
### RxJS alternative
332+
333+
If your codebase uses RxJS pipelines, you can wrap the async generator in an Observable.
334+
335+
```ts
336+
import { Observable } from 'rxjs';
337+
import { watchStockPrices } from './client/sdk.gen';
338+
import type { StockUpdate } from './client/types.gen';
339+
340+
function watchPrices$(): Observable<StockUpdate> {
341+
return new Observable((subscriber) => {
342+
const controller = new AbortController();
343+
344+
(async () => {
345+
try {
346+
const { stream } = await watchStockPrices({
347+
signal: controller.signal,
348+
});
349+
350+
for await (const event of stream) {
351+
subscriber.next(event);
352+
}
353+
subscriber.complete();
354+
} catch (error) {
355+
if (!controller.signal.aborted) {
356+
subscriber.error(error);
357+
}
358+
}
359+
})();
360+
361+
return () => controller.abort();
362+
});
363+
}
364+
```
365+
265366
::: warning
266367
Angular `HttpClient` interceptors do not apply to SSE connections. The SSE client uses the native Fetch API under the hood.
267368
:::

docs/openapi-ts/clients/angular/v19.md

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,13 +210,79 @@ When your OpenAPI spec defines endpoints with `text/event-stream` responses, the
210210

211211
### Consuming a stream
212212

213-
```js
213+
Create a service to manage the SSE connection and expose state via signals.
214+
215+
```ts
216+
import { Injectable, signal } from '@angular/core';
214217
import { watchStockPrices } from './client/sdk.gen';
218+
import type { StockUpdate } from './client/types.gen';
219+
220+
@Injectable({ providedIn: 'root' })
221+
export class StockService {
222+
readonly updates = signal<StockUpdate[]>([]);
223+
readonly status = signal<'connected' | 'disconnected' | 'error'>('disconnected');
224+
225+
#controller: AbortController | null = null;
226+
227+
async connect() {
228+
this.#controller = new AbortController();
229+
this.status.set('connected');
230+
this.updates.set([]);
231+
232+
try {
233+
const { stream } = await watchStockPrices({
234+
signal: this.#controller.signal,
235+
});
236+
237+
for await (const event of stream) {
238+
this.updates.update((prev) => [...prev, event]);
239+
}
240+
} catch {
241+
if (!this.#controller?.signal.aborted) {
242+
this.status.set('error');
243+
return;
244+
}
245+
}
246+
this.status.set('disconnected');
247+
}
248+
249+
disconnect() {
250+
this.#controller?.abort();
251+
this.#controller = null;
252+
this.status.set('disconnected');
253+
}
254+
}
255+
```
215256

216-
const { stream } = await watchStockPrices();
257+
Then inject the service in your component and clean up on destroy.
217258

218-
for await (const event of stream) {
219-
console.log(event);
259+
```ts
260+
import { Component, inject, OnDestroy } from '@angular/core';
261+
import { StockService } from './stock.service';
262+
263+
@Component({
264+
selector: 'app-stock-ticker',
265+
template: `
266+
<button (click)="connect()">Connect</button>
267+
<button (click)="stockService.disconnect()">Disconnect</button>
268+
<p>Status: {{ stockService.status() }}</p>
269+
<ul>
270+
@for (update of stockService.updates(); track $index) {
271+
<li>{{ update | json }}</li>
272+
}
273+
</ul>
274+
`,
275+
})
276+
export class StockTickerComponent implements OnDestroy {
277+
stockService = inject(StockService);
278+
279+
connect() {
280+
this.stockService.connect();
281+
}
282+
283+
ngOnDestroy() {
284+
this.stockService.disconnect();
285+
}
220286
}
221287
```
222288

@@ -262,6 +328,41 @@ const { stream } = await watchStockPrices({
262328
});
263329
```
264330

331+
### RxJS alternative
332+
333+
If your codebase uses RxJS pipelines, you can wrap the async generator in an Observable.
334+
335+
```ts
336+
import { Observable } from 'rxjs';
337+
import { watchStockPrices } from './client/sdk.gen';
338+
import type { StockUpdate } from './client/types.gen';
339+
340+
function watchPrices$(): Observable<StockUpdate> {
341+
return new Observable((subscriber) => {
342+
const controller = new AbortController();
343+
344+
(async () => {
345+
try {
346+
const { stream } = await watchStockPrices({
347+
signal: controller.signal,
348+
});
349+
350+
for await (const event of stream) {
351+
subscriber.next(event);
352+
}
353+
subscriber.complete();
354+
} catch (error) {
355+
if (!controller.signal.aborted) {
356+
subscriber.error(error);
357+
}
358+
}
359+
})();
360+
361+
return () => controller.abort();
362+
});
363+
}
364+
```
365+
265366
::: warning
266367
Angular `HttpClient` interceptors do not apply to SSE connections. The SSE client uses the native Fetch API under the hood.
267368
:::

docs/openapi-ts/clients/next-js.md

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,13 +251,54 @@ When your OpenAPI spec defines endpoints with `text/event-stream` responses, the
251251

252252
### Consuming a stream
253253

254-
```js
254+
SSE requires a client component since it uses browser APIs and React hooks.
255+
256+
```tsx
257+
'use client';
258+
259+
import { useRef, useState } from 'react';
255260
import { watchStockPrices } from './client/sdk.gen';
261+
import type { StockUpdate } from './client/types.gen';
262+
263+
export function StockTicker() {
264+
const [updates, setUpdates] = useState<StockUpdate[]>([]);
265+
const controllerRef = useRef<AbortController | null>(null);
266+
267+
const connect = async () => {
268+
const controller = new AbortController();
269+
controllerRef.current = controller;
270+
271+
try {
272+
const { stream } = await watchStockPrices({
273+
signal: controller.signal,
274+
});
275+
276+
for await (const event of stream) {
277+
setUpdates((prev) => [...prev, event]);
278+
}
279+
} catch {
280+
if (!controller.signal.aborted) {
281+
console.error('Stream failed');
282+
}
283+
}
284+
};
256285

257-
const { stream } = await watchStockPrices();
286+
const disconnect = () => {
287+
controllerRef.current?.abort();
288+
controllerRef.current = null;
289+
};
258290

259-
for await (const event of stream) {
260-
console.log(event);
291+
return (
292+
<>
293+
<button onClick={connect}>Connect</button>
294+
<button onClick={disconnect}>Disconnect</button>
295+
<ul>
296+
{updates.map((u, i) => (
297+
<li key={i}>{JSON.stringify(u)}</li>
298+
))}
299+
</ul>
300+
</>
301+
);
261302
}
262303
```
263304

@@ -303,6 +344,73 @@ const { stream } = await watchStockPrices({
303344
});
304345
```
305346

347+
### Custom hook factory
348+
349+
You can create a reusable factory that wraps any SSE SDK function into a React hook.
350+
351+
```tsx
352+
'use client';
353+
354+
import { useCallback, useRef, useState } from 'react';
355+
356+
export function createUseSse<
357+
TFn extends (...args: any[]) => Promise<{ stream: AsyncGenerator<any> }>,
358+
>(sseFn: TFn) {
359+
type TEvent = Awaited<ReturnType<TFn>> extends { stream: AsyncGenerator<infer E> } ? E : never;
360+
type TOptions = Parameters<TFn>[0];
361+
362+
return function useSse() {
363+
const [events, setEvents] = useState<TEvent[]>([]);
364+
const [status, setStatus] = useState<'connected' | 'disconnected' | 'error'>('disconnected');
365+
const controllerRef = useRef<AbortController | null>(null);
366+
367+
const connect = useCallback(async (options?: Omit<TOptions, 'signal'>) => {
368+
const controller = new AbortController();
369+
controllerRef.current = controller;
370+
setStatus('connected');
371+
setEvents([]);
372+
373+
try {
374+
const { stream } = await sseFn({
375+
...options,
376+
signal: controller.signal,
377+
} as TOptions);
378+
379+
for await (const event of stream) {
380+
setEvents((prev) => [...prev, event as TEvent]);
381+
}
382+
} catch {
383+
if (!controller.signal.aborted) {
384+
setStatus('error');
385+
return;
386+
}
387+
}
388+
setStatus('disconnected');
389+
}, []);
390+
391+
const disconnect = useCallback(() => {
392+
controllerRef.current?.abort();
393+
controllerRef.current = null;
394+
setStatus('disconnected');
395+
}, []);
396+
397+
return { connect, disconnect, events, status };
398+
};
399+
}
400+
```
401+
402+
```tsx
403+
import { watchStockPrices } from './client/sdk.gen';
404+
import { createUseSse } from './hooks/createUseSse';
405+
406+
const useStockPrices = createUseSse(watchStockPrices);
407+
408+
function StockTicker() {
409+
const { events, status, connect, disconnect } = useStockPrices();
410+
// ...
411+
}
412+
```
413+
306414
::: tip
307415
Request interceptors registered through `client.interceptors.request` apply to SSE connections, including on each reconnect attempt.
308416
:::

0 commit comments

Comments
 (0)