From 1216b4bfb7c6378a9c2bb06e4e2c73857162226c Mon Sep 17 00:00:00 2001 From: Anna Bocharova Date: Tue, 28 Jan 2025 22:33:12 +0100 Subject: [PATCH] feat: `Subscription` class for SSE (#2280) Addition to #2238 --- CHANGELOG.md | 15 ++ README.md | 15 +- eslint.config.js | 8 + example/endpoints/time-subscription.ts | 1 + example/example.client.ts | 68 +++-- example/example.documentation.yaml | 10 +- example/factories.ts | 1 + example/routing.ts | 2 +- src/integration-base.ts | 251 +++++++++++++++--- src/integration.ts | 8 +- src/typescript-api.ts | 44 ++- tests/system/example.spec.ts | 27 +- .../__snapshots__/documentation.spec.ts.snap | 32 +-- .../__snapshots__/integration.spec.ts.snap | 206 +++++++++++--- 14 files changed, 531 insertions(+), 157 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index deddafb23..49ada70a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/README.md b/README.md index fb4a509b6..868b7d657 100644 --- a/README.md +++ b/README.md @@ -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"; @@ -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. @@ -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 diff --git a/eslint.config.js b/eslint.config.js index 94b56df69..65c42aaf4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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( diff --git a/example/endpoints/time-subscription.ts b/example/endpoints/time-subscription.ts index af421e9f4..9d38a2e80 100644 --- a/example/endpoints/time-subscription.ts +++ b/example/endpoints/time-subscription.ts @@ -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({ diff --git a/example/example.client.ts b/example/example.client.ts index fb02010e3..6fd238805 100644 --- a/example/example.client.ts +++ b/example/example.client.ts @@ -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 = @@ -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"; @@ -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 { @@ -328,7 +328,7 @@ export interface PositiveResponse { "get /v1/avatar/stream": SomeOf; "post /v1/avatar/upload": SomeOf; "post /v1/avatar/raw": SomeOf; - "get /v1/events/time": SomeOf; + "get /v1/events/stream": SomeOf; } export interface NegativeResponse { @@ -341,7 +341,7 @@ export interface NegativeResponse { "get /v1/avatar/stream": SomeOf; "post /v1/avatar/upload": SomeOf; "post /v1/avatar/raw": SomeOf; - "get /v1/events/time": SomeOf; + "get /v1/events/stream": SomeOf; } export interface EncodedResponse { @@ -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 { @@ -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; @@ -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) => @@ -465,8 +465,32 @@ export class Client { } } +export class Subscription< + K extends Extract, + R extends Extract, +> { + 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( + event: E, + handler: (data: Extract["data"]) => void | Promise, + ) { + 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) => {}); */ diff --git a/example/example.documentation.yaml b/example/example.documentation.yaml index b920fafa5..33cef0427 100644 --- a/example/example.documentation.yaml +++ b/example/example.documentation.yaml @@ -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: @@ -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: diff --git a/example/factories.ts b/example/factories.ts index 1d0efafc7..55cc9bb68 100644 --- a/example/factories.ts +++ b/example/factories.ts @@ -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(), }); diff --git a/example/routing.ts b/example/routing.ts index ff2fac7a5..3938e0f18 100644 --- a/example/routing.ts +++ b/example/routing.ts @@ -37,7 +37,7 @@ export const routing: Routing = { raw: rawAcceptingEndpoint, }, events: { - time: subscriptionEndpoint, + stream: subscriptionEndpoint, }, }, // path /public serves static files from /example/assets diff --git a/src/integration-base.ts b/src/integration-base.ts index 49c0cc57f..f0208283e 100644 --- a/src/integration-base.ts +++ b/src/integration-base.ts @@ -2,6 +2,7 @@ import ts from "typescript"; import { ResponseVariant } from "./api-response"; import { contentTypes } from "./content-type"; import { Method, methods } from "./method"; +import type { makeEventSchema } from "./sse"; import { accessModifiers, ensureTypeNode, @@ -9,10 +10,12 @@ import { makeArrowFn, makeConst, makeDeconstruction, + makeExtract, makeInterface, makeInterfaceProp, makeKeyOf, makeNew, + makeOneLine, makeParam, makeParams, makePromise, @@ -27,9 +30,12 @@ import { makeType, propOf, recordStringAny, + makeAssignment, + makePublicProperty, } from "./typescript-api"; type IOKind = "input" | "response" | ResponseVariant | "encoded"; +type SSEShape = ReturnType["shape"]; export abstract class IntegrationBase { protected paths = new Set(); @@ -44,9 +50,14 @@ export abstract class IntegrationBase { paramsArgument: f.createIdentifier("params"), methodParameter: f.createIdentifier("method"), requestParameter: f.createIdentifier("request"), + eventParameter: f.createIdentifier("event"), + dataParameter: f.createIdentifier("data"), + handlerParameter: f.createIdentifier("handler"), + msgParameter: f.createIdentifier("msg"), parseRequestFn: f.createIdentifier("parseRequest"), substituteFn: f.createIdentifier("substitute"), provideMethod: f.createIdentifier("provide"), + onMethod: f.createIdentifier("on"), implementationArgument: f.createIdentifier("implementation"), hasBodyConst: f.createIdentifier("hasBody"), undefinedValue: f.createIdentifier("undefined"), @@ -57,6 +68,7 @@ export abstract class IntegrationBase { clientConst: f.createIdentifier("client"), contentTypeConst: f.createIdentifier("contentType"), isJsonConst: f.createIdentifier("isJSON"), + sourceProp: f.createIdentifier("source"), } satisfies Record; protected interfaces: Record = { @@ -189,36 +201,33 @@ export abstract class IntegrationBase { ), this.ids.paramsArgument, f.createBlock([ - f.createExpressionStatement( - f.createBinaryExpression( + makeAssignment( + this.ids.pathParameter, + makePropCall( this.ids.pathParameter, - f.createToken(ts.SyntaxKind.EqualsToken), - makePropCall( - this.ids.pathParameter, - propOf("replace"), - [ - makeTemplate(":", [this.ids.keyParameter]), // `:${key}` - makeArrowFn( - [], - f.createBlock([ - f.createExpressionStatement( - f.createDeleteExpression( - f.createElementAccessExpression( - this.ids.restConst, - this.ids.keyParameter, - ), - ), - ), - f.createReturnStatement( + propOf("replace"), + [ + makeTemplate(":", [this.ids.keyParameter]), // `:${key}` + makeArrowFn( + [], + f.createBlock([ + f.createExpressionStatement( + f.createDeleteExpression( f.createElementAccessExpression( - this.ids.paramsArgument, + this.ids.restConst, this.ids.keyParameter, ), ), - ]), - ), - ], - ), + ), + f.createReturnStatement( + f.createElementAccessExpression( + this.ids.paramsArgument, + this.ids.keyParameter, + ), + ), + ]), + ), + ], ), ), ]), @@ -293,6 +302,19 @@ export abstract class IntegrationBase { this.makeProvider(), ]); + // `?${new URLSearchParams(____)}` + protected makeSearchParams = (from: ts.Expression) => + makeTemplate("?", [ + makeNew(f.createIdentifier(URLSearchParams.name), from), + ]); + + protected makeFetchURL = () => + makeNew( + f.createIdentifier(URL.name), + makeTemplate("", [this.ids.pathParameter], [this.ids.searchParamsConst]), + f.createStringLiteral(this.serverUrl), + ); + // export const defaultImplementation: Implementation = async (method,path,params) => { ___ }; protected makeDefaultImplementation = () => { // method: method.toUpperCase() @@ -335,15 +357,7 @@ export abstract class IntegrationBase { this.ids.responseConst, f.createAwaitExpression( f.createCallExpression(f.createIdentifier(fetch.name), undefined, [ - makeNew( - f.createIdentifier(URL.name), - makeTemplate( - "", - [this.ids.pathParameter], - [this.ids.searchParamsConst], - ), - f.createStringLiteral(this.serverUrl), - ), + this.makeFetchURL(), f.createObjectLiteralExpression([ methodProperty, headersProperty, @@ -368,18 +382,13 @@ export abstract class IntegrationBase { ), ); - // const searchParams = hasBody ? "" : `?${new URLSearchParams(params)}`; + // const searchParams = hasBody ? "" : ___; const searchParamsStatement = makeConst( this.ids.searchParamsConst, makeTernary( this.ids.hasBodyConst, f.createStringLiteral(""), - makeTemplate("?", [ - makeNew( - f.createIdentifier(URLSearchParams.name), - this.ids.paramsArgument, - ), - ]), + this.makeSearchParams(this.ids.paramsArgument), ), ); @@ -449,8 +458,151 @@ export abstract class IntegrationBase { ); }; - protected makeUsageStatements = (className: string): ts.Node[] => [ - makeConst(this.ids.clientConst, makeNew(f.createIdentifier(className))), // const client = new Client(); + protected makeSubscriptionConstructor = () => + makePublicConstructor( + makeParams({ + request: ensureTypeNode("K"), + params: f.createIndexedAccessTypeNode( + ensureTypeNode(this.interfaces.input), + ensureTypeNode("K"), + ), + }), + [ + makeConst( + makeDeconstruction(this.ids.pathParameter, this.ids.restConst), + f.createCallExpression(this.ids.substituteFn, undefined, [ + f.createElementAccessExpression( + f.createCallExpression(this.ids.parseRequestFn, undefined, [ + this.ids.requestParameter, + ]), + f.createNumericLiteral(1), + ), + this.ids.paramsArgument, + ]), + ), + makeConst( + this.ids.searchParamsConst, + this.makeSearchParams(this.ids.restConst), + ), + makeAssignment( + f.createPropertyAccessExpression(f.createThis(), this.ids.sourceProp), + makeNew(f.createIdentifier("EventSource"), this.makeFetchURL()), + ), + ], + ); + + protected makeEventNarrow = (value: Parameters[0]) => + f.createTypeLiteralNode([ + makeInterfaceProp(propOf("event"), value), + ]); + + protected makeOnMethod = () => + makePublicMethod( + this.ids.onMethod, + makeParams({ + [this.ids.eventParameter.text]: ensureTypeNode("E"), + [this.ids.handlerParameter.text]: f.createFunctionTypeNode( + undefined, + makeParams({ + [this.ids.dataParameter.text]: f.createIndexedAccessTypeNode( + makeExtract("R", makeOneLine(this.makeEventNarrow("E"))), + f.createLiteralTypeNode( + f.createStringLiteral(propOf("data")), + ), + ), + }), + f.createUnionTypeNode([ + f.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword), + makePromise(f.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword)), + ]), + ), + }), + f.createBlock([ + f.createExpressionStatement( + makePropCall( + [f.createThis(), this.ids.sourceProp], + propOf("addEventListener"), + [ + this.ids.eventParameter, + makeArrowFn( + [this.ids.msgParameter], + f.createCallExpression(this.ids.handlerParameter, undefined, [ + makePropCall( + f.createIdentifier(JSON[Symbol.toStringTag]), + propOf("parse"), + [ + f.createPropertyAccessExpression( + f.createParenthesizedExpression( + f.createAsExpression( + this.ids.msgParameter, + ensureTypeNode(MessageEvent.name), + ), + ), + propOf("data"), + ), + ], + ), + ]), + ), + ], + ), + ), + f.createReturnStatement(f.createThis()), + ]), + { + typeParams: { + E: f.createIndexedAccessTypeNode( + ensureTypeNode("R"), + f.createLiteralTypeNode( + f.createStringLiteral(propOf("event")), + ), + ), + }, + }, + ); + + protected makeSubscriptionClass = (name: string) => + makePublicClass( + name, + [ + makePublicProperty(this.ids.sourceProp, ensureTypeNode("EventSource")), + this.makeSubscriptionConstructor(), + this.makeOnMethod(), + ], + { + typeParams: { + K: makeExtract( + this.requestType.name, + f.createTemplateLiteralType(f.createTemplateHead("get "), [ + f.createTemplateLiteralTypeSpan( + f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), + f.createTemplateTail(""), + ), + ]), + ), + R: makeExtract( + f.createIndexedAccessTypeNode( + ensureTypeNode(this.interfaces.positive), + ensureTypeNode("K"), + ), + makeOneLine( + this.makeEventNarrow( + f.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), + ), + ), + ), + }, + }, + ); + + protected makeUsageStatements = ( + clientClassName: string, + subscriptionClassName: string, + ): ts.Node[] => [ + makeConst( + this.ids.clientConst, + makeNew(f.createIdentifier(clientClassName)), + ), // const client = new Client(); // client.provide("get /v1/user/retrieve", { id: "10" }); makePropCall(this.ids.clientConst, this.ids.provideMethod, [ f.createStringLiteral(`${"get" satisfies Method} /v1/user/retrieve`), @@ -458,5 +610,18 @@ export abstract class IntegrationBase { f.createPropertyAssignment("id", f.createStringLiteral("10")), ]), ]), + // new Subscription("get /v1/events/stream", {}).on("time", (time) => {}); + makePropCall( + makeNew( + f.createIdentifier(subscriptionClassName), + f.createStringLiteral(`${"get" satisfies Method} /v1/events/stream`), + f.createObjectLiteralExpression(), + ), + this.ids.onMethod, + [ + f.createStringLiteral("time"), + makeArrowFn({ time: undefined }, f.createBlock([])), + ], + ), ]; } diff --git a/src/integration.ts b/src/integration.ts index 119e0d7f6..b3fa49fe3 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -31,6 +31,8 @@ interface IntegrationParams { variant?: "types" | "client"; /** @default Client */ clientClassName?: string; + /** @default Subscription */ + subscriptionClassName?: string; /** * @desc The API URL to use in the generated code * @default https://example.com @@ -100,6 +102,7 @@ export class Integration extends IntegrationBase { brandHandling, variant = "client", clientClassName = "Client", + subscriptionClassName = "Subscription", serverUrl = "https://example.com", optionalPropStyle = { withQuestionMark: true, withUndefined: true }, noContent = z.undefined(), @@ -184,9 +187,12 @@ export class Integration extends IntegrationBase { this.makeImplementationType(), this.makeDefaultImplementation(), this.makeClientClass(clientClassName), + this.makeSubscriptionClass(subscriptionClassName), ); - this.usage.push(...this.makeUsageStatements(clientClassName)); + this.usage.push( + ...this.makeUsageStatements(clientClassName, subscriptionClassName), + ); } protected printUsage(printerOptions?: ts.PrinterOptions) { diff --git a/src/typescript-api.ts b/src/typescript-api.ts index 662e16616..042df47e2 100644 --- a/src/typescript-api.ts +++ b/src/typescript-api.ts @@ -84,11 +84,14 @@ export const makeParams = (params: Partial>) => makeParam(f.createIdentifier(name), { type }), ); -export const makePublicConstructor = (params: ts.ParameterDeclaration[]) => +export const makePublicConstructor = ( + params: ts.ParameterDeclaration[], + statements: ts.Statement[] = [], +) => f.createConstructorDeclaration( accessModifiers.public, params, - f.createBlock([]), + f.createBlock(statements), ); export const ensureTypeNode = ( @@ -110,6 +113,9 @@ export const makeInterfaceProp = ( ensureTypeNode(value), ); +export const makeOneLine = (subject: ts.TypeNode) => + ts.setEmitFlags(subject, ts.EmitFlags.SingleLine); + export const makeDeconstruction = ( ...names: ts.Identifier[] ): ts.ArrayBindingPattern => @@ -168,6 +174,18 @@ export const makeType = ( return comment ? addJsDocComment(node, comment) : node; }; +export const makePublicProperty = ( + name: string | ts.PropertyName, + type: ts.TypeNode, +) => + f.createPropertyDeclaration( + accessModifiers.public, + name, + undefined, + type, + undefined, + ); + export const makePublicMethod = ( name: ts.Identifier, params: ts.ParameterDeclaration[], @@ -191,11 +209,15 @@ export const makePublicMethod = ( body, ); -export const makePublicClass = (name: string, statements: ts.ClassElement[]) => +export const makePublicClass = ( + name: string, + statements: ts.ClassElement[], + { typeParams }: { typeParams?: Parameters[0] } = {}, +) => f.createClassDeclaration( exportModifier, name, - undefined, + typeParams && makeTypeParams(typeParams), undefined, statements, ); @@ -288,6 +310,20 @@ export const makePropCall = ( export const makeNew = (cls: ts.Identifier, ...args: ts.Expression[]) => f.createNewExpression(cls, undefined, args); +export const makeExtract = ( + base: Parameters[0], + narrow: ts.TypeNode, +) => f.createTypeReferenceNode("Extract", [ensureTypeNode(base), narrow]); + +export const makeAssignment = (left: ts.Expression, right: ts.Expression) => + f.createExpressionStatement( + f.createBinaryExpression( + left, + f.createToken(ts.SyntaxKind.EqualsToken), + right, + ), + ); + const primitives: ts.KeywordTypeSyntaxKind[] = [ ts.SyntaxKind.AnyKeyword, ts.SyntaxKind.BigIntKeyword, diff --git a/tests/system/example.spec.ts b/tests/system/example.spec.ts index 3b96b41da..9f9cebe20 100644 --- a/tests/system/example.spec.ts +++ b/tests/system/example.spec.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { EventSource } from "undici"; import { spawn } from "node:child_process"; import { createReadStream, readFileSync } from "node:fs"; -import { Client } from "../../example/example.client"; +import { Client, Subscription } from "../../example/example.client"; import { givePort } from "../helpers"; import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; @@ -18,10 +18,16 @@ describe("Example", async () => { const port = givePort("example"); await vi.waitFor(() => assert(out.includes(`Listening`)), { timeout: 1e4 }); + beforeAll(() => { + // @todo revisit when Node 24 released (currently behind a flag, Node 22.3.0 and 23x) + vi.stubGlobal("EventSource", EventSource); + }); + afterAll(async () => { example.stdout.removeListener("data", listener); example.kill(); await vi.waitFor(() => assert(example.killed), { timeout: 1e4 }); + vi.unstubAllGlobals(); }); afterEach(() => { @@ -251,17 +257,14 @@ describe("Example", async () => { }); test("Should emit SSE (server sent events)", async () => { - const source = new EventSource(`http://localhost:${port}/v1/events/time`); - const stack: unknown[] = []; - const onTime = (evt: Event) => stack.push((evt as MessageEvent).data); - source.addEventListener("time", onTime); - await vi.waitFor(() => assert(stack.length > 2), { timeout: 5e3 }); - expect( - stack.every( - (entry) => typeof entry === "string" && /\d{10,}/.test(entry), - ), + const stack: number[] = []; + const onTime = (data: number) => void stack.push(data); + const subscription = new Subscription("get /v1/events/stream", {}).on( + "time", + onTime, ); - source.removeEventListener("time", onTime); + await vi.waitFor(() => assert(stack.length > 2), { timeout: 5e3 }); + subscription.source.close(); }); }); @@ -431,7 +434,7 @@ describe("Example", async () => { test("Should handle errors for SSE endpoints", async () => { const response = await fetch( - `http://localhost:${port}/v1/events/time?trigger=failure`, + `http://localhost:${port}/v1/events/stream?trigger=failure`, ); expect(response.status).toBe(500); expect(response.headers.get("content-type")).toBe( diff --git a/tests/unit/__snapshots__/documentation.spec.ts.snap b/tests/unit/__snapshots__/documentation.spec.ts.snap index 80d300b67..85c6869e6 100644 --- a/tests/unit/__snapshots__/documentation.spec.ts.snap +++ b/tests/unit/__snapshots__/documentation.spec.ts.snap @@ -1728,21 +1728,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: @@ -1767,7 +1767,7 @@ paths: - data - event "400": - description: GET /v1/events/time Negative response + description: GET /v1/events/stream Negative response content: text/plain: schema: @@ -2118,31 +2118,31 @@ 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: - $ref: "#/components/schemas/GetV1EventsTimeParameterTrigger" + $ref: "#/components/schemas/GetV1EventsStreamParameterTrigger" responses: "200": - description: GET /v1/events/time Positive response + description: GET /v1/events/stream Positive response content: text/event-stream: schema: - $ref: "#/components/schemas/GetV1EventsTimePositiveResponse" + $ref: "#/components/schemas/GetV1EventsStreamPositiveResponse" "400": - description: GET /v1/events/time Negative response + description: GET /v1/events/stream Negative response content: text/plain: schema: - $ref: "#/components/schemas/GetV1EventsTimeNegativeResponse" + $ref: "#/components/schemas/GetV1EventsStreamNegativeResponse" components: schemas: GetV1UserRetrieveParameterId: @@ -2486,9 +2486,9 @@ components: PostV1AvatarRawRequestBody: type: string format: binary - GetV1EventsTimeParameterTrigger: + GetV1EventsStreamParameterTrigger: type: string - GetV1EventsTimePositiveResponse: + GetV1EventsStreamPositiveResponse: type: object properties: data: @@ -2509,7 +2509,7 @@ components: required: - data - event - GetV1EventsTimeNegativeResponse: + GetV1EventsStreamNegativeResponse: type: string responses: {} parameters: {} diff --git a/tests/unit/__snapshots__/integration.spec.ts.snap b/tests/unit/__snapshots__/integration.spec.ts.snap index 88bd35bbf..91d377e57 100644 --- a/tests/unit/__snapshots__/integration.spec.ts.snap +++ b/tests/unit/__snapshots__/integration.spec.ts.snap @@ -115,10 +115,34 @@ export class Client { } } +export class Subscription< + K extends Extract, + R extends Extract, +> { + 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}\`, "https://example.com"), + ); + } + public on( + event: E, + handler: (data: Extract["data"]) => void | Promise, + ) { + 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) => {}); */ " `; @@ -238,10 +262,34 @@ export class Client { } } +export class Subscription< + K extends Extract, + R extends Extract, +> { + 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}\`, "https://example.com"), + ); + } + public on( + event: E, + handler: (data: Extract["data"]) => void | Promise, + ) { + 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) => {}); */ " `; @@ -361,10 +409,34 @@ export class Client { } } +export class Subscription< + K extends Extract, + R extends Extract, +> { + 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}\`, "https://example.com"), + ); + } + public on( + event: E, + handler: (data: Extract["data"]) => void | Promise, + ) { + 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) => {}); */ " `; @@ -703,30 +775,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 = @@ -739,7 +811,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"; @@ -753,7 +825,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 { @@ -766,7 +838,7 @@ export interface PositiveResponse { "get /v1/avatar/stream": SomeOf; "post /v1/avatar/upload": SomeOf; "post /v1/avatar/raw": SomeOf; - "get /v1/events/time": SomeOf; + "get /v1/events/stream": SomeOf; } export interface NegativeResponse { @@ -779,7 +851,7 @@ export interface NegativeResponse { "get /v1/avatar/stream": SomeOf; "post /v1/avatar/upload": SomeOf; "post /v1/avatar/raw": SomeOf; - "get /v1/events/time": SomeOf; + "get /v1/events/stream": SomeOf; } export interface EncodedResponse { @@ -801,8 +873,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 { @@ -833,9 +905,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; @@ -850,7 +922,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) => @@ -903,10 +975,34 @@ export class Client { } } +export class Subscription< + K extends Extract, + R extends Extract, +> { + 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}\`, "https://example.com"), + ); + } + public on( + event: E, + handler: (data: Extract["data"]) => void | Promise, + ) { + 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) => {}); */ " `; @@ -1179,30 +1275,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 = @@ -1215,7 +1311,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"; @@ -1229,7 +1325,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 { @@ -1242,7 +1338,7 @@ export interface PositiveResponse { "get /v1/avatar/stream": SomeOf; "post /v1/avatar/upload": SomeOf; "post /v1/avatar/raw": SomeOf; - "get /v1/events/time": SomeOf; + "get /v1/events/stream": SomeOf; } export interface NegativeResponse { @@ -1255,7 +1351,7 @@ export interface NegativeResponse { "get /v1/avatar/stream": SomeOf; "post /v1/avatar/upload": SomeOf; "post /v1/avatar/raw": SomeOf; - "get /v1/events/time": SomeOf; + "get /v1/events/stream": SomeOf; } export interface EncodedResponse { @@ -1277,8 +1373,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 { @@ -1309,9 +1405,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; @@ -1506,10 +1602,34 @@ export class Client { } } +export class Subscription< + K extends Extract, + R extends Extract, +> { + 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}\`, "https://example.com"), + ); + } + public on( + event: E, + handler: (data: Extract["data"]) => void | Promise, + ) { + 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) => {}); */ " `;