Skip to content

Commit

Permalink
feat: Subscription class for SSE (#2280)
Browse files Browse the repository at this point in the history
Addition to #2238
  • Loading branch information
RobinTail authored Jan 28, 2025
1 parent c97513f commit 1216b4b
Show file tree
Hide file tree
Showing 14 changed files with 531 additions and 157 deletions.
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

## Version 22

### v22.3.0

- Feat: `Subscription` class consuming for Server-sent events:
- The `Integration` can now also generate a frontend helper class `Subscription` to ease SSE support;
- The new class establishes an `EventSource` instance and exposes it as the public `source` property;
- The class also provides the public `on` method for your typed listeners;
- You can configure the generated class name using `subscriptionClassName` option (default: `Subscription`);
- The feature is only applicable to the `variant` option set to `client` (default).

```ts
import { Subscription } from "./client.ts"; // the generated file

new Subscription("get /v1/events/stream", {}).on("time", (time) => {});
```

### v22.2.0

- Feat: detecting headers from `Middleware::security` declarations:
Expand Down
15 changes: 5 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1202,8 +1202,9 @@ createConfig({

If you want the user of a client application to be able to subscribe to subsequent updates initiated by the server,
consider [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) (SSE) feature.
Client application can subscribe to the event stream using `EventSource` class instance. The following example
demonstrates the implementation emitting the `time` event each second.
Client application can subscribe to the event stream using `EventSource` class instance or the
[instance of the generated](#generating-a-frontend-client) `Subscription` class. The following example demonstrates
the implementation emitting the `time` event each second.

```typescript
import { z } from "zod";
Expand All @@ -1223,13 +1224,6 @@ const subscriptionEndpoint = EventStreamFactory({
});
```

```js
const source = new EventSource("https://example.com/api/v1/time");
source.addEventListener("time", (event) => {
const data = JSON.parse(event.data); // number
});
```

If you need more capabilities, such as bidirectional event sending, I have developed an additional websocket operating
framework, [Zod Sockets](https://github.com/RobinTail/zod-sockets), which has similar principles and capabilities.

Expand Down Expand Up @@ -1267,11 +1261,12 @@ makes requests using the libraries and methods of your choice. The default imple
asserts the type of request parameters and response. Consuming the generated client requires Typescript version 4.1+.

```typescript
import { Client, Implementation } from "./client.ts"; // the generated file
import { Client, Implementation, Subscription } from "./client.ts"; // the generated file

const client = new Client(/* optional custom Implementation */);
client.provide("get /v1/user/retrieve", { id: "10" });
client.provide("post /v1/user/:id", { id: "10" }); // it also substitues path params
new Subscription("get /v1/events/stream", {}).on("time", (time) => {}); // Server-sent events (SSE)
```

## Creating a documentation
Expand Down
8 changes: 8 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ const tsFactoryConcerns = [
"CallExpression[callee.property.name='createTypeReferenceNode'][arguments.length=1]",
message: "use ensureTypeNode() helper",
},
{
selector: "Literal[value='Extract']",
message: "use makeExtract() helper",
},
{
selector: "Identifier[name='EqualsToken']",
message: "use makeAssignment() helper",
},
];

export default tsPlugin.config(
Expand Down
1 change: 1 addition & 0 deletions example/endpoints/time-subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from "zod";
import { setTimeout } from "node:timers/promises";
import { eventsFactory } from "../factories";

/** @desc The endpoint demonstrates emitting server-sent events (SSE) */
export const subscriptionEndpoint = eventsFactory.buildVoid({
tag: "subscriptions",
input: z.object({
Expand Down
68 changes: 46 additions & 22 deletions example/example.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,30 +265,30 @@ interface PostV1AvatarRawNegativeResponseVariants {
400: PostV1AvatarRawNegativeVariant1;
}

/** get /v1/events/time */
type GetV1EventsTimeInput = {
/** get /v1/events/stream */
type GetV1EventsStreamInput = {
trigger?: string | undefined;
};

/** get /v1/events/time */
type GetV1EventsTimePositiveVariant1 = {
/** get /v1/events/stream */
type GetV1EventsStreamPositiveVariant1 = {
data: number;
event: "time";
id?: string | undefined;
retry?: number | undefined;
};

/** get /v1/events/time */
interface GetV1EventsTimePositiveResponseVariants {
200: GetV1EventsTimePositiveVariant1;
/** get /v1/events/stream */
interface GetV1EventsStreamPositiveResponseVariants {
200: GetV1EventsStreamPositiveVariant1;
}

/** get /v1/events/time */
type GetV1EventsTimeNegativeVariant1 = string;
/** get /v1/events/stream */
type GetV1EventsStreamNegativeVariant1 = string;

/** get /v1/events/time */
interface GetV1EventsTimeNegativeResponseVariants {
400: GetV1EventsTimeNegativeVariant1;
/** get /v1/events/stream */
interface GetV1EventsStreamNegativeResponseVariants {
400: GetV1EventsStreamNegativeVariant1;
}

export type Path =
Expand All @@ -301,7 +301,7 @@ export type Path =
| "/v1/avatar/stream"
| "/v1/avatar/upload"
| "/v1/avatar/raw"
| "/v1/events/time";
| "/v1/events/stream";

export type Method = "get" | "post" | "put" | "delete" | "patch";

Expand All @@ -315,7 +315,7 @@ export interface Input {
"get /v1/avatar/stream": GetV1AvatarStreamInput;
"post /v1/avatar/upload": PostV1AvatarUploadInput;
"post /v1/avatar/raw": PostV1AvatarRawInput;
"get /v1/events/time": GetV1EventsTimeInput;
"get /v1/events/stream": GetV1EventsStreamInput;
}

export interface PositiveResponse {
Expand All @@ -328,7 +328,7 @@ export interface PositiveResponse {
"get /v1/avatar/stream": SomeOf<GetV1AvatarStreamPositiveResponseVariants>;
"post /v1/avatar/upload": SomeOf<PostV1AvatarUploadPositiveResponseVariants>;
"post /v1/avatar/raw": SomeOf<PostV1AvatarRawPositiveResponseVariants>;
"get /v1/events/time": SomeOf<GetV1EventsTimePositiveResponseVariants>;
"get /v1/events/stream": SomeOf<GetV1EventsStreamPositiveResponseVariants>;
}

export interface NegativeResponse {
Expand All @@ -341,7 +341,7 @@ export interface NegativeResponse {
"get /v1/avatar/stream": SomeOf<GetV1AvatarStreamNegativeResponseVariants>;
"post /v1/avatar/upload": SomeOf<PostV1AvatarUploadNegativeResponseVariants>;
"post /v1/avatar/raw": SomeOf<PostV1AvatarRawNegativeResponseVariants>;
"get /v1/events/time": SomeOf<GetV1EventsTimeNegativeResponseVariants>;
"get /v1/events/stream": SomeOf<GetV1EventsStreamNegativeResponseVariants>;
}

export interface EncodedResponse {
Expand All @@ -363,8 +363,8 @@ export interface EncodedResponse {
PostV1AvatarUploadNegativeResponseVariants;
"post /v1/avatar/raw": PostV1AvatarRawPositiveResponseVariants &
PostV1AvatarRawNegativeResponseVariants;
"get /v1/events/time": GetV1EventsTimePositiveResponseVariants &
GetV1EventsTimeNegativeResponseVariants;
"get /v1/events/stream": GetV1EventsStreamPositiveResponseVariants &
GetV1EventsStreamNegativeResponseVariants;
}

export interface Response {
Expand Down Expand Up @@ -395,9 +395,9 @@ export interface Response {
"post /v1/avatar/raw":
| PositiveResponse["post /v1/avatar/raw"]
| NegativeResponse["post /v1/avatar/raw"];
"get /v1/events/time":
| PositiveResponse["get /v1/events/time"]
| NegativeResponse["get /v1/events/time"];
"get /v1/events/stream":
| PositiveResponse["get /v1/events/stream"]
| NegativeResponse["get /v1/events/stream"];
}

export type Request = keyof Input;
Expand All @@ -412,7 +412,7 @@ export const endpointTags = {
"get /v1/avatar/stream": ["users", "files"],
"post /v1/avatar/upload": ["files"],
"post /v1/avatar/raw": ["files"],
"get /v1/events/time": ["subscriptions"],
"get /v1/events/stream": ["subscriptions"],
};

const parseRequest = (request: string) =>
Expand Down Expand Up @@ -465,8 +465,32 @@ export class Client {
}
}

export class Subscription<
K extends Extract<Request, `get ${string}`>,
R extends Extract<PositiveResponse[K], { event: string }>,
> {
public source: EventSource;
public constructor(request: K, params: Input[K]) {
const [path, rest] = substitute(parseRequest(request)[1], params);
const searchParams = `?${new URLSearchParams(rest)}`;
this.source = new EventSource(
new URL(`${path}${searchParams}`, "http://localhost:8090"),
);
}
public on<E extends R["event"]>(
event: E,
handler: (data: Extract<R, { event: E }>["data"]) => void | Promise<void>,
) {
this.source.addEventListener(event, (msg) =>
handler(JSON.parse((msg as MessageEvent).data)),
);
return this;
}
}

// Usage example:
/*
const client = new Client();
client.provide("get /v1/user/retrieve", { id: "10" });
new Subscription("get /v1/events/stream", {}).on("time", (time) => {});
*/
10 changes: 5 additions & 5 deletions example/example.documentation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -576,21 +576,21 @@ paths:
status: error
error:
message: Sample error message
/v1/events/time:
/v1/events/stream:
get:
operationId: GetV1EventsTime
operationId: GetV1EventsStream
tags:
- subscriptions
parameters:
- name: trigger
in: query
required: false
description: GET /v1/events/time Parameter
description: GET /v1/events/stream Parameter
schema:
type: string
responses:
"200":
description: GET /v1/events/time Positive response
description: GET /v1/events/stream Positive response
content:
text/event-stream:
schema:
Expand All @@ -615,7 +615,7 @@ paths:
- data
- event
"400":
description: GET /v1/events/time Negative response
description: GET /v1/events/stream Negative response
content:
text/plain:
schema:
Expand Down
1 change: 1 addition & 0 deletions example/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export const noContentFactory = new EndpointsFactory(
}),
);

/** @desc This factory is for producing event streams of server-sent events (SSE) */
export const eventsFactory = new EventStreamFactory({
time: z.number().int().positive(),
});
2 changes: 1 addition & 1 deletion example/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const routing: Routing = {
raw: rawAcceptingEndpoint,
},
events: {
time: subscriptionEndpoint,
stream: subscriptionEndpoint,
},
},
// path /public serves static files from /example/assets
Expand Down
Loading

0 comments on commit 1216b4b

Please sign in to comment.