From ecfa796814d3511d9f1b2ef90d0663973bb0c993 Mon Sep 17 00:00:00 2001 From: "Frank Chiarulli Jr." Date: Mon, 9 Mar 2026 19:07:12 -0400 Subject: [PATCH 01/20] first party SSE --- apps/landing/src/components/Features.tsx | 6 +- .../docs/docs/core-concepts/handlers.mdx | 10 + .../docs/docs/core-concepts/raw-handlers.mdx | 5 +- .../docs/core-concepts/server-sent-events.mdx | 430 +++++++++++ .../src/content/docs/docs/frontend/nextjs.mdx | 19 + .../src/content/docs/docs/frontend/vite.mdx | 30 + .../docs/getting-started/introduction.mdx | 1 + doc.go | 59 ++ example_test.go | 68 ++ handler.go | 26 + handlerFuncs.go | 59 ++ handlerOptions.go | 25 +- packages/create-shiftapi/package.json | 6 +- .../create-shiftapi/templates/next/app/api.ts | 4 +- .../templates/react/packages/api/src/index.ts | 4 +- .../svelte/packages/api/src/index.ts | 4 +- packages/create-shiftapi/tsdown.config.ts | 5 + packages/next/package.json | 18 +- packages/shiftapi/package.json | 20 +- .../shiftapi/src/__tests__/generate.test.ts | 2 + packages/shiftapi/src/internal.ts | 2 + packages/shiftapi/src/subscribe.ts | 109 +++ packages/shiftapi/src/templates.ts | 36 +- packages/shiftapi/tsdown.config.ts | 9 + packages/vite-plugin/package.json | 18 +- packages/vite-plugin/src/index.ts | 3 + pnpm-lock.yaml | 675 ++++++++++++------ schema.go | 56 +- sse.go | 108 +++ sse_test.go | 442 ++++++++++++ 30 files changed, 1985 insertions(+), 274 deletions(-) create mode 100644 apps/landing/src/content/docs/docs/core-concepts/server-sent-events.mdx create mode 100644 packages/create-shiftapi/tsdown.config.ts create mode 100644 packages/shiftapi/src/subscribe.ts create mode 100644 packages/shiftapi/tsdown.config.ts create mode 100644 sse.go create mode 100644 sse_test.go diff --git a/apps/landing/src/components/Features.tsx b/apps/landing/src/components/Features.tsx index b7d1853..2f6749e 100644 --- a/apps/landing/src/components/Features.tsx +++ b/apps/landing/src/components/Features.tsx @@ -25,9 +25,9 @@ const features = [ desc: <>Scalar API reference at /docs and an OpenAPI spec at /openapi.json — generated, never stale., }, { - icon: , - title: "Just net/http", - desc: <>Implements http.Handler. Works with any middleware, any router, any test framework. Zero lock-in., + icon: , + title: "Server-Sent Events", + desc: <>HandleSSE gives you a typed writer with auto headers. The TypeScript client gets a typed subscribe helper plus React and Svelte hooks., }, ]; diff --git a/apps/landing/src/content/docs/docs/core-concepts/handlers.mdx b/apps/landing/src/content/docs/docs/core-concepts/handlers.mdx index f4e905f..bdf7355 100644 --- a/apps/landing/src/content/docs/docs/core-concepts/handlers.mdx +++ b/apps/landing/src/content/docs/docs/core-concepts/handlers.mdx @@ -218,4 +218,14 @@ shiftapi.Handle(api, "POST /users", createUser, `WithError`, `WithMiddleware`, and `WithResponseHeader` are `Option` values — they work at all levels (API, group, and route). `WithStatus` and `WithRouteInfo` are route-only options. See [Options](/docs/core-concepts/options) for the full option system and composition. +## Other handler types + +ShiftAPI provides specialized handler functions for different use cases: + +| Function | Use case | +|----------|----------| +| `Handle` | JSON API endpoints (this page) | +| [`HandleSSE`](/docs/core-concepts/server-sent-events) | Server-Sent Events with a typed writer and auto-generated TypeScript `subscribe` helper | +| [`HandleRaw`](/docs/core-concepts/raw-handlers) | File downloads, WebSocket upgrades, and other responses that need direct `ResponseWriter` access | + See also: [Middleware](/docs/core-concepts/middleware), [Error Handling](/docs/core-concepts/error-handling), [Options](/docs/core-concepts/options). diff --git a/apps/landing/src/content/docs/docs/core-concepts/raw-handlers.mdx b/apps/landing/src/content/docs/docs/core-concepts/raw-handlers.mdx index dd5523a..324069b 100644 --- a/apps/landing/src/content/docs/docs/core-concepts/raw-handlers.mdx +++ b/apps/landing/src/content/docs/docs/core-concepts/raw-handlers.mdx @@ -237,12 +237,13 @@ shiftapi.HandleRaw(api, "GET /events", sseHandler, | `WithMiddleware(mw...)` | Apply HTTP middleware | | `WithResponseHeader(name, value)` | Set a static response header | -## When to use HandleRaw vs Handle +## When to use HandleRaw vs Handle vs HandleSSE | Use case | Recommendation | |----------|---------------| | JSON API endpoint | `Handle` — let the framework encode the response | -| Server-Sent Events (SSE) | `HandleRaw` + `WithContentType("text/event-stream")` | +| Server-Sent Events (SSE) | [`HandleSSE`](/docs/core-concepts/server-sent-events) — typed writer, auto headers, typed TS client | +| SSE with custom framing | `HandleRaw` + `WithContentType("text/event-stream")` | | File download | `HandleRaw` + `WithContentType("application/octet-stream")` | | WebSocket upgrade | `HandleRaw` (no `WithContentType` needed) | | Streaming response | `HandleRaw` with `Flusher` access | diff --git a/apps/landing/src/content/docs/docs/core-concepts/server-sent-events.mdx b/apps/landing/src/content/docs/docs/core-concepts/server-sent-events.mdx new file mode 100644 index 0000000..8a26e9d --- /dev/null +++ b/apps/landing/src/content/docs/docs/core-concepts/server-sent-events.mdx @@ -0,0 +1,430 @@ +--- +title: Server-Sent Events +description: Use HandleSSE for type-safe Server-Sent Events with automatic OpenAPI spec generation and typed TypeScript clients. +sidebar: + order: 8 +--- + +`HandleSSE` is a dedicated handler for Server-Sent Events that gives you a typed writer, automatic SSE headers, and end-to-end type safety from Go to TypeScript — including a generated `subscribe` helper and framework-specific hooks. + +## Registering SSE handlers + +```go +shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[Message]) error { + for msg := range messages(r.Context()) { + if err := sse.Send(msg); err != nil { + return err + } + } + return nil +}) +``` + +## Handler signature + +An SSE handler has two type parameters — input and event: + +```go +func handler(r *http.Request, in InputType, sse *shiftapi.SSEWriter[EventType]) error +``` + +- **`r *http.Request`** — the standard HTTP request +- **`in InputType`** — automatically decoded from path, query, header, body, or form — identical to `Handle` +- **`sse *shiftapi.SSEWriter[EventType]`** — typed writer for sending events +- **`error`** — return an error to stop the stream + +Use `struct{}` when the handler takes no input: + +```go +shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[Event]) error { + // ... +}) +``` + +## SSEWriter + +`SSEWriter[Event]` provides two methods for sending events: + +### Send + +Writes a data-only event: + +```go +sse.Send(Message{Text: "hello"}) +``` + +Produces: + +``` +data: {"text":"hello"} + +``` + +### SendEvent + +Writes a named event: + +```go +sse.SendEvent("chat", Message{Text: "hello"}) +``` + +Produces: + +``` +event: chat +data: {"text":"hello"} + +``` + +Both methods JSON-encode the value, write it in SSE format, and flush the response. On the first call, SSEWriter automatically sets the required headers: + +- `Content-Type: text/event-stream` +- `Cache-Control: no-cache` +- `Connection: keep-alive` + +## Input parsing + +Input decoding works identically to `Handle` — all struct tags (`path`, `query`, `header`, `json`, `form`) and validation rules apply: + +```go +type EventInput struct { + Channel string `query:"channel" validate:"required"` + After int `query:"after"` +} + +shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, in EventInput, sse *shiftapi.SSEWriter[Event]) error { + for event := range subscribe(r.Context(), in.Channel, in.After) { + if err := sse.Send(event); err != nil { + return err + } + } + return nil +}) +``` + +Path parameters work too: + +```go +type StreamInput struct { + RoomID string `path:"room_id" validate:"required"` +} + +shiftapi.HandleSSE(api, "GET /rooms/{room_id}/events", func(r *http.Request, in StreamInput, sse *shiftapi.SSEWriter[Event]) error { + // in.RoomID is parsed and validated +}) +``` + +## Error handling + +Error behavior depends on whether the handler has started sending events: + +- **Before sending** — if your handler returns an error and hasn't called `Send` or `SendEvent`, the framework handles it normally: matching `WithError` types, returning `422` for validation errors, or falling back to `500`. +- **After sending** — if events have already been sent, it's too late to send an error response. The error is logged and the stream ends. + +```go +shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[Event]) error { + data, err := loadInitialData() + if err != nil { + return err // 500 (no events sent yet) + } + + if err := sse.Send(Event{Data: data}); err != nil { + return err // logged, stream already started + } + return nil +}, shiftapi.WithError[*AuthError](http.StatusUnauthorized)) +``` + +## OpenAPI spec + +`HandleSSE` automatically generates the correct OpenAPI spec with `text/event-stream` as the response content type. The `Event` type parameter is reflected into the schema: + +```go +type ChatEvent struct { + User string `json:"user"` + Message string `json:"message"` +} + +shiftapi.HandleSSE(api, "GET /chat", chatHandler) +``` + +Produces: + +```yaml +paths: + /chat: + get: + responses: + '200': + description: OK + content: + text/event-stream: + schema: + $ref: '#/components/schemas/ChatEvent' +``` + +You don't need to use `WithContentType` or `ResponseSchema` — `HandleSSE` sets both automatically. + +## TypeScript client + +`shiftapi prepare` generates a fully-typed `subscribe` function constrained to SSE paths only — calling `subscribe` on a non-SSE path is a compile-time error. + +### subscribe + +`subscribe` returns an async iterable stream with a `close()` method: + +```typescript +import { subscribe } from "@shiftapi/client"; + +const stream = subscribe("/events", { + params: { query: { channel: "general" } }, +}); + +for await (const event of stream) { + console.log(event); + // ^? ChatEvent — fully typed +} +``` + +To stop the stream: + +```typescript +stream.close(); +``` + +You can also pass an `AbortSignal`: + +```typescript +const controller = new AbortController(); +const stream = subscribe("/events", { signal: controller.signal }); + +// later... +controller.abort(); +``` + +### Path parameters and headers + +Path parameters and request headers are type-checked. If your Go handler declares `path:"room_id"` or `header:"X-Token"` on the input struct, the generated types require them: + +```typescript +const stream = subscribe("/rooms/{room_id}/events", { + params: { + path: { room_id: "abc" }, + header: { "X-Token": "secret" }, + }, +}); +``` + +### React + +Use `subscribe` with React state. You control how events are accumulated: + +```tsx +import { subscribe } from "@myapp/api"; +import { useEffect, useState } from "react"; + +function ChatFeed() { + const [messages, setMessages] = useState([]); + + useEffect(() => { + const stream = subscribe("/chat"); + (async () => { + for await (const event of stream) { + setMessages((prev) => [...prev, event]); + } + })(); + return () => stream.close(); + }, []); + + return ( + + ); +} +``` + +For a "latest value" pattern (dashboards, tickers): + +```tsx +function TickerDisplay() { + const [tick, setTick] = useState(null); + + useEffect(() => { + const stream = subscribe("/ticks"); + (async () => { + for await (const event of stream) setTick(event); + })(); + return () => stream.close(); + }, []); + + if (!tick) return
Connecting...
; + return
Last tick: {tick.time}
; +} +``` + +### Svelte + +```svelte + + +
    + {#each messages as msg} +
  • {msg.user}: {msg.message}
  • + {/each} +
+``` + +## Route options + +All standard route options work with `HandleSSE`: + +```go +shiftapi.HandleSSE(api, "GET /events", handler, + shiftapi.WithRouteInfo(shiftapi.RouteInfo{ + Summary: "Subscribe to events", + Tags: []string{"events"}, + }), + shiftapi.WithError[*AuthError](http.StatusUnauthorized), + shiftapi.WithMiddleware(auth), +) +``` + +| Option | Description | +|--------|-------------| +| `WithRouteInfo(info)` | Set OpenAPI summary, description, and tags | +| `WithError[T](code)` | Declare an error type at a status code | +| `WithMiddleware(mw...)` | Apply HTTP middleware | +| `WithResponseHeader(name, value)` | Set a static response header | +| `WithEvents(variants...)` | Declare discriminated union event types (see above) | + +## Discriminated union events + +For endpoints that emit multiple event types, use `WithEvents` to declare each variant. This generates a `oneOf` schema with a `discriminator` in the OpenAPI spec, which produces **TypeScript discriminated unions** in the generated client. + +### Define a marker interface + +Use a Go marker interface to constrain the SSE writer's type parameter: + +```go +type ChatEvent interface{ chatEvent() } + +type MessageData struct { + User string `json:"user"` + Text string `json:"text"` +} +func (MessageData) chatEvent() {} + +type JoinData struct { + User string `json:"user"` +} +func (JoinData) chatEvent() {} +``` + +### Register with WithEvents + +Pass `EventType[T]` descriptors to `WithEvents` to declare each named event: + +```go +shiftapi.HandleSSE(api, "GET /chat", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[ChatEvent]) error { + if err := sse.SendEvent("message", MessageData{User: "alice", Text: "hi"}); err != nil { + return err + } + return sse.SendEvent("join", JoinData{User: "bob"}) +}, shiftapi.WithEvents( + shiftapi.EventType[MessageData]("message"), + shiftapi.EventType[JoinData]("join"), +)) +``` + +### Generated OpenAPI schema + +`WithEvents` produces a `oneOf` + `discriminator` schema: + +```yaml +paths: + /chat: + get: + responses: + '200': + content: + text/event-stream: + schema: + oneOf: + - type: object + required: [event, data] + properties: + event: + type: string + enum: [message] + data: + $ref: '#/components/schemas/MessageData' + - type: object + required: [event, data] + properties: + event: + type: string + enum: [join] + data: + $ref: '#/components/schemas/JoinData' + discriminator: + propertyName: event +``` + +### TypeScript discriminated unions + +The generated TypeScript client automatically narrows event types based on the `event` field: + +```typescript +import { subscribe } from "@shiftapi/client"; + +const stream = subscribe("/chat"); + +for await (const msg of stream) { + if (msg.event === "message") { + console.log(msg.data.text); + // ^? string — narrowed to MessageData + } else if (msg.event === "join") { + console.log(msg.data.user); + // ^? string — narrowed to JoinData + } +} +``` + +### Single vs multi-event + +Without `WithEvents`, `HandleSSE` uses the `Event` type parameter directly as the schema — no `oneOf`, no discriminator. This is the simple case for endpoints with a single event shape: + +```go +// Single event type — no WithEvents needed +shiftapi.HandleSSE(api, "GET /ticks", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[Tick]) error { + return sse.Send(Tick{Time: time.Now()}) +}) +``` + +Use `WithEvents` only when your endpoint emits multiple distinct event types. + +## When to use HandleSSE vs HandleRaw + +| Use case | Recommendation | +|----------|---------------| +| SSE with typed events | `HandleSSE` — typed writer, auto headers, typed TS client | +| SSE with custom framing | `HandleRaw` + `WithContentType("text/event-stream")` | +| File download | `HandleRaw` + `WithContentType("application/octet-stream")` | +| WebSocket upgrade | `HandleRaw` | +| JSON API endpoint | `Handle` | + +`HandleSSE` is the recommended approach for SSE. Use [`HandleRaw`](/docs/core-concepts/raw-handlers) only when you need custom SSE framing or non-standard behavior. diff --git a/apps/landing/src/content/docs/docs/frontend/nextjs.mdx b/apps/landing/src/content/docs/docs/frontend/nextjs.mdx index 72438ee..0190f18 100644 --- a/apps/landing/src/content/docs/docs/frontend/nextjs.mdx +++ b/apps/landing/src/content/docs/docs/frontend/nextjs.mdx @@ -59,3 +59,22 @@ console.log(data.message); ``` The plugin handles starting your Go server, proxying requests, and regenerating types — just like the Vite plugin. + +### Server-Sent Events + +The generated client includes a `subscribe` function for SSE endpoints registered with `HandleSSE`. Use it directly or with the `useSSE` hook from your API package: + +```tsx +import { useSSE } from "@/api"; + +function EventFeed() { + const { data, error } = useSSE("/events"); + + if (error) return
Error: {error.message}
; + if (!data) return
Connecting...
; + + return
{data.message}
; +} +``` + +See [Server-Sent Events](/docs/core-concepts/server-sent-events) for full details. diff --git a/apps/landing/src/content/docs/docs/frontend/vite.mdx b/apps/landing/src/content/docs/docs/frontend/vite.mdx index 94a20ab..192e545 100644 --- a/apps/landing/src/content/docs/docs/frontend/vite.mdx +++ b/apps/landing/src/content/docs/docs/frontend/vite.mdx @@ -67,3 +67,33 @@ console.log(data.message); ``` Types are regenerated automatically when you save a Go file — changes appear in your editor via Vite's HMR. + +### Server-Sent Events + +The generated client includes a `subscribe` function for SSE endpoints registered with `HandleSSE`. It returns a typed async iterable: + +```typescript +import { subscribe } from "@shiftapi/client"; + +const stream = subscribe("/events"); + +for await (const event of stream) { + console.log(event); // fully typed from your Go Event struct +} +``` + +For Svelte projects, use the `createSSE` store from your API package: + +```svelte + + +{#if $events.data} +

{$events.data.message}

+{/if} +``` + +See [Server-Sent Events](/docs/core-concepts/server-sent-events) for full details. diff --git a/apps/landing/src/content/docs/docs/getting-started/introduction.mdx b/apps/landing/src/content/docs/docs/getting-started/introduction.mdx index 5257920..3afc64c 100644 --- a/apps/landing/src/content/docs/docs/getting-started/introduction.mdx +++ b/apps/landing/src/content/docs/docs/getting-started/introduction.mdx @@ -21,5 +21,6 @@ ShiftAPI is a Go framework that generates an OpenAPI 3.1 spec from your handler - **Composable options** — bundle middleware, errors, and other options into reusable `Option` values with `ComposeOptions` - **Typed HTTP headers** — parse, validate, and document HTTP headers with `header` struct tags - **File uploads** — declare uploads with `form` tags, get correct `multipart/form-data` types +- **Server-Sent Events** — `HandleSSE` gives you a typed writer, automatic SSE headers, and a generated `subscribe` helper with React/Svelte hooks - **Interactive docs** — Scalar API reference at `/docs` and OpenAPI spec at `/openapi.json` - **Just `net/http`** — implements `http.Handler`, works with any middleware or router diff --git a/doc.go b/doc.go index b53af96..a1af3ed 100644 --- a/doc.go +++ b/doc.go @@ -119,6 +119,65 @@ // Registering a route with status 204 or 304 and a response type that has JSON body // fields panics at startup — this catches misconfigurations early. // +// # Server-Sent Events +// +// Use [HandleSSE] for Server-Sent Events with a typed event writer: +// +// type ChatEvent struct { +// User string `json:"user"` +// Message string `json:"message"` +// } +// +// shiftapi.HandleSSE(api, "GET /chat", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[ChatEvent]) error { +// for event := range events(r.Context()) { +// if err := sse.Send(event); err != nil { +// return err +// } +// } +// return nil +// }) +// +// [SSEWriter] automatically sets Content-Type, Cache-Control, and Connection +// headers on the first write. Use [SSEWriter.Send] for data-only events or +// [SSEWriter.SendEvent] for named events. The Event type parameter is reflected +// into the OpenAPI spec under text/event-stream. +// +// For endpoints that emit multiple event types, use [WithEvents] to declare +// each variant. Define a marker interface and use [SSEWriter.SendEvent] with +// named events: +// +// type ChatEvent interface{ chatEvent() } +// +// type MessageData struct { +// User string `json:"user"` +// Text string `json:"text"` +// } +// func (MessageData) chatEvent() {} +// +// type JoinData struct { +// User string `json:"user"` +// } +// func (JoinData) chatEvent() {} +// +// shiftapi.HandleSSE(api, "GET /chat", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[ChatEvent]) error { +// sse.SendEvent("message", MessageData{User: "alice", Text: "hi"}) +// return sse.SendEvent("join", JoinData{User: "bob"}) +// }, shiftapi.WithEvents( +// shiftapi.EventType[MessageData]("message"), +// shiftapi.EventType[JoinData]("join"), +// )) +// +// [WithEvents] generates a oneOf schema with a discriminator in the OpenAPI spec, +// which produces TypeScript discriminated unions in the generated client. +// +// The generated TypeScript client includes a typed subscribe function +// constrained to SSE paths. It handles path/query/header parameter +// substitution, SSE stream parsing, and yields typed events as an async +// iterable. +// +// For custom SSE framing or non-standard behavior, use [HandleRaw] with +// [WithContentType]("text/event-stream") instead. +// // # Route groups // // Use [API.Group] to create a sub-router with a shared path prefix and options. diff --git a/example_test.go b/example_test.go index f8df709..0ac2649 100644 --- a/example_test.go +++ b/example_test.go @@ -341,6 +341,74 @@ func ExampleFromContext() { // {"user":"alice"} } +func ExampleHandleSSE() { + api := shiftapi.New() + + type Message struct { + Text string `json:"text"` + } + + shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[Message]) error { + for _, msg := range []string{"hello", "world"} { + if err := sse.Send(Message{Text: msg}); err != nil { + return err + } + } + return nil + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/events", nil) + api.ServeHTTP(w, r) + fmt.Println(w.Body.String()) + // Output: + // data: {"text":"hello"} + // + // data: {"text":"world"} + // +} + +type exChatEvent interface{ exChatEvent() } + +type exMessageData struct { + User string `json:"user"` + Text string `json:"text"` +} + +func (exMessageData) exChatEvent() {} + +type exJoinData struct { + User string `json:"user"` +} + +func (exJoinData) exChatEvent() {} + +func ExampleWithEvents() { + api := shiftapi.New() + + shiftapi.HandleSSE(api, "GET /chat", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[exChatEvent]) error { + if err := sse.SendEvent("message", exMessageData{User: "alice", Text: "hi"}); err != nil { + return err + } + return sse.SendEvent("join", exJoinData{User: "bob"}) + }, shiftapi.WithEvents( + shiftapi.EventType[exMessageData]("message"), + shiftapi.EventType[exJoinData]("join"), + )) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/chat", nil) + api.ServeHTTP(w, r) + fmt.Println(w.Body.String()) + // Output: + // event: message + // data: {"user":"alice","text":"hi"} + // + // event: join + // data: {"user":"bob"} + // +} + func ExampleAPI_ServeHTTP() { api := shiftapi.New() diff --git a/handler.go b/handler.go index 2e31763..72d3539 100644 --- a/handler.go +++ b/handler.go @@ -201,6 +201,32 @@ func adaptRaw[In any](fn RawHandlerFunc[In], hc *handlerConfig) http.HandlerFunc } } +func adaptSSE[In, Event any](fn SSEHandlerFunc[In, Event], hc *handlerConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + in, ok := parseInput[In](w, r, hc) + if !ok { + return + } + + for _, h := range hc.staticHeaders { + w.Header().Set(h.name, h.value) + } + + wt := &writeTracker{ResponseWriter: w} + sse := &SSEWriter[Event]{ + w: wt, + rc: http.NewResponseController(wt), + } + if err := fn(r, in, sse); err != nil { + if !wt.written { + handleError(wt, hc.internalServerFn, err, hc.errLookup) + } else { + log.Printf("shiftapi: SSE handler error after response started: %v", err) + } + } + } +} + func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) diff --git a/handlerFuncs.go b/handlerFuncs.go index 28d1e49..485a40c 100644 --- a/handlerFuncs.go +++ b/handlerFuncs.go @@ -162,6 +162,7 @@ func (s *routeSetup) schemaInput(method string, outType reflect.Type, hasRespHea staticHeaders: s.allStaticHeaders, contentType: s.cfg.contentType, responseSchemaType: s.cfg.responseSchemaType, + eventVariants: s.cfg.eventVariants, } } @@ -304,3 +305,61 @@ func HandleRaw[In any](router Router, pattern string, fn RawHandlerFunc[In], opt registerRawRoute(router, method, path, fn, options...) } +func registerSSERoute[In, Event any]( + router Router, + method string, + path string, + fn SSEHandlerFunc[In, Event], + options ...RouteOption, +) { + s := prepareRoute[In](router, method, path, false, options) + + // Set content type for the OpenAPI spec. If WithEvents was used, + // event variants drive schema generation (oneOf + discriminator). + // Otherwise, use the Event type parameter for a single-type schema. + s.cfg.contentType = "text/event-stream" + if len(s.cfg.eventVariants) == 0 { + s.cfg.responseSchemaType = reflect.TypeFor[Event]() + } else { + seen := make(map[string]bool, len(s.cfg.eventVariants)) + for _, ev := range s.cfg.eventVariants { + name := ev.eventName() + if seen[name] { + panic(fmt.Sprintf("shiftapi: duplicate event name %q in WithEvents for %s %s", name, method, path)) + } + seen[name] = true + } + } + + si := s.schemaInput(method, nil, false, false) + if err := s.api.updateSchema(si); err != nil { + panic(fmt.Sprintf("shiftapi: schema generation failed for %s %s: %v", method, s.fullPath, err)) + } + + hc := s.handlerCfg(method, false) + h := adaptSSE(fn, hc) + s.wrapAndRegister(router, h) +} + + + +// HandleSSE registers a Server-Sent Events handler for the given pattern. +// The handler receives a typed [SSEWriter] for sending events to the client. +// Input parsing, validation, and middleware work identically to [Handle]. +// +// The OpenAPI spec automatically uses "text/event-stream" as the response +// content type, with the Event type parameter generating the event schema. +// +// shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, in struct{}, sse *shiftapi.SSEWriter[Message]) error { +// for msg := range messages(r.Context()) { +// if err := sse.Send(msg); err != nil { +// return err +// } +// } +// return nil +// }) +func HandleSSE[In, Event any](router Router, pattern string, fn SSEHandlerFunc[In, Event], options ...RouteOption) { + method, path := parsePattern(pattern) + registerSSERoute(router, method, path, fn, options...) +} + diff --git a/handlerOptions.go b/handlerOptions.go index 424ccfa..22d5bad 100644 --- a/handlerOptions.go +++ b/handlerOptions.go @@ -11,8 +11,9 @@ type routeConfig struct { errors []errorEntry middleware []func(http.Handler) http.Handler staticRespHeaders []staticResponseHeader - contentType string // custom response media type - responseSchemaType reflect.Type // optional type for schema generation under the content type + contentType string // custom response media type + responseSchemaType reflect.Type // optional type for schema generation under the content type + eventVariants []EventVariant // SSE event variants for oneOf schema generation } func (c *routeConfig) addError(e errorEntry) { @@ -98,3 +99,23 @@ func WithContentType(contentType string, opts ...ResponseSchemaOption) routeOpti } } } + +// WithEvents registers named SSE event types for OpenAPI schema generation. +// Each [EventVariant] maps an event name to a payload type, producing a oneOf +// schema with a discriminator in the OpenAPI spec. The generated TypeScript +// client yields a discriminated union type. +// +// Use with [HandleSSE] and [SSEWriter.SendEvent] to send different payload +// types under different event names: +// +// shiftapi.HandleSSE(api, "GET /chat", chatHandler, +// shiftapi.WithEvents( +// shiftapi.EventType[MessageData]("message"), +// shiftapi.EventType[JoinData]("join"), +// ), +// ) +func WithEvents(variants ...EventVariant) routeOptionFunc { + return func(cfg *routeConfig) { + cfg.eventVariants = append(cfg.eventVariants, variants...) + } +} diff --git a/packages/create-shiftapi/package.json b/packages/create-shiftapi/package.json index 94f73b5..4eafc49 100644 --- a/packages/create-shiftapi/package.json +++ b/packages/create-shiftapi/package.json @@ -15,14 +15,14 @@ }, "type": "module", "bin": { - "create-shiftapi": "dist/index.js" + "create-shiftapi": "dist/index.mjs" }, "files": [ "dist", "templates" ], "scripts": { - "build": "tsup src/index.ts --format esm", + "build": "tsdown --no-dts", "test": "vitest run" }, "dependencies": { @@ -30,7 +30,7 @@ }, "devDependencies": { "@types/node": "^25.2.3", - "tsup": "^8.0.0", + "tsdown": "^0.21.1", "typescript": "^5.5.0", "vitest": "^2.0.0" } diff --git a/packages/create-shiftapi/templates/next/app/api.ts b/packages/create-shiftapi/templates/next/app/api.ts index d760119..042e6c7 100644 --- a/packages/create-shiftapi/templates/next/app/api.ts +++ b/packages/create-shiftapi/templates/next/app/api.ts @@ -1,5 +1,5 @@ import createClient from "openapi-react-query"; -import { client } from "@shiftapi/client"; +import { client, subscribe } from "@shiftapi/client"; export const api = createClient(client); -export { client }; +export { client, subscribe }; diff --git a/packages/create-shiftapi/templates/react/packages/api/src/index.ts b/packages/create-shiftapi/templates/react/packages/api/src/index.ts index d760119..042e6c7 100644 --- a/packages/create-shiftapi/templates/react/packages/api/src/index.ts +++ b/packages/create-shiftapi/templates/react/packages/api/src/index.ts @@ -1,5 +1,5 @@ import createClient from "openapi-react-query"; -import { client } from "@shiftapi/client"; +import { client, subscribe } from "@shiftapi/client"; export const api = createClient(client); -export { client }; +export { client, subscribe }; diff --git a/packages/create-shiftapi/templates/svelte/packages/api/src/index.ts b/packages/create-shiftapi/templates/svelte/packages/api/src/index.ts index e13dfa8..29cd7a9 100644 --- a/packages/create-shiftapi/templates/svelte/packages/api/src/index.ts +++ b/packages/create-shiftapi/templates/svelte/packages/api/src/index.ts @@ -1,5 +1,5 @@ import createClient from "openapi-svelte-query"; -import { client } from "@shiftapi/client"; +import { client, subscribe } from "@shiftapi/client"; export const api = createClient(client); -export { client }; +export { client, subscribe }; diff --git a/packages/create-shiftapi/tsdown.config.ts b/packages/create-shiftapi/tsdown.config.ts new file mode 100644 index 0000000..46785f6 --- /dev/null +++ b/packages/create-shiftapi/tsdown.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], +}); diff --git a/packages/next/package.json b/packages/next/package.json index ed2031b..78dbfd8 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -14,32 +14,32 @@ "url": "https://github.com/fcjr/shiftapi/issues" }, "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "dist/index.mjs", + "types": "dist/index.d.mts", "exports": { ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" } }, "files": [ "dist" ], "scripts": { - "build": "tsup src/index.ts --format esm --dts", - "dev": "tsup src/index.ts --format esm --dts --watch" + "build": "tsdown", + "dev": "tsdown --watch" }, "peerDependencies": { "next": ">=14.0.0" }, "dependencies": { - "shiftapi": "workspace:*", - "openapi-fetch": "^0.13.0" + "openapi-fetch": "^0.13.0", + "shiftapi": "workspace:*" }, "devDependencies": { "@types/node": "^25.2.3", "next": "^15.0.0", - "tsup": "^8.0.0", + "tsdown": "^0.21.1", "typescript": "^5.5.0" } } diff --git a/packages/shiftapi/package.json b/packages/shiftapi/package.json index fd29e8f..7c3acb7 100644 --- a/packages/shiftapi/package.json +++ b/packages/shiftapi/package.json @@ -14,27 +14,27 @@ "url": "https://github.com/fcjr/shiftapi/issues" }, "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "dist/index.mjs", + "types": "dist/index.d.mts", "exports": { ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" }, "./internal": { - "types": "./dist/internal.d.ts", - "default": "./dist/internal.js" + "types": "./dist/internal.d.mts", + "default": "./dist/internal.mjs" } }, "bin": { - "shiftapi": "./dist/prepare.js" + "shiftapi": "./dist/prepare.mjs" }, "files": [ "dist" ], "scripts": { - "build": "tsup src/index.ts src/internal.ts src/prepare.ts --format esm --dts", - "dev": "tsup src/index.ts src/internal.ts src/prepare.ts --format esm --dts --watch", + "build": "tsdown", + "dev": "tsdown --watch", "test": "vitest run" }, "dependencies": { @@ -44,7 +44,7 @@ }, "devDependencies": { "@types/node": "^25.2.3", - "tsup": "^8.0.0", + "tsdown": "^0.21.1", "typescript": "^5.5.0", "vitest": "^2.0.0" } diff --git a/packages/shiftapi/src/__tests__/generate.test.ts b/packages/shiftapi/src/__tests__/generate.test.ts index 4da7a47..58b9dd8 100644 --- a/packages/shiftapi/src/__tests__/generate.test.ts +++ b/packages/shiftapi/src/__tests__/generate.test.ts @@ -121,6 +121,8 @@ describe("virtualModuleTemplate", () => { expect(source).toContain("import.meta.env.VITE_SHIFTAPI_BASE_URL"); expect(source).toContain("/api"); expect(source).toContain("export { createClient }"); + expect(source).toContain('import { createSubscribe } from "shiftapi/internal"'); + expect(source).toContain("export const subscribe = createSubscribe("); // Should NOT contain TypeScript syntax expect(source).not.toContain("interface"); expect(source).not.toContain("type "); diff --git a/packages/shiftapi/src/internal.ts b/packages/shiftapi/src/internal.ts index a19ef21..42b76db 100644 --- a/packages/shiftapi/src/internal.ts +++ b/packages/shiftapi/src/internal.ts @@ -7,3 +7,5 @@ export { dtsTemplate, clientJsTemplate, nextClientJsTemplate, virtualModuleTempl export { MODULE_ID, RESOLVED_MODULE_ID, DEV_API_PREFIX } from "./constants"; export { GoServerManager } from "./goServer"; export { findFreePort } from "./ports"; +export { createSubscribe } from "./subscribe"; +export type { SSEStream, SubscribeOptions, SubscribeFn } from "./subscribe"; diff --git a/packages/shiftapi/src/subscribe.ts b/packages/shiftapi/src/subscribe.ts new file mode 100644 index 0000000..5754c9e --- /dev/null +++ b/packages/shiftapi/src/subscribe.ts @@ -0,0 +1,109 @@ +/** + * SSE stream returned by {@link createSubscribe}. + * An async iterable of parsed events with a `close()` method to abort. + */ +export interface SSEStream { + [Symbol.asyncIterator](): AsyncIterableIterator; + close(): void; +} + +/** Options accepted by a `subscribe` function created via {@link createSubscribe}. */ +export interface SubscribeOptions { + params?: { + path?: Record; + query?: Record; + header?: Record; + }; + signal?: AbortSignal; +} + +/** A subscribe function returned by {@link createSubscribe}. */ +export type SubscribeFn = ( + path: string, + options?: SubscribeOptions, +) => SSEStream; + +/** + * Creates a type-safe SSE `subscribe` function bound to the given base URL. + * + * The returned function connects to an SSE endpoint via `fetch`, parses the + * event stream, and yields parsed JSON events as an async iterable. + * + * Named events (with an `event:` field) are yielded as `{ event, data }`. + * Unnamed events are yielded as the parsed `data` value directly. + */ +export function createSubscribe(baseUrl: string) { + return function subscribe( + path: string, + options: SubscribeOptions = {}, + ): SSEStream { + let url = baseUrl + path; + const { params } = options; + + if (params?.path) { + for (const [k, v] of Object.entries(params.path)) + url = url.replace("{" + k + "}", encodeURIComponent(String(v))); + } + + if (params?.query) { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(params.query)) qs.set(k, String(v)); + const str = qs.toString(); + if (str) url += "?" + str; + } + + const headers: Record = { Accept: "text/event-stream" }; + if (params?.header) { + for (const [k, v] of Object.entries(params.header)) + headers[k] = String(v); + } + + const controller = new AbortController(); + const signal = options.signal + ? AbortSignal.any([options.signal, controller.signal]) + : controller.signal; + + const response = fetch(url, { method: "GET", signal, headers }); + + return { + async *[Symbol.asyncIterator]() { + const res = await response; + if (!res.ok) throw new Error("SSE request failed: " + res.status); + const reader = res.body! + .pipeThrough(new TextDecoderStream()) + .getReader(); + let buf = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += value; + const parts = buf.split("\n\n"); + buf = parts.pop()!; + for (const part of parts) { + const lines = part.split("\n"); + const eventLine = lines.find((l) => l.startsWith("event: ")); + const event = eventLine ? eventLine.slice(7) : undefined; + const data = lines + .filter((l) => l.startsWith("data: ")) + .map((l) => l.slice(6)) + .join("\n"); + if (data) { + yield ( + event !== undefined + ? { event, data: JSON.parse(data) } + : JSON.parse(data) + ) as T; + } + } + } + } finally { + reader.releaseLock(); + } + }, + close() { + controller.abort(); + }, + }; + }; +} diff --git a/packages/shiftapi/src/templates.ts b/packages/shiftapi/src/templates.ts index a8c19c9..a29f2fe 100644 --- a/packages/shiftapi/src/templates.ts +++ b/packages/shiftapi/src/templates.ts @@ -38,6 +38,26 @@ declare module "@shiftapi/client" { ${indent(generatedTypes)} import type createClient from "openapi-fetch"; + import type { SSEStream } from "shiftapi/internal"; + + type SSEPaths = { + [P in keyof paths]: paths[P] extends { + get: { responses: { 200: { content: { "text/event-stream": infer T } } } } + } ? P : never + }[keyof paths]; + + type SSEEventType

= + paths[P] extends { + get: { responses: { 200: { content: { "text/event-stream": infer T } } } } + } ? T : never; + + type SSEParams

= + paths[P] extends { get: { parameters: infer Params } } ? Params : never; + + export function subscribe

( + path: P, + options?: { params?: SSEParams

; signal?: AbortSignal } + ): SSEStream>; export const client: ReturnType>; export { createClient }; @@ -49,6 +69,7 @@ export function clientJsTemplate(baseUrl: string): string { return `\ // Auto-generated by shiftapi. Do not edit. import createClient from "openapi-fetch"; +import { createSubscribe } from "shiftapi/internal"; /** Pre-configured, fully-typed API client. */ export const client = createClient({ @@ -56,6 +77,8 @@ export const client = createClient({ bodySerializer: ${BODY_SERIALIZER}, }); +export const subscribe = createSubscribe(${JSON.stringify(baseUrl)}); + export { createClient }; `; } @@ -69,6 +92,7 @@ export function nextClientJsTemplate( return `\ // Auto-generated by @shiftapi/next. Do not edit. import createClient from "./openapi-fetch.js"; +import { createSubscribe } from "shiftapi/internal"; const baseUrl = process.env.NEXT_PUBLIC_SHIFTAPI_BASE_URL || ${JSON.stringify(baseUrl)}; @@ -79,6 +103,8 @@ export const client = createClient({ bodySerializer: ${BODY_SERIALIZER}, }); +export const subscribe = createSubscribe(baseUrl); + export { createClient }; `; } @@ -87,6 +113,7 @@ export { createClient }; return `\ // Auto-generated by @shiftapi/next. Do not edit. import createClient from "./openapi-fetch.js"; +import { createSubscribe } from "shiftapi/internal"; const baseUrl = process.env.NEXT_PUBLIC_SHIFTAPI_BASE_URL || @@ -100,6 +127,8 @@ export const client = createClient({ bodySerializer: ${BODY_SERIALIZER}, }); +export const subscribe = createSubscribe(baseUrl); + export { createClient }; `; } @@ -115,13 +144,18 @@ export function virtualModuleTemplate( return `\ // Auto-generated by @shiftapi/vite-plugin import createClient from "openapi-fetch"; +import { createSubscribe } from "shiftapi/internal"; + +const baseUrl = ${baseUrlExpr}; /** Pre-configured, fully-typed API client. */ export const client = createClient({ - baseUrl: ${baseUrlExpr}, + baseUrl, bodySerializer: ${BODY_SERIALIZER}, }); +export const subscribe = createSubscribe(baseUrl); + export { createClient }; `; } diff --git a/packages/shiftapi/tsdown.config.ts b/packages/shiftapi/tsdown.config.ts new file mode 100644 index 0000000..3ad043a --- /dev/null +++ b/packages/shiftapi/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: [ + "src/index.ts", + "src/internal.ts", + "src/prepare.ts", + ], +}); diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json index b238842..62e0cf9 100644 --- a/packages/vite-plugin/package.json +++ b/packages/vite-plugin/package.json @@ -14,12 +14,12 @@ "url": "https://github.com/fcjr/shiftapi/issues" }, "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "dist/index.mjs", + "types": "dist/index.d.mts", "exports": { ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" }, "./virtual": { "types": "./virtual.d.ts" @@ -30,19 +30,19 @@ "virtual.d.ts" ], "scripts": { - "build": "tsup src/index.ts --format esm --dts", - "dev": "tsup src/index.ts --format esm --dts --watch" + "build": "tsdown", + "dev": "tsdown --watch" }, "peerDependencies": { "vite": "^6.0.0" }, "dependencies": { - "shiftapi": "workspace:*", - "openapi-fetch": "^0.13.0" + "openapi-fetch": "^0.13.0", + "shiftapi": "workspace:*" }, "devDependencies": { "@types/node": "^25.2.3", - "tsup": "^8.0.0", + "tsdown": "^0.21.1", "typescript": "^5.5.0", "vite": "^6.0.0" } diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index b51a018..be11b40 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -143,6 +143,9 @@ export default function shiftapiPlugin(opts?: ShiftAPIPluginOptions): Plugin { if (id === "openapi-fetch" && importer === RESOLVED_MODULE_ID) { return createRequire(import.meta.url).resolve("openapi-fetch"); } + if (id === "shiftapi/internal" && importer === RESOLVED_MODULE_ID) { + return createRequire(import.meta.url).resolve("shiftapi/internal"); + } }, load(id) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b12d0a..9e6b40d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,9 +110,9 @@ importers: '@types/node': specifier: ^25.2.3 version: 25.2.3 - tsup: - specifier: ^8.0.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.2) + tsdown: + specifier: ^0.21.1 + version: 0.21.1(typescript@5.9.3) typescript: specifier: ^5.5.0 version: 5.9.3 @@ -135,9 +135,9 @@ importers: next: specifier: ^15.0.0 version: 15.5.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - tsup: - specifier: ^8.0.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.2) + tsdown: + specifier: ^0.21.1 + version: 0.21.1(typescript@5.9.3) typescript: specifier: ^5.5.0 version: 5.9.3 @@ -157,9 +157,9 @@ importers: '@types/node': specifier: ^25.2.3 version: 25.2.3 - tsup: - specifier: ^8.0.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.2) + tsdown: + specifier: ^0.21.1 + version: 0.21.1(typescript@5.9.3) typescript: specifier: ^5.5.0 version: 5.9.3 @@ -179,9 +179,9 @@ importers: '@types/node': specifier: ^25.2.3 version: 25.2.3 - tsup: - specifier: ^8.0.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.2) + tsdown: + specifier: ^0.21.1 + version: 0.21.1(typescript@5.9.3) typescript: specifier: ^5.5.0 version: 5.9.3 @@ -247,6 +247,10 @@ packages: resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/generator@8.0.0-rc.2': + resolution: {integrity: sha512-oCQ1IKPwkzCeJzAPb7Fv8rQ9k5+1sG8mf2uoHiMInPYvkRfrDJxbTIbH51U+jstlkghus0vAi3EBvkfvEsYNLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} @@ -273,10 +277,18 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.0-rc.2': + resolution: {integrity: sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ==} + engines: {node: ^20.19.0 || >=22.12.0} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.0-rc.2': + resolution: {integrity: sha512-xExUBkuXWJjVuIbO7z6q7/BA9bgfJDEhVL0ggrggLMbg0IzCUWGT1hZGE8qUH7Il7/RD/a6cZ3AAFrrlp1LF/A==} + engines: {node: ^20.19.0 || >=22.12.0} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -290,6 +302,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@8.0.0-rc.2': + resolution: {integrity: sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -318,6 +335,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@8.0.0-rc.2': + resolution: {integrity: sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw==} + engines: {node: ^20.19.0 || >=22.12.0} + '@capsizecss/unpack@4.0.0': resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} engines: {node: '>=18'} @@ -382,9 +403,15 @@ packages: resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} engines: {node: '>=14'} + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -1006,6 +1033,9 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@next/env@15.5.12': resolution: {integrity: sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==} @@ -1060,6 +1090,9 @@ packages: '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + '@oxc-project/types@0.115.0': + resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} + '@pagefind/darwin-arm64@1.4.0': resolution: {integrity: sha512-2vMqkbv3lbx1Awea90gTaBsvpzgRs7MuSgKDxW0m9oV1GPZCZbZBJg/qL83GIUEN2BFlY46dtUZi54pwH+/pTQ==} cpu: [arm64] @@ -1102,6 +1135,9 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + '@redocly/ajv@8.17.4': resolution: {integrity: sha512-BieiCML/IgP6x99HZByJSt7fJE4ipgzO7KAFss92Bs+PEI35BhY7vGIysFXLT+YmS7nHtQjZjhOQyPPEf7xGHA==} @@ -1112,9 +1148,101 @@ packages: resolution: {integrity: sha512-2+O+riuIUgVSuLl3Lyh5AplWZyVMNuG2F98/o6NrutKJfW4/GTZdPpZlIphS0HGgcOHgmWcCSHj+dWFlZaGSHw==} engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rolldown/binding-android-arm64@1.0.0-rc.8': + resolution: {integrity: sha512-5bcmMQDWEfWUq3m79Mcf/kbO6e5Jr6YjKSsA1RnpXR6k73hQ9z1B17+4h93jXpzHvS18p7bQHM1HN/fSd+9zog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.8': + resolution: {integrity: sha512-dcHPd5N4g9w2iiPRJmAvO0fsIWzF2JPr9oSuTjxLL56qu+oML5aMbBMNwWbk58Mt3pc7vYs9CCScwLxdXPdRsg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.8': + resolution: {integrity: sha512-mw0VzDvoj8AuR761QwpdCFN0sc/jspuc7eRYJetpLWd+XyansUrH3C7IgNw6swBOgQT9zBHNKsVCjzpfGJlhUA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.8': + resolution: {integrity: sha512-xNrRa6mQ9NmMIJBdJtPMPG8Mso0OhM526pDzc/EKnRrIrrkHD1E0Z6tONZRmUeJElfsQ6h44lQQCcDilSNIvSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.8': + resolution: {integrity: sha512-WgCKoO6O/rRUwimWfEJDeztwJJmuuX0N2bYLLRxmXDTtCwjToTOqk7Pashl/QpQn3H/jHjx0b5yCMbcTVYVpNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.8': + resolution: {integrity: sha512-tOHgTOQa8G4Z3ULj4G3NYOGGJEsqPHR91dT72u63OtVsZ7B6wFJKOx+ZKv+pvwzxWz92/I2ycaqi2/Ll4l+rlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.8': + resolution: {integrity: sha512-oRbxcgDujCi2Yp1GTxoUFsIFlZsuPHU4OV4AzNc3/6aUmR4lfm9FK0uwQu82PJsuUwnF2jFdop3Ep5c1uK7Uxg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.8': + resolution: {integrity: sha512-oaLRyUHw8kQE5M89RqrDJZ10GdmGJcMeCo8tvaE4ukOofqgjV84AbqBSH6tTPjeT2BHv+xlKj678GBuIb47lKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.8': + resolution: {integrity: sha512-1hjSKFrod5MwBBdLOOA0zpUuSfSDkYIY+QqcMcIU1WOtswZtZdUkcFcZza9b2HcAb0bnpmmyo0LZcaxLb2ov1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.8': + resolution: {integrity: sha512-a1+F0aV4Wy9tT3o+cHl3XhOy6aFV+B8Ll+/JFj98oGkb6lGk3BNgrxd+80RwYRVd23oLGvj3LwluKYzlv1PEuw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.8': + resolution: {integrity: sha512-bGyXCFU11seFrf7z8PcHSwGEiFVkZ9vs+auLacVOQrVsI8PFHJzzJROF3P6b0ODDmXr0m6Tj5FlDhcXVk0Jp8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.8': + resolution: {integrity: sha512-n8d+L2bKgf9G3+AM0bhHFWdlz9vYKNim39ujRTieukdRek0RAo2TfG2uEnV9spa4r4oHUfL9IjcY3M9SlqN1gw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.8': + resolution: {integrity: sha512-4R4iJDIk7BrJdteAbEAICXPoA7vZoY/M0OBfcRlQxzQvUYMcEp2GbC/C8UOgQJhu2TjGTpX1H8vVO1xHWcRqQA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.8': + resolution: {integrity: sha512-3lwnklba9qQOpFnQ7EW+A1m4bZTWXZE4jtehsZ0YOl2ivW1FQqp5gY7X2DLuKITggesyuLwcmqS11fA7NtrmrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.8': + resolution: {integrity: sha512-VGjCx9Ha1P/r3tXGDZyG0Fcq7Q0Afnk64aaKzr1m40vbn1FL8R3W0V1ELDvPgzLXaaqK/9PnsqSaLWXfn6JtGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rolldown/pluginutils@1.0.0-rc.8': + resolution: {integrity: sha512-wzJwL82/arVfeSP3BLr1oTy40XddjtEdrdgtJ4lLRBu06mP3q/8HGM6K0JRlQuTA3XB0pNJx2so/nmpY4xyOew==} + '@rollup/pluginutils@5.3.0': resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} engines: {node: '>=14.0.0'} @@ -1402,6 +1530,9 @@ packages: peerDependencies: react: ^18 || ^19 + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1429,6 +1560,9 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -1535,8 +1669,9 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} @@ -1562,6 +1697,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-kit@3.0.0-beta.1: + resolution: {integrity: sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==} + engines: {node: '>=20.19.0'} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -1600,6 +1739,9 @@ packages: bcp-47@2.1.0: resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==} + birpc@4.0.0: + resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} @@ -1618,16 +1760,14 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - bundle-require@5.1.0: - resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - peerDependencies: - esbuild: '>=0.18' - cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + camelcase@8.0.0: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} @@ -1665,10 +1805,6 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -1701,10 +1837,6 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - comment-json@4.5.1: resolution: {integrity: sha512-taEtr3ozUmOB7it68Jll7s0Pwm+aoiHyXKrEC8SEodL4rNpdfDLqa7PfBlrgFoCNNdR8ImL+muti5IGvktJAAg==} engines: {node: '>= 6'} @@ -1712,13 +1844,6 @@ packages: common-ancestor-path@1.0.1: resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1833,6 +1958,15 @@ packages: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} + dts-resolver@2.1.3: + resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==} + engines: {node: '>=20.19.0'} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + electron-to-chromium@1.5.307: resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} @@ -1842,6 +1976,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + enhanced-resolve@5.20.0: resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} engines: {node: '>=10.13.0'} @@ -1946,9 +2084,6 @@ packages: picomatch: optional: true - fix-dts-default-cjs-exports@1.0.1: - resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} - flattie@1.1.1: resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} engines: {node: '>=8'} @@ -1973,6 +2108,9 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -2042,6 +2180,9 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hookable@6.0.1: + resolution: {integrity: sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -2064,6 +2205,10 @@ packages: import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + import-without-cache@0.2.5: + resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} + engines: {node: '>=20.19.0'} + index-to-position@1.2.0: resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} engines: {node: '>=18'} @@ -2112,10 +2257,6 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - joycon@3.1.1: - resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} - engines: {node: '>=10'} - js-levenshtein@1.1.6: resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} engines: {node: '>=0.10.0'} @@ -2222,17 +2363,6 @@ packages: resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} engines: {node: '>= 12.0.0'} - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - load-tsconfig@0.2.5: - resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -2436,9 +2566,6 @@ packages: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} - mlly@1.8.0: - resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -2446,9 +2573,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2498,9 +2622,8 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -2591,35 +2714,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} - postcss-load-config@6.0.1: - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true - postcss-nested@6.2.0: resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} engines: {node: '>=12.0'} @@ -2649,6 +2747,9 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} @@ -2665,10 +2766,6 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -2743,9 +2840,8 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} retext-latin@4.0.0: resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} @@ -2759,6 +2855,30 @@ packages: retext@9.0.0: resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + rolldown-plugin-dts@0.22.4: + resolution: {integrity: sha512-pueqTPyN1N6lWYivyDGad+j+GO3DT67pzpct8s8e6KGVIezvnrDjejuw1AXFeyDRas3xTq4Ja6Lj5R5/04C5GQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@ts-macro/tsc': ^0.3.6 + '@typescript/native-preview': '>=7.0.0-dev.20250601.1' + rolldown: ^1.0.0-rc.3 + typescript: ^5.0.0 || ^6.0.0-beta + vue-tsc: ~3.2.0 + peerDependenciesMeta: + '@ts-macro/tsc': + optional: true + '@typescript/native-preview': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.0.0-rc.8: + resolution: {integrity: sha512-RGOL7mz/aoQpy/y+/XS9iePBfeNRDUdozrhCEJxdpJyimW8v6yp4c30q6OviUU5AnUJVLRL9GP//HUs6N3ALrQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2860,11 +2980,6 @@ packages: babel-plugin-macros: optional: true - sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -2881,13 +2996,6 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} @@ -2927,9 +3035,6 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} @@ -2940,27 +3045,36 @@ packages: typescript: optional: true - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - tsup@8.5.1: - resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} - engines: {node: '>=18'} + tsdown@0.21.1: + resolution: {integrity: sha512-2Qgm5Pztm1ZOBr6AfJ4pAlspuufa5SlnBgnUx7a0QSm0a73FrBETiRB422gHtMKbgWf1oUtjBL/eK+po7OXwKw==} + engines: {node: '>=20.19.0'} hasBin: true peerDependencies: - '@microsoft/api-extractor': ^7.36.0 - '@swc/core': ^1 - postcss: ^8.4.12 - typescript: '>=4.5.0' + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.21.1 + '@tsdown/exe': 0.21.1 + '@vitejs/devtools': '*' + publint: ^0.3.0 + typescript: ^5.0.0 + unplugin-unused: ^0.5.0 peerDependenciesMeta: - '@microsoft/api-extractor': + '@arethetypeswrong/core': + optional: true + '@tsdown/css': optional: true - '@swc/core': + '@tsdown/exe': optional: true - postcss: + '@vitejs/devtools': + optional: true + publint: optional: true typescript: optional: true + unplugin-unused: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} turbo-darwin-64@2.8.9: resolution: {integrity: sha512-KnCw1ZI9KTnEAhdI9avZrnZ/z4wsM++flMA1w8s8PKOqi5daGpFV36qoPafg4S8TmYMe52JPWEoFr0L+lQ5JIw==} @@ -3011,6 +3125,9 @@ packages: ultrahtml@1.6.0: resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} @@ -3060,6 +3177,16 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unrun@0.2.31: + resolution: {integrity: sha512-qltXRUeKQSrIgVS4NbH6PXEFqq+dru2ivH9QINfB+TinSlslgQvursJEV56QzaX8VaDCV5KfbROwKTQf/APJFA==} + engines: {node: '>=20.19.0'} + hasBin: true + peerDependencies: + synckit: ^0.11.11 + peerDependenciesMeta: + synckit: + optional: true + unstorage@1.17.4: resolution: {integrity: sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==} peerDependencies: @@ -3515,6 +3642,15 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@8.0.0-rc.2': + dependencies: + '@babel/parser': 8.0.0-rc.2 + '@babel/types': 8.0.0-rc.2 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.0 @@ -3545,8 +3681,12 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@8.0.0-rc.2': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@8.0.0-rc.2': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.28.6': @@ -3558,6 +3698,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@8.0.0-rc.2': + dependencies: + '@babel/types': 8.0.0-rc.2 + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -3593,6 +3737,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@8.0.0-rc.2': + dependencies: + '@babel/helper-string-parser': 8.0.0-rc.2 + '@babel/helper-validator-identifier': 8.0.0-rc.2 + '@capsizecss/unpack@4.0.0': dependencies: fontkitten: 1.0.3 @@ -3639,11 +3788,22 @@ snapshots: '@ctrl/tinycolor@4.2.0': {} + '@emnapi/core@1.8.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -4044,6 +4204,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/wasm-runtime@1.1.1': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + '@next/env@15.5.12': {} '@next/swc-darwin-arm64@15.5.12': @@ -4072,6 +4239,8 @@ snapshots: '@oslojs/encoding@1.1.0': {} + '@oxc-project/types@0.115.0': {} + '@pagefind/darwin-arm64@1.4.0': optional: true @@ -4104,6 +4273,10 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + '@redocly/ajv@8.17.4': dependencies: fast-deep-equal: 3.1.3 @@ -4127,8 +4300,57 @@ snapshots: transitivePeerDependencies: - supports-color + '@rolldown/binding-android-arm64@1.0.0-rc.8': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.8': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.8': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.8': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.8': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.8': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.8': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.8': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.8': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.8': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.8': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.8': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.8': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.8': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.8': + optional: true + '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rolldown/pluginutils@1.0.0-rc.8': {} + '@rollup/pluginutils@5.3.0(rollup@4.57.1)': dependencies: '@types/estree': 1.0.8 @@ -4361,6 +4583,11 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 19.2.4 + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -4398,6 +4625,8 @@ snapshots: '@types/js-yaml@4.0.9': {} + '@types/jsesc@2.5.1': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -4506,7 +4735,7 @@ snapshots: ansi-styles@6.2.3: {} - any-promise@1.3.0: {} + ansis@4.2.0: {} anymatch@3.1.3: dependencies: @@ -4525,6 +4754,12 @@ snapshots: assertion-error@2.0.1: {} + ast-kit@3.0.0-beta.1: + dependencies: + '@babel/parser': 8.0.0-rc.2 + estree-walker: 3.0.3 + pathe: 2.0.3 + astring@1.9.0: {} astro-expressive-code@0.41.7(astro@5.18.0(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.57.1)(typescript@5.9.3)(yaml@2.8.2)): @@ -4652,6 +4887,8 @@ snapshots: is-alphanumerical: 2.0.1 is-decimal: 2.0.1 + birpc@4.0.0: {} + blake3-wasm@2.1.5: {} boolbase@1.0.0: {} @@ -4679,13 +4916,10 @@ snapshots: node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) - bundle-require@5.1.0(esbuild@0.27.3): - dependencies: - esbuild: 0.27.3 - load-tsconfig: 0.2.5 - cac@6.7.14: {} + cac@7.0.0: {} + camelcase@8.0.0: {} caniuse-lite@1.0.30001774: {} @@ -4714,10 +4948,6 @@ snapshots: check-error@2.1.3: {} - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -4738,8 +4968,6 @@ snapshots: commander@11.1.0: {} - commander@4.1.1: {} - comment-json@4.5.1: dependencies: array-timsort: 1.0.3 @@ -4748,10 +4976,6 @@ snapshots: common-ancestor-path@1.0.1: {} - confbox@0.1.8: {} - - consola@3.4.2: {} - convert-source-map@2.0.0: {} cookie-es@1.2.2: {} @@ -4850,12 +5074,16 @@ snapshots: dset@3.1.4: {} + dts-resolver@2.1.3: {} + electron-to-chromium@1.5.307: {} emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} + empathic@2.0.0: {} + enhanced-resolve@5.20.0: dependencies: graceful-fs: 4.2.11 @@ -5029,12 +5257,6 @@ snapshots: optionalDependencies: picomatch: 4.0.3 - fix-dts-default-cjs-exports@1.0.1: - dependencies: - magic-string: 0.30.21 - mlly: 1.8.0 - rollup: 4.57.1 - flattie@1.1.1: {} fontace@0.4.1: @@ -5052,6 +5274,10 @@ snapshots: get-east-asian-width@1.5.0: {} + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + github-slugger@2.0.0: {} graceful-fs@4.2.11: {} @@ -5257,6 +5483,8 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + hookable@6.0.1: {} + html-escaper@3.0.3: {} html-void-elements@3.0.0: {} @@ -5278,6 +5506,8 @@ snapshots: import-meta-resolve@4.2.0: {} + import-without-cache@0.2.5: {} + index-to-position@1.2.0: {} inline-style-parser@0.2.7: {} @@ -5311,8 +5541,6 @@ snapshots: jiti@2.6.1: {} - joycon@3.1.1: {} - js-levenshtein@1.1.6: {} js-tokens@4.0.0: {} @@ -5382,12 +5610,6 @@ snapshots: lightningcss-win32-arm64-msvc: 1.31.1 lightningcss-win32-x64-msvc: 1.31.1 - lilconfig@3.1.3: {} - - lines-and-columns@1.2.4: {} - - load-tsconfig@0.2.5: {} - longest-streak@3.1.0: {} loupe@3.2.1: {} @@ -5889,23 +6111,10 @@ snapshots: dependencies: brace-expansion: 2.0.2 - mlly@1.8.0: - dependencies: - acorn: 8.15.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.3 - mrmime@2.0.1: {} ms@2.1.3: {} - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - nanoid@3.3.11: {} neotraverse@0.6.18: {} @@ -5949,7 +6158,7 @@ snapshots: dependencies: boolbase: 1.0.0 - object-assign@4.1.1: {} + obug@2.1.1: {} ofetch@1.5.1: dependencies: @@ -6056,24 +6265,8 @@ snapshots: picomatch@4.0.3: {} - pirates@4.0.7: {} - - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.8.0 - pathe: 2.0.3 - pluralize@8.0.0: {} - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(yaml@2.8.2): - dependencies: - lilconfig: 3.1.3 - optionalDependencies: - jiti: 2.6.1 - postcss: 8.5.6 - yaml: 2.8.2 - postcss-nested@6.2.0(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -6105,6 +6298,8 @@ snapshots: property-information@7.1.0: {} + quansync@1.0.0: {} + radix3@1.1.2: {} react-dom@19.2.4(react@19.2.4): @@ -6116,8 +6311,6 @@ snapshots: react@19.2.4: {} - readdirp@4.1.2: {} - readdirp@5.0.0: {} recma-build-jsx@1.0.0: @@ -6260,7 +6453,7 @@ snapshots: require-from-string@2.0.2: {} - resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} retext-latin@4.0.0: dependencies: @@ -6287,6 +6480,44 @@ snapshots: retext-stringify: 4.0.0 unified: 11.0.5 + rolldown-plugin-dts@0.22.4(rolldown@1.0.0-rc.8)(typescript@5.9.3): + dependencies: + '@babel/generator': 8.0.0-rc.2 + '@babel/helper-validator-identifier': 8.0.0-rc.2 + '@babel/parser': 8.0.0-rc.2 + '@babel/types': 8.0.0-rc.2 + ast-kit: 3.0.0-beta.1 + birpc: 4.0.0 + dts-resolver: 2.1.3 + get-tsconfig: 4.13.6 + obug: 2.1.1 + rolldown: 1.0.0-rc.8 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.0.0-rc.8: + dependencies: + '@oxc-project/types': 0.115.0 + '@rolldown/pluginutils': 1.0.0-rc.8 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.8 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.8 + '@rolldown/binding-darwin-x64': 1.0.0-rc.8 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.8 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.8 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.8 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.8 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.8 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.8 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.8 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.8 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.8 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.8 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.8 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.8 + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -6431,16 +6662,6 @@ snapshots: client-only: 0.0.1 react: 19.2.4 - sucrase@3.35.1: - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - commander: 4.1.1 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.7 - tinyglobby: 0.2.15 - ts-interface-checker: 0.1.13 - supports-color@10.2.2: {} svgo@4.0.1: @@ -6457,14 +6678,6 @@ snapshots: tapable@2.3.0: {} - thenify-all@1.6.0: - dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - tiny-inflate@1.0.3: {} tinybench@2.9.0: {} @@ -6490,41 +6703,38 @@ snapshots: trough@2.2.0: {} - ts-interface-checker@0.1.13: {} - tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 - tslib@2.8.1: {} - - tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.2): + tsdown@0.21.1(typescript@5.9.3): dependencies: - bundle-require: 5.1.0(esbuild@0.27.3) - cac: 6.7.14 - chokidar: 4.0.3 - consola: 3.4.2 - debug: 4.4.3(supports-color@10.2.2) - esbuild: 0.27.3 - fix-dts-default-cjs-exports: 1.0.1 - joycon: 3.1.1 - picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(yaml@2.8.2) - resolve-from: 5.0.0 - rollup: 4.57.1 - source-map: 0.7.6 - sucrase: 3.35.1 - tinyexec: 0.3.2 + ansis: 4.2.0 + cac: 7.0.0 + defu: 6.1.4 + empathic: 2.0.0 + hookable: 6.0.1 + import-without-cache: 0.2.5 + obug: 2.1.1 + picomatch: 4.0.3 + rolldown: 1.0.0-rc.8 + rolldown-plugin-dts: 0.22.4(rolldown@1.0.0-rc.8)(typescript@5.9.3) + semver: 7.7.4 + tinyexec: 1.0.2 tinyglobby: 0.2.15 tree-kill: 1.2.2 + unconfig-core: 7.5.0 + unrun: 0.2.31 optionalDependencies: - postcss: 8.5.6 typescript: 5.9.3 transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - synckit + - vue-tsc + + tslib@2.8.1: {} turbo-darwin-64@2.8.9: optional: true @@ -6561,6 +6771,11 @@ snapshots: ultrahtml@1.6.0: {} + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + uncrypto@0.1.3: {} undici-types@7.16.0: {} @@ -6633,6 +6848,10 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unrun@0.2.31: + dependencies: + rolldown: 1.0.0-rc.8 + unstorage@1.17.4: dependencies: anymatch: 3.1.3 diff --git a/schema.go b/schema.go index 5700a08..5d62ae9 100644 --- a/schema.go +++ b/schema.go @@ -32,6 +32,7 @@ type schemaInput struct { staticHeaders []staticResponseHeader contentType string responseSchemaType reflect.Type + eventVariants []EventVariant // SSE event variants for oneOf schema } func (a *API) updateSchema(si schemaInput) error { @@ -142,7 +143,60 @@ func (a *API) updateSchema(si schemaInput) error { resp := &openapi3.Response{ Description: new(http.StatusText(si.status)), } - if si.responseSchemaType != nil { + if len(si.eventVariants) > 0 { + // SSE with multiple event types — generate oneOf + discriminator. + var oneOf openapi3.SchemaRefs + for _, ev := range si.eventVariants { + payloadSchema, err := a.generateSchemaRef(ev.eventPayloadType()) + if err != nil { + return err + } + var dataRef *openapi3.SchemaRef + if payloadSchema != nil && payloadSchema.Ref != "" && len(payloadSchema.Value.Properties) > 0 { + a.spec.Components.Schemas[payloadSchema.Ref] = &openapi3.SchemaRef{ + Value: payloadSchema.Value, + } + dataRef = &openapi3.SchemaRef{ + Ref: fmt.Sprintf("#/components/schemas/%s", payloadSchema.Ref), + } + } else if payloadSchema != nil { + a.registerNestedSchemas(payloadSchema) + dataRef = payloadSchema + } else { + dataRef = &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}, + } + } + wrapper := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{"event", "data"}, + Properties: openapi3.Schemas{ + "event": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: []any{ev.eventName()}, + }, + }, + "data": dataRef, + }, + }, + } + oneOf = append(oneOf, wrapper) + } + resp.Content = map[string]*openapi3.MediaType{ + si.contentType: { + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + OneOf: oneOf, + Discriminator: &openapi3.Discriminator{ + PropertyName: "event", + }, + }, + }, + }, + } + } else if si.responseSchemaType != nil { responseSchema, err := a.generateSchemaRef(si.responseSchemaType) if err != nil { return err diff --git a/sse.go b/sse.go new file mode 100644 index 0000000..d131a80 --- /dev/null +++ b/sse.go @@ -0,0 +1,108 @@ +package shiftapi + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" +) + +// SSEHandlerFunc is a handler function for Server-Sent Events. It receives +// the parsed input and a typed [SSEWriter] for sending events to the client. +// +// The handler should send events via [SSEWriter.Send] or [SSEWriter.SendEvent] +// and return nil when the stream is complete. If the handler returns an error +// before any events have been sent, a JSON error response is written. If the +// error occurs after events have been sent the error is logged (the response +// has already started). +type SSEHandlerFunc[In, Event any] func(r *http.Request, in In, sse *SSEWriter[Event]) error + +// SSEWriter writes typed Server-Sent Events to the client. It is created +// internally by [HandleSSE] and should not be constructed directly. +// +// On the first call to [Send] or [SendEvent], SSEWriter sets the required SSE +// headers (Content-Type, Cache-Control, Connection) before writing data. +type SSEWriter[Event any] struct { + w http.ResponseWriter + rc *http.ResponseController + started bool +} + +// Send writes a data-only SSE event. The value is JSON-encoded and written as: +// +// data: {json}\n\n +// +// The response is flushed after each event. +func (s *SSEWriter[Event]) Send(v Event) error { + s.writeHeaders() + data, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("shiftapi: SSE marshal error: %w", err) + } + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + return fmt.Errorf("shiftapi: SSE write error: %w", err) + } + return s.rc.Flush() +} + +// SendEvent writes a named SSE event. The value is JSON-encoded and written as: +// +// event: {name}\ndata: {json}\n\n +// +// The response is flushed after each event. +func (s *SSEWriter[Event]) SendEvent(event string, v Event) error { + s.writeHeaders() + data, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("shiftapi: SSE marshal error: %w", err) + } + if _, err := fmt.Fprintf(s.w, "event: %s\ndata: %s\n\n", event, data); err != nil { + return fmt.Errorf("shiftapi: SSE write error: %w", err) + } + return s.rc.Flush() +} + +// writeHeaders sets SSE headers on the first write. +func (s *SSEWriter[Event]) writeHeaders() { + if s.started { + return + } + s.started = true + h := s.w.Header() + h.Set("Content-Type", "text/event-stream") + h.Set("Cache-Control", "no-cache") + h.Set("Connection", "keep-alive") +} + +// EventVariant describes a named SSE event type for OpenAPI schema generation. +// Created by [EventType] and passed to [WithEvents]. +type EventVariant interface { + eventName() string + eventPayloadType() reflect.Type +} + +type eventVariant[T any] struct { + name string +} + +func (e eventVariant[T]) eventName() string { return e.name } +func (e eventVariant[T]) eventPayloadType() reflect.Type { return reflect.TypeFor[T]() } + +// EventType creates an [EventVariant] that maps an SSE event name to a payload +// type T. Use with [WithEvents] to register discriminated event types for a +// single SSE endpoint. The OpenAPI spec will contain a oneOf schema with a +// discriminator, and the generated TypeScript client will yield a discriminated +// union type. +// +// shiftapi.HandleSSE(api, "GET /chat", chatHandler, +// shiftapi.WithEvents( +// shiftapi.EventType[MessageData]("message"), +// shiftapi.EventType[JoinData]("join"), +// ), +// ) +func EventType[T any](name string) EventVariant { + if name == "" { + panic("shiftapi: EventType name must not be empty") + } + return eventVariant[T]{name: name} +} diff --git a/sse_test.go b/sse_test.go new file mode 100644 index 0000000..386ccb8 --- /dev/null +++ b/sse_test.go @@ -0,0 +1,442 @@ +package shiftapi_test + +import ( + "bufio" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/fcjr/shiftapi" +) + +type sseMessage struct { + Text string `json:"text"` +} + +func TestSSEWriter_Send(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[sseMessage]) error { + if err := sse.Send(sseMessage{Text: "hello"}); err != nil { + return err + } + return sse.Send(sseMessage{Text: "world"}) + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/events", nil) + api.ServeHTTP(w, r) + + if w.Header().Get("Content-Type") != "text/event-stream" { + t.Errorf("Content-Type = %q, want %q", w.Header().Get("Content-Type"), "text/event-stream") + } + if w.Header().Get("Cache-Control") != "no-cache" { + t.Errorf("Cache-Control = %q, want %q", w.Header().Get("Cache-Control"), "no-cache") + } + if w.Header().Get("Connection") != "keep-alive" { + t.Errorf("Connection = %q, want %q", w.Header().Get("Connection"), "keep-alive") + } + + events := parseSSEEvents(t, w.Body.String()) + if len(events) != 2 { + t.Fatalf("got %d events, want 2", len(events)) + } + if events[0].Data != `{"text":"hello"}` { + t.Errorf("event[0].Data = %q, want %q", events[0].Data, `{"text":"hello"}`) + } + if events[1].Data != `{"text":"world"}` { + t.Errorf("event[1].Data = %q, want %q", events[1].Data, `{"text":"world"}`) + } +} + +func TestSSEWriter_SendEvent(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[sseMessage]) error { + return sse.SendEvent("msg", sseMessage{Text: "named"}) + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/events", nil) + api.ServeHTTP(w, r) + + events := parseSSEEvents(t, w.Body.String()) + if len(events) != 1 { + t.Fatalf("got %d events, want 1", len(events)) + } + if events[0].Event != "msg" { + t.Errorf("event[0].Event = %q, want %q", events[0].Event, "msg") + } + if events[0].Data != `{"text":"named"}` { + t.Errorf("event[0].Data = %q, want %q", events[0].Data, `{"text":"named"}`) + } +} + +func TestHandleSSE_InputParsing(t *testing.T) { + api := shiftapi.New() + + type Input struct { + Channel string `query:"channel" validate:"required"` + } + + shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, in Input, sse *shiftapi.SSEWriter[sseMessage]) error { + return sse.Send(sseMessage{Text: "channel=" + in.Channel}) + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/events?channel=general", nil) + api.ServeHTTP(w, r) + + events := parseSSEEvents(t, w.Body.String()) + if len(events) != 1 { + t.Fatalf("got %d events, want 1", len(events)) + } + if events[0].Data != `{"text":"channel=general"}` { + t.Errorf("event[0].Data = %q, want %q", events[0].Data, `{"text":"channel=general"}`) + } +} + +func TestHandleSSE_InputValidationError(t *testing.T) { + api := shiftapi.New() + + type Input struct { + Channel string `query:"channel" validate:"required"` + } + + shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, in Input, sse *shiftapi.SSEWriter[sseMessage]) error { + return sse.Send(sseMessage{Text: "should not reach"}) + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/events", nil) // missing required channel + api.ServeHTTP(w, r) + + if w.Code != http.StatusUnprocessableEntity { + t.Errorf("status = %d, want %d", w.Code, http.StatusUnprocessableEntity) + } +} + +func TestHandleSSE_ErrorBeforeWrite(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[sseMessage]) error { + return fmt.Errorf("something went wrong") + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/events", nil) + api.ServeHTTP(w, r) + + if w.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want %d", w.Code, http.StatusInternalServerError) + } +} + +func TestHandleSSE_PathParams(t *testing.T) { + api := shiftapi.New() + + type Input struct { + ID string `path:"id"` + } + + shiftapi.HandleSSE(api, "GET /streams/{id}", func(r *http.Request, in Input, sse *shiftapi.SSEWriter[sseMessage]) error { + return sse.Send(sseMessage{Text: "id=" + in.ID}) + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/streams/abc", nil) + api.ServeHTTP(w, r) + + events := parseSSEEvents(t, w.Body.String()) + if len(events) != 1 { + t.Fatalf("got %d events, want 1", len(events)) + } + if events[0].Data != `{"text":"id=abc"}` { + t.Errorf("event[0].Data = %q, want %q", events[0].Data, `{"text":"id=abc"}`) + } +} + +func TestHandleSSE_OpenAPISpec(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[sseMessage]) error { + return nil + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/openapi.json", nil) + api.ServeHTTP(w, r) + + var spec map[string]any + if err := json.NewDecoder(w.Body).Decode(&spec); err != nil { + t.Fatalf("decode spec: %v", err) + } + + paths, ok := spec["paths"].(map[string]any) + if !ok { + t.Fatal("no paths in spec") + } + eventsPath, ok := paths["/events"].(map[string]any) + if !ok { + t.Fatal("no /events path in spec") + } + getOp, ok := eventsPath["get"].(map[string]any) + if !ok { + t.Fatal("no GET operation on /events") + } + responses, ok := getOp["responses"].(map[string]any) + if !ok { + t.Fatal("no responses in GET /events") + } + resp200, ok := responses["200"].(map[string]any) + if !ok { + t.Fatal("no 200 response in GET /events") + } + content, ok := resp200["content"].(map[string]any) + if !ok { + t.Fatal("no content in 200 response") + } + if _, ok := content["text/event-stream"]; !ok { + t.Error("200 response missing text/event-stream content type") + } +} + +// --- Multi-event (WithEvents) tests --- + +type chatEvent interface{ chatEvent() } + +type messageData struct { + User string `json:"user"` + Text string `json:"text"` +} + +func (messageData) chatEvent() {} + +type joinData struct { + User string `json:"user"` +} + +func (joinData) chatEvent() {} + +func TestHandleSSE_WithEvents_SendEvent(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleSSE(api, "GET /chat", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[chatEvent]) error { + if err := sse.SendEvent("message", messageData{User: "alice", Text: "hi"}); err != nil { + return err + } + return sse.SendEvent("join", joinData{User: "bob"}) + }, shiftapi.WithEvents( + shiftapi.EventType[messageData]("message"), + shiftapi.EventType[joinData]("join"), + )) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/chat", nil) + api.ServeHTTP(w, r) + + if w.Header().Get("Content-Type") != "text/event-stream" { + t.Errorf("Content-Type = %q, want %q", w.Header().Get("Content-Type"), "text/event-stream") + } + + events := parseSSEEvents(t, w.Body.String()) + if len(events) != 2 { + t.Fatalf("got %d events, want 2", len(events)) + } + if events[0].Event != "message" { + t.Errorf("event[0].Event = %q, want %q", events[0].Event, "message") + } + if events[0].Data != `{"user":"alice","text":"hi"}` { + t.Errorf("event[0].Data = %q, want %q", events[0].Data, `{"user":"alice","text":"hi"}`) + } + if events[1].Event != "join" { + t.Errorf("event[1].Event = %q, want %q", events[1].Event, "join") + } + if events[1].Data != `{"user":"bob"}` { + t.Errorf("event[1].Data = %q, want %q", events[1].Data, `{"user":"bob"}`) + } +} + +func TestHandleSSE_WithEvents_OpenAPISpec(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleSSE(api, "GET /chat", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[chatEvent]) error { + return nil + }, shiftapi.WithEvents( + shiftapi.EventType[messageData]("message"), + shiftapi.EventType[joinData]("join"), + )) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/openapi.json", nil) + api.ServeHTTP(w, r) + + var spec map[string]any + if err := json.NewDecoder(w.Body).Decode(&spec); err != nil { + t.Fatalf("decode spec: %v", err) + } + + // Navigate to the text/event-stream schema + paths := spec["paths"].(map[string]any) + chatPath := paths["/chat"].(map[string]any) + getOp := chatPath["get"].(map[string]any) + responses := getOp["responses"].(map[string]any) + resp200 := responses["200"].(map[string]any) + content := resp200["content"].(map[string]any) + sse := content["text/event-stream"].(map[string]any) + schema := sse["schema"].(map[string]any) + + // Should have oneOf + oneOf, ok := schema["oneOf"].([]any) + if !ok { + t.Fatal("schema missing oneOf") + } + if len(oneOf) != 2 { + t.Fatalf("oneOf has %d items, want 2", len(oneOf)) + } + + // Should have discriminator + disc, ok := schema["discriminator"].(map[string]any) + if !ok { + t.Fatal("schema missing discriminator") + } + if disc["propertyName"] != "event" { + t.Errorf("discriminator.propertyName = %q, want %q", disc["propertyName"], "event") + } + + // Check first variant (message) + v0 := oneOf[0].(map[string]any) + v0Props := v0["properties"].(map[string]any) + v0Event := v0Props["event"].(map[string]any) + v0Enum := v0Event["enum"].([]any) + if len(v0Enum) != 1 || v0Enum[0] != "message" { + t.Errorf("variant 0 event enum = %v, want [message]", v0Enum) + } + v0Required := v0["required"].([]any) + if len(v0Required) != 2 { + t.Errorf("variant 0 required = %v, want [event data]", v0Required) + } + + // Check second variant (join) + v1 := oneOf[1].(map[string]any) + v1Props := v1["properties"].(map[string]any) + v1Event := v1Props["event"].(map[string]any) + v1Enum := v1Event["enum"].([]any) + if len(v1Enum) != 1 || v1Enum[0] != "join" { + t.Errorf("variant 1 event enum = %v, want [join]", v1Enum) + } +} + +func TestHandleSSE_SingleEvent_StillWorks(t *testing.T) { + // Verify that single-event HandleSSE (no WithEvents) is unchanged. + api := shiftapi.New() + + shiftapi.HandleSSE(api, "GET /ticks", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[sseMessage]) error { + return sse.Send(sseMessage{Text: "tick"}) + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/ticks", nil) + api.ServeHTTP(w, r) + + events := parseSSEEvents(t, w.Body.String()) + if len(events) != 1 { + t.Fatalf("got %d events, want 1", len(events)) + } + if events[0].Data != `{"text":"tick"}` { + t.Errorf("event[0].Data = %q, want %q", events[0].Data, `{"text":"tick"}`) + } + + // Verify spec still uses single schema (not oneOf) + w2 := httptest.NewRecorder() + r2 := httptest.NewRequest("GET", "/openapi.json", nil) + api.ServeHTTP(w2, r2) + + var spec map[string]any + if err := json.NewDecoder(w2.Body).Decode(&spec); err != nil { + t.Fatalf("decode spec: %v", err) + } + + paths := spec["paths"].(map[string]any) + ticksPath := paths["/ticks"].(map[string]any) + getOp := ticksPath["get"].(map[string]any) + responses := getOp["responses"].(map[string]any) + resp200 := responses["200"].(map[string]any) + content := resp200["content"].(map[string]any) + sse := content["text/event-stream"].(map[string]any) + schema := sse["schema"].(map[string]any) + + // Should NOT have oneOf + if _, ok := schema["oneOf"]; ok { + t.Error("single-event schema should not have oneOf") + } +} + +func TestEventType_EmptyNamePanics(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic for empty event name") + } + msg, ok := r.(string) + if !ok || !strings.Contains(msg, "must not be empty") { + t.Errorf("unexpected panic message: %v", r) + } + }() + shiftapi.EventType[sseMessage]("") +} + +func TestWithEvents_DuplicateNamePanics(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic for duplicate event name") + } + msg, ok := r.(string) + if !ok || !strings.Contains(msg, "duplicate event name") { + t.Errorf("unexpected panic message: %v", r) + } + }() + api := shiftapi.New() + shiftapi.HandleSSE(api, "GET /dup", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[chatEvent]) error { + return nil + }, shiftapi.WithEvents( + shiftapi.EventType[messageData]("same"), + shiftapi.EventType[joinData]("same"), + )) +} + +// sseEvent represents a parsed SSE event. +type sseEvent struct { + Event string + Data string +} + +// parseSSEEvents parses SSE-formatted text into events. +func parseSSEEvents(t *testing.T, body string) []sseEvent { + t.Helper() + var events []sseEvent + scanner := bufio.NewScanner(strings.NewReader(body)) + var current sseEvent + var dataLines []string + for scanner.Scan() { + line := scanner.Text() + switch { + case strings.HasPrefix(line, "event: "): + current.Event = line[7:] + case strings.HasPrefix(line, "data: "): + dataLines = append(dataLines, line[6:]) + case line == "": + if len(dataLines) > 0 { + current.Data = strings.Join(dataLines, "\n") + events = append(events, current) + current = sseEvent{} + dataLines = nil + } + } + } + return events +} From fd38b28905192181d427e29df7781bb8b5ff7eda Mon Sep 17 00:00:00 2001 From: "Frank Chiarulli Jr." Date: Mon, 9 Mar 2026 19:07:12 -0400 Subject: [PATCH 02/20] first party SSE --- apps/landing/src/components/Features.tsx | 4 +- .../docs/docs/core-concepts/raw-handlers.mdx | 14 +- .../docs/docs/core-concepts/websockets.mdx | 487 ++++++++++++++ .../src/content/docs/docs/frontend/nextjs.mdx | 18 + .../src/content/docs/docs/frontend/vite.mdx | 18 + .../docs/getting-started/introduction.mdx | 1 + asyncapi.go | 226 +++++++ docs.go | 36 +- example_test.go | 87 +++ examples/greeter/main.go | 33 + go.mod | 3 + go.sum | 6 + handler.go | 48 ++ handlerFuncs.go | 93 +++ handlerOptions.go | 74 +++ package-lock.json | 126 ++++ packages/next/src/index.ts | 5 +- .../shiftapi/src/__tests__/generate.test.ts | 3 +- packages/shiftapi/src/codegen.ts | 16 +- packages/shiftapi/src/extract.ts | 39 +- packages/shiftapi/src/internal.ts | 5 +- packages/shiftapi/src/templates.ts | 138 +++- packages/shiftapi/src/websocket.ts | 141 +++++ packages/vite-plugin/src/index.ts | 10 +- serve.go | 2 + serve_dev.go | 17 +- server.go | 31 + ws.go | 158 +++++ ws_test.go | 595 ++++++++++++++++++ 29 files changed, 2389 insertions(+), 45 deletions(-) create mode 100644 apps/landing/src/content/docs/docs/core-concepts/websockets.mdx create mode 100644 asyncapi.go create mode 100644 package-lock.json create mode 100644 packages/shiftapi/src/websocket.ts create mode 100644 ws.go create mode 100644 ws_test.go diff --git a/apps/landing/src/components/Features.tsx b/apps/landing/src/components/Features.tsx index 2f6749e..54669ce 100644 --- a/apps/landing/src/components/Features.tsx +++ b/apps/landing/src/components/Features.tsx @@ -26,8 +26,8 @@ const features = [ }, { icon: , - title: "Server-Sent Events", - desc: <>HandleSSE gives you a typed writer with auto headers. The TypeScript client gets a typed subscribe helper plus React and Svelte hooks., + title: "Real-Time", + desc: <>HandleSSE gives you typed server push with a subscribe helper. HandleWS adds typed bidirectional WebSockets with a connect helper., }, ]; diff --git a/apps/landing/src/content/docs/docs/core-concepts/raw-handlers.mdx b/apps/landing/src/content/docs/docs/core-concepts/raw-handlers.mdx index 324069b..73b6935 100644 --- a/apps/landing/src/content/docs/docs/core-concepts/raw-handlers.mdx +++ b/apps/landing/src/content/docs/docs/core-concepts/raw-handlers.mdx @@ -5,14 +5,15 @@ sidebar: order: 7 --- -ShiftAPI's `Handle` function owns the response lifecycle — it JSON-encodes whatever your handler returns. But some responses can't be expressed as a typed struct: Server-Sent Events, file downloads, WebSocket upgrades, chunked streaming, and more. `HandleRaw` gives you full control over the `http.ResponseWriter` while keeping typed input parsing, validation, and middleware. +ShiftAPI's `Handle` function owns the response lifecycle — it JSON-encodes whatever your handler returns. But some responses can't be expressed as a typed struct: file downloads, chunked streaming, and more. `HandleRaw` gives you full control over the `http.ResponseWriter` while keeping typed input parsing, validation, and middleware. + +For SSE and WebSocket use cases, prefer [`HandleSSE`](/docs/core-concepts/server-sent-events) and [`HandleWS`](/docs/core-concepts/websockets) which provide typed abstractions. Use `HandleRaw` when you need custom framing or non-standard behavior. ## Registering raw handlers ```go shiftapi.HandleRaw(api, "GET /events", sseHandler) shiftapi.HandleRaw(api, "GET /files/{id}", downloadHandler) -shiftapi.HandleRaw(api, "GET /ws", wsHandler) ``` ## Handler signature @@ -186,10 +187,10 @@ The schema is generated using the same reflection logic as typed handlers — st ### No WithContentType (default) -A `HandleRaw` route with no `WithContentType` produces a response with only a description. This is appropriate for WebSocket upgrades or other routes where the response format isn't meaningful to document: +A `HandleRaw` route with no `WithContentType` produces a response with only a description. This is appropriate for routes where the response format isn't meaningful to document: ```go -shiftapi.HandleRaw(api, "GET /ws", wsHandler) +shiftapi.HandleRaw(api, "GET /proxy", proxyHandler) ``` Produces: @@ -237,15 +238,16 @@ shiftapi.HandleRaw(api, "GET /events", sseHandler, | `WithMiddleware(mw...)` | Apply HTTP middleware | | `WithResponseHeader(name, value)` | Set a static response header | -## When to use HandleRaw vs Handle vs HandleSSE +## When to use HandleRaw vs Handle vs HandleSSE vs HandleWS | Use case | Recommendation | |----------|---------------| | JSON API endpoint | `Handle` — let the framework encode the response | | Server-Sent Events (SSE) | [`HandleSSE`](/docs/core-concepts/server-sent-events) — typed writer, auto headers, typed TS client | +| Bidirectional WebSocket | [`HandleWS`](/docs/core-concepts/websockets) — typed send/receive, auto upgrade, typed TS client | | SSE with custom framing | `HandleRaw` + `WithContentType("text/event-stream")` | | File download | `HandleRaw` + `WithContentType("application/octet-stream")` | -| WebSocket upgrade | `HandleRaw` (no `WithContentType` needed) | +| Custom WebSocket framing | `HandleRaw` (no `WithContentType` needed) | | Streaming response | `HandleRaw` with `Flusher` access | | Proxy / passthrough | `HandleRaw` with `struct{}` input to preserve `r.Body` | | JSON but custom content type | `Handle` + `WithContentType("application/vnd.api+json")` | diff --git a/apps/landing/src/content/docs/docs/core-concepts/websockets.mdx b/apps/landing/src/content/docs/docs/core-concepts/websockets.mdx new file mode 100644 index 0000000..0c6186c --- /dev/null +++ b/apps/landing/src/content/docs/docs/core-concepts/websockets.mdx @@ -0,0 +1,487 @@ +--- +title: WebSockets +description: Use HandleWS for type-safe bidirectional WebSocket communication with automatic OpenAPI spec generation and typed TypeScript clients. +sidebar: + order: 9 +--- + +`HandleWS` is a dedicated handler for WebSocket connections that gives you a typed bidirectional connection, automatic upgrade handling, and end-to-end type safety from Go to TypeScript — including a generated `websocket` helper. + +## Registering WebSocket handlers + +```go +shiftapi.HandleWS(api, "GET /chat", func(r *http.Request, _ struct{}, ws *shiftapi.WSConn[ServerMsg, ClientMsg]) error { + ctx := r.Context() + for { + msg, err := ws.Receive(ctx) + if shiftapi.WSCloseStatus(err) == shiftapi.WSStatusNormalClosure { + return nil + } + if err != nil { + return err + } + if err := ws.Send(ctx, ServerMsg{Text: "echo: " + msg.Text}); err != nil { + return err + } + } +}) +``` + +## Handler signature + +A WebSocket handler has three type parameters — input, server-to-client message, and client-to-server message: + +```go +func handler(r *http.Request, in InputType, ws *shiftapi.WSConn[Send, Recv]) error +``` + +- **`r *http.Request`** — the standard HTTP request (available before and after upgrade) +- **`in InputType`** — automatically decoded from path, query, and header — identical to `Handle` +- **`ws *shiftapi.WSConn[Send, Recv]`** — typed bidirectional connection +- **`error`** — return an error to close the connection + +Use `struct{}` when the handler takes no input: + +```go +shiftapi.HandleWS(api, "GET /ws", func(r *http.Request, _ struct{}, ws *shiftapi.WSConn[ServerMsg, ClientMsg]) error { + // ... +}) +``` + +## WSConn + +`WSConn[Send, Recv]` provides methods for bidirectional communication: + +### Send + +Writes a JSON-encoded message to the client: + +```go +ws.Send(ctx, ServerMsg{Text: "hello"}) +``` + +### Receive + +Reads and JSON-decodes a message from the client: + +```go +msg, err := ws.Receive(ctx) +``` + +### Close + +Closes the connection with a status code and reason: + +```go +ws.Close(shiftapi.WSStatusNormalClosure, "done") +``` + +## Input parsing + +Input decoding works identically to `Handle` — `path`, `query`, and `header` struct tags and validation rules all apply. Input is parsed **before** the WebSocket upgrade, so validation errors produce normal JSON error responses: + +```go +type ChatInput struct { + Room string `path:"room" validate:"required"` + Token string `query:"token" validate:"required"` +} + +shiftapi.HandleWS(api, "GET /rooms/{room}", func(r *http.Request, in ChatInput, ws *shiftapi.WSConn[ServerMsg, ClientMsg]) error { + // in.Room and in.Token are parsed and validated before upgrade + // ... +}) +``` + +## Error handling + +Error behavior depends on whether the WebSocket upgrade has occurred: + +- **Before upgrade** — if input parsing or validation fails, a JSON error response is written (just like `Handle`). +- **After upgrade** — if the handler returns an error, the connection is closed with `WSStatusInternalError` and the error is logged. +- **WebSocket close** — if the handler returns an error that is already a WebSocket close (e.g. from `ws.Receive` when the client disconnects), the framework recognizes it and does not double-close. + +```go +shiftapi.HandleWS(api, "GET /ws", func(r *http.Request, _ struct{}, ws *shiftapi.WSConn[ServerMsg, ClientMsg]) error { + ctx := r.Context() + for { + msg, err := ws.Receive(ctx) + if shiftapi.WSCloseStatus(err) == shiftapi.WSStatusNormalClosure { + return nil // clean close — no error logged + } + if err != nil { + return err // connection closed with WSStatusInternalError + } + if err := ws.Send(ctx, ServerMsg{Text: msg.Text}); err != nil { + return err + } + } +}, shiftapi.WithError[*AuthError](http.StatusUnauthorized)) +``` + +## AsyncAPI spec + +WebSocket endpoints are documented in an [AsyncAPI 2.4](https://www.asyncapi.com/) spec served at `GET /asyncapi.json`. Each `HandleWS` call creates a channel with `subscribe` (server→client) and `publish` (client→server) operations: + +```go +type ServerMsg struct { + Text string `json:"text"` +} + +type ClientMsg struct { + Text string `json:"text"` +} + +shiftapi.HandleWS(api, "GET /chat", chatHandler) +``` + +Produces: + +```yaml +asyncapi: 2.4.0 +defaultContentType: application/json +channels: + /chat: + subscribe: + message: + name: ServerMsg + payload: + $ref: '#/components/schemas/ServerMsg' + publish: + message: + name: ClientMsg + payload: + $ref: '#/components/schemas/ClientMsg' +components: + schemas: + ServerMsg: + type: object + properties: + text: + type: string + ClientMsg: + type: object + properties: + text: + type: string +``` + +Message schemas are also registered in the OpenAPI spec's `components/schemas` so the TypeScript codegen can generate types from a single `openapi-typescript` pass. + +## TypeScript client + +`shiftapi prepare` generates a fully-typed `websocket` function constrained to WebSocket paths only — calling `websocket` on a non-WebSocket path is a compile-time error. + +### websocket + +`websocket` returns a `WSConnection` with typed `send`, `receive`, async iteration, and `close`: + +```typescript +import { websocket} from "@shiftapi/client"; + +const ws = websocket("/chat", { + params: { query: { token: "abc" } }, +}); + +// Send a message +ws.send({ text: "hello" }); + +// Receive a single message +const msg = await ws.receive(); +console.log(msg); +// ^? ServerMsg — fully typed + +// Or iterate over all messages +for await (const msg of ws) { + console.log(msg.text); +} +``` + +To close the connection: + +```typescript +ws.close(1000, "done"); +``` + +### Path parameters + +Path parameters are type-checked. If your Go handler declares `path:"room"` on the input struct, the generated types require it: + +```typescript +const ws = websocket("/rooms/{room}", { + params: { + path: { room: "general" }, + query: { token: "secret" }, + }, +}); +``` + +### React + +Use `websocket` with React state for a chat-style component: + +```tsx +import { websocket} from "./api"; +import { useEffect, useRef, useState } from "react"; + +function Chat() { + const [messages, setMessages] = useState([]); + const wsRef = useRef> | null>(null); + + useEffect(() => { + const ws = websocket("/chat"); + wsRef.current = ws; + (async () => { + for await (const msg of ws) { + setMessages((prev) => [...prev, msg]); + } + })(); + return () => ws.close(); + }, []); + + const send = (text: string) => { + wsRef.current?.send({ text }); + }; + + return ( +

+
    + {messages.map((msg, i) => ( +
  • {msg.text}
  • + ))} +
+ +
+ ); +} +``` + +## Discriminated union messages + +For endpoints that send or receive multiple message types, use `WithSendMessages` and `WithRecvMessages` to declare each variant. This generates `oneOf` schemas with a `discriminator` on the `type` field, which produces **TypeScript discriminated unions** in the generated client. + +### Define marker interfaces + +Use Go marker interfaces to constrain the type parameters: + +```go +type ServerEvent interface{ serverEvent() } + +type ChatMessage struct { + User string `json:"user"` + Text string `json:"text"` +} +func (ChatMessage) serverEvent() {} + +type SystemMessage struct { + Info string `json:"info"` +} +func (SystemMessage) serverEvent() {} + +type ClientEvent interface{ clientEvent() } + +type UserMessage struct { + Text string `json:"text"` +} +func (UserMessage) clientEvent() {} + +type UserCommand struct { + Command string `json:"command"` +} +func (UserCommand) clientEvent() {} +``` + +### Register with WithSendMessages / WithRecvMessages + +Pass `MessageType[T]` descriptors to declare each named message type: + +```go +shiftapi.HandleWS(api, "GET /chat", func(r *http.Request, _ struct{}, ws *shiftapi.WSConn[ServerEvent, ClientEvent]) error { + ctx := r.Context() + + // Send discriminated messages + if err := ws.SendEvent(ctx, "chat", ChatMessage{User: "alice", Text: "hi"}); err != nil { + return err + } + if err := ws.SendEvent(ctx, "system", SystemMessage{Info: "alice joined"}); err != nil { + return err + } + + // Receive discriminated messages + for { + msg, err := ws.ReceiveEvent(ctx) + if err != nil { + return err + } + switch msg.Type { + case "message": + var m UserMessage + if err := msg.Decode(&m); err != nil { + return err + } + // handle message... + case "command": + var cmd UserCommand + if err := msg.Decode(&cmd); err != nil { + return err + } + // handle command... + } + } +}, shiftapi.WithSendMessages( + shiftapi.MessageType[ChatMessage]("chat"), + shiftapi.MessageType[SystemMessage]("system"), +), shiftapi.WithRecvMessages( + shiftapi.MessageType[UserMessage]("message"), + shiftapi.MessageType[UserCommand]("command"), +)) +``` + +### Wire format + +`SendEvent` wraps the payload in an envelope: + +```json +{"type": "chat", "data": {"user": "alice", "text": "hi"}} +``` + +`ReceiveEvent` expects the same envelope and returns a `WSEvent` with `Type` (the discriminator) and `Data` (raw JSON). Use `msg.Decode(&v)` to unmarshal into the concrete type. + +### Generated AsyncAPI schema + +`WithSendMessages` and `WithRecvMessages` produce `oneOf` message definitions in the AsyncAPI spec. Each variant becomes a named message in `components/messages` with an envelope payload `{type, data}`: + +```yaml +channels: + /chat: + subscribe: + message: + oneOf: + - $ref: '#/components/messages/chat_ChatMessage' + - $ref: '#/components/messages/system_SystemMessage' + publish: + message: + oneOf: + - $ref: '#/components/messages/message_UserMessage' + - $ref: '#/components/messages/command_UserCommand' +components: + messages: + chat_ChatMessage: + name: chat + payload: + $ref: '#/components/schemas/chat_ChatMessage' + schemas: + chat_ChatMessage: + type: object + required: [type, data] + properties: + type: + type: string + enum: [chat] + data: + $ref: '#/components/schemas/ChatMessage' + ChatMessage: + type: object + properties: + user: + type: string + text: + type: string +``` + +### TypeScript discriminated unions + +The generated TypeScript client automatically narrows message types based on the `type` field: + +```typescript +import { websocket} from "@shiftapi/client"; + +const ws = websocket("/chat"); + +for await (const msg of ws) { + if (msg.type === "chat") { + console.log(msg.data.text); + // ^? string — narrowed to ChatMessage + } else if (msg.type === "system") { + console.log(msg.data.info); + // ^? string — narrowed to SystemMessage + } +} +``` + +### Single vs multi-message + +Without `WithSendMessages` / `WithRecvMessages`, `HandleWS` uses the `Send` and `Recv` type parameters directly — no `oneOf`, no discriminator, no envelope wrapping. Use the plain `Send` and `Receive` methods: + +```go +// Single message type per direction — no WithSendMessages/WithRecvMessages needed +shiftapi.HandleWS(api, "GET /echo", func(r *http.Request, _ struct{}, ws *shiftapi.WSConn[ServerMsg, ClientMsg]) error { + msg, err := ws.Receive(ctx) + // ... + return ws.Send(ctx, ServerMsg{Text: msg.Text}) +}) +``` + +Use `WithSendMessages` / `WithRecvMessages` only when your endpoint has multiple distinct message types in a given direction. + +## Origin checking + +By default, `HandleWS` rejects WebSocket connections from different origins. In dev mode (`shiftapidev` build tag, set automatically by the Vite and Next.js plugins), origin checking is disabled so that cross-origin requests from the frontend dev server work without extra configuration. + +In production, you must either serve the frontend and API from the same origin or allow specific origins with `WithWSAcceptOptions`: + +```go +shiftapi.HandleWS(api, "GET /ws", handler, + shiftapi.WithWSAcceptOptions(shiftapi.WSAcceptOptions{ + OriginPatterns: []string{"example.com"}, + }), +) +``` + +## WithWSAcceptOptions + +Use `WithWSAcceptOptions` to configure the WebSocket upgrade — allowed origins, subprotocols, etc.: + +```go +shiftapi.HandleWS(api, "GET /ws", handler, + shiftapi.WithWSAcceptOptions(shiftapi.WSAcceptOptions{ + Subprotocols: []string{"graphql-ws"}, + OriginPatterns: []string{"example.com", "*.example.com"}, + }), +) +``` + +## Route options + +All standard route options work with `HandleWS`: + +```go +shiftapi.HandleWS(api, "GET /ws", handler, + shiftapi.WithRouteInfo(shiftapi.RouteInfo{ + Summary: "WebSocket chat", + Tags: []string{"chat"}, + }), + shiftapi.WithError[*AuthError](http.StatusUnauthorized), + shiftapi.WithMiddleware(auth), + shiftapi.WithWSAcceptOptions(shiftapi.WSAcceptOptions{ + OriginPatterns: []string{"example.com"}, + }), +) +``` + +| Option | Description | +|--------|-------------| +| `WithRouteInfo(info)` | Set OpenAPI summary, description, and tags | +| `WithError[T](code)` | Declare an error type at a status code | +| `WithMiddleware(mw...)` | Apply HTTP middleware | +| `WithWSAcceptOptions(opts)` | Configure WebSocket upgrade (origins, subprotocols) | +| `WithSendMessages(variants...)` | Declare discriminated union server→client message types | +| `WithRecvMessages(variants...)` | Declare discriminated union client→server message types | + +## When to use HandleWS vs HandleSSE vs Handle + +| Use case | Recommendation | +|----------|---------------| +| Bidirectional real-time (chat, games) | `HandleWS` — typed send/receive, auto upgrade | +| Server push only (feeds, notifications) | [`HandleSSE`](/docs/core-concepts/server-sent-events) — simpler, works through proxies | +| JSON API endpoint | `Handle` — let the framework encode the response | +| Custom WebSocket framing | `HandleRaw` — direct `ResponseWriter` control | diff --git a/apps/landing/src/content/docs/docs/frontend/nextjs.mdx b/apps/landing/src/content/docs/docs/frontend/nextjs.mdx index 0190f18..11f5c02 100644 --- a/apps/landing/src/content/docs/docs/frontend/nextjs.mdx +++ b/apps/landing/src/content/docs/docs/frontend/nextjs.mdx @@ -78,3 +78,21 @@ function EventFeed() { ``` See [Server-Sent Events](/docs/core-concepts/server-sent-events) for full details. + +### WebSockets + +The generated client also includes a `connect` function for WebSocket endpoints registered with `HandleWS`. It returns a typed bidirectional connection: + +```typescript +import { connect } from "@shiftapi/client"; + +const ws = connect("/chat"); + +ws.send({ text: "hello" }); + +for await (const msg of ws) { + console.log(msg.text); // fully typed from your Go Send struct +} +``` + +See [WebSockets](/docs/core-concepts/websockets) for full details. diff --git a/apps/landing/src/content/docs/docs/frontend/vite.mdx b/apps/landing/src/content/docs/docs/frontend/vite.mdx index 192e545..24bac6c 100644 --- a/apps/landing/src/content/docs/docs/frontend/vite.mdx +++ b/apps/landing/src/content/docs/docs/frontend/vite.mdx @@ -97,3 +97,21 @@ For Svelte projects, use the `createSSE` store from your API package: ``` See [Server-Sent Events](/docs/core-concepts/server-sent-events) for full details. + +### WebSockets + +The generated client also includes a `connect` function for WebSocket endpoints registered with `HandleWS`. It returns a typed bidirectional connection: + +```typescript +import { connect } from "@shiftapi/client"; + +const ws = connect("/chat"); + +ws.send({ text: "hello" }); + +for await (const msg of ws) { + console.log(msg.text); // fully typed from your Go Send struct +} +``` + +See [WebSockets](/docs/core-concepts/websockets) for full details. diff --git a/apps/landing/src/content/docs/docs/getting-started/introduction.mdx b/apps/landing/src/content/docs/docs/getting-started/introduction.mdx index 3afc64c..9cd4454 100644 --- a/apps/landing/src/content/docs/docs/getting-started/introduction.mdx +++ b/apps/landing/src/content/docs/docs/getting-started/introduction.mdx @@ -22,5 +22,6 @@ ShiftAPI is a Go framework that generates an OpenAPI 3.1 spec from your handler - **Typed HTTP headers** — parse, validate, and document HTTP headers with `header` struct tags - **File uploads** — declare uploads with `form` tags, get correct `multipart/form-data` types - **Server-Sent Events** — `HandleSSE` gives you a typed writer, automatic SSE headers, and a generated `subscribe` helper with React/Svelte hooks +- **WebSockets** — `HandleWS` gives you a typed bidirectional connection with automatic upgrade handling and a generated `connect` helper - **Interactive docs** — Scalar API reference at `/docs` and OpenAPI spec at `/openapi.json` - **Just `net/http`** — implements `http.Handler`, works with any middleware or router diff --git a/asyncapi.go b/asyncapi.go new file mode 100644 index 0000000..81ce62d --- /dev/null +++ b/asyncapi.go @@ -0,0 +1,226 @@ +package shiftapi + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "reflect" + + "github.com/getkin/kin-openapi/openapi3" + spec "github.com/swaggest/go-asyncapi/spec-2.4.0" +) + +// addWSChannel registers a WebSocket endpoint in the AsyncAPI spec. +// It creates a channel with subscribe (server→client) and publish +// (client→server) operations, and registers schemas in both the +// AsyncAPI and OpenAPI specs. +func (a *API) addWSChannel( + path string, + sendType, recvType reflect.Type, + sendVariants []MessageVariant, + recvVariants []MessageVariant, + info *RouteInfo, + pathFields map[string]reflect.StructField, +) error { + channelItem := spec.ChannelItem{} + + // Path parameters. + for _, match := range pathParamRe.FindAllStringSubmatch(path, -1) { + name := match[1] + paramSchema := map[string]interface{}{"type": "string"} + if field, ok := pathFields[name]; ok { + paramSchema = goTypeToJSONSchema(field.Type) + } + if channelItem.Parameters == nil { + channelItem.Parameters = make(map[string]spec.Parameter) + } + channelItem.Parameters[name] = spec.Parameter{Schema: paramSchema} + } + + // Subscribe = what clients receive = our Send type (server→client). + subMsg, err := a.buildWSMessage(sendType, sendVariants) + if err != nil { + return fmt.Errorf("send message: %w", err) + } + channelItem.Subscribe = &spec.Operation{ + ID: operationID("subscribe", path), + Message: subMsg, + } + + // Publish = what clients send = our Recv type (client→server). + pubMsg, err := a.buildWSMessage(recvType, recvVariants) + if err != nil { + return fmt.Errorf("recv message: %w", err) + } + channelItem.Publish = &spec.Operation{ + ID: operationID("publish", path), + Message: pubMsg, + } + + if info != nil { + channelItem.Description = info.Description + for _, op := range []*spec.Operation{channelItem.Subscribe, channelItem.Publish} { + if op == nil { + continue + } + op.Summary = info.Summary + for _, t := range info.Tags { + op.Tags = append(op.Tags, spec.Tag{Name: t}) + } + } + } + + a.asyncSpec.WithChannelsItem(path, channelItem) + return nil +} + +// buildWSMessage builds an AsyncAPI Message for a single direction of a +// WebSocket channel. For single-type endpoints it produces a direct message +// reference. For multi-type endpoints (variants) it produces a oneOf wrapper. +func (a *API) buildWSMessage(t reflect.Type, variants []MessageVariant) (*spec.Message, error) { + if len(variants) > 0 { + return a.buildWSOneOfMessage(variants) + } + return a.buildWSSingleMessage(t) +} + +// buildWSSingleMessage creates a message with an inline payload reference to +// the schema in components/schemas. No components/messages entry is created +// for the simple single-type case. +func (a *API) buildWSSingleMessage(t reflect.Type) (*spec.Message, error) { + name, err := a.registerWSSchema(t) + if err != nil { + return nil, err + } + + msg := &spec.Message{} + msg.OneOf1Ens().WithMessageEntity(spec.MessageEntity{ + Name: name, + Payload: map[string]interface{}{"$ref": "#/components/schemas/" + name}, + }) + return msg, nil +} + +// buildWSOneOfMessage creates a oneOf message from discriminated variants. +// Each variant gets an envelope schema {type, data} registered in components. +func (a *API) buildWSOneOfMessage(variants []MessageVariant) (*spec.Message, error) { + var msgs []spec.Message + + for _, v := range variants { + // Register the payload schema. + payloadName, err := a.registerWSSchema(v.messagePayloadType()) + if err != nil { + return nil, err + } + + // Build envelope schema: {"type": name, "data": payload} + envelopeName := v.messageName() + "_" + payloadName + envelopeSchema := map[string]interface{}{ + "type": "object", + "required": []string{"type", "data"}, + "properties": map[string]interface{}{ + "type": map[string]interface{}{ + "type": "string", + "enum": []interface{}{v.messageName()}, + }, + "data": map[string]interface{}{ + "$ref": "#/components/schemas/" + payloadName, + }, + }, + } + a.asyncSpec.ComponentsEns().WithSchemasItem(envelopeName, envelopeSchema) + + // Register envelope message in components. + envelopeMsg := spec.Message{} + envelopeMsg.OneOf1Ens().WithMessageEntity(spec.MessageEntity{ + Name: v.messageName(), + Payload: map[string]interface{}{"$ref": "#/components/schemas/" + envelopeName}, + }) + a.asyncSpec.ComponentsEns().WithMessagesItem(envelopeName, envelopeMsg) + + msgs = append(msgs, spec.Message{ + Reference: &spec.Reference{Ref: "#/components/messages/" + envelopeName}, + }) + } + + result := &spec.Message{} + result.OneOf1Ens().WithOneOf0(spec.MessageOneOf1OneOf0{OneOf: msgs}) + return result, nil +} + +// registerWSSchema registers a Go type as a schema in both the AsyncAPI and +// OpenAPI component sections, returning the schema name. +func (a *API) registerWSSchema(t reflect.Type) (string, error) { + schema, err := a.generateSchemaRef(t) + if err != nil { + return "", err + } + if schema == nil { + return "", fmt.Errorf("could not generate schema for %v", t) + } + + name := schema.Ref + if name == "" { + name = t.Name() + } + + // Register in OpenAPI components (for openapi-typescript type generation). + if schema.Ref != "" && len(schema.Value.Properties) > 0 { + a.spec.Components.Schemas[name] = &openapi3.SchemaRef{Value: schema.Value} + } + + // Register in AsyncAPI components. + asyncSchema, err := openAPISchemaToMap(schema) + if err != nil { + return "", err + } + a.asyncSpec.ComponentsEns().WithSchemasItem(name, asyncSchema) + + return name, nil +} + +// openAPISchemaToMap converts a kin-openapi SchemaRef to a plain map for use +// in the AsyncAPI spec's JSON Schema fields. +func openAPISchemaToMap(s *openapi3.SchemaRef) (map[string]interface{}, error) { + b, err := json.Marshal(s.Value) + if err != nil { + return nil, err + } + var m map[string]interface{} + if err := json.Unmarshal(b, &m); err != nil { + return nil, err + } + return m, nil +} + +// goTypeToJSONSchema returns a minimal JSON Schema map for a scalar Go type. +func goTypeToJSONSchema(t reflect.Type) map[string]interface{} { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + switch t.Kind() { + case reflect.Bool: + return map[string]interface{}{"type": "boolean"} + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return map[string]interface{}{"type": "integer"} + case reflect.Float32, reflect.Float64: + return map[string]interface{}{"type": "number"} + default: + return map[string]interface{}{"type": "string"} + } +} + +func (a *API) serveAsyncSpec(w http.ResponseWriter, r *http.Request) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + if err := enc.Encode(a.asyncSpec); err != nil { + http.Error(w, "error encoding async spec", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = buf.WriteTo(w) +} diff --git a/docs.go b/docs.go index 6928686..5c2d467 100644 --- a/docs.go +++ b/docs.go @@ -24,15 +24,41 @@ Scalar.createApiReference('#app', { ` +const asyncDocsTemplate string = ` + + +{{.Title}} + + + + + +
+ + + + +` + +var ( + docsTmpl = template.Must(template.New("docsHTML").Parse(docsTemplate)) + asyncDocsTmpl = template.Must(template.New("asyncDocsHTML").Parse(asyncDocsTemplate)) +) + type docsData struct { Title string SpecURL string } func genDocsHTML(data docsData, out io.Writer) error { - t, err := template.New("docsHTML").Parse(docsTemplate) - if err != nil { - return err - } - return t.Execute(out, data) + return docsTmpl.Execute(out, data) +} + +func genAsyncDocsHTML(data docsData, out io.Writer) error { + return asyncDocsTmpl.Execute(out, data) } diff --git a/example_test.go b/example_test.go index 0ac2649..dadefab 100644 --- a/example_test.go +++ b/example_test.go @@ -409,6 +409,93 @@ func ExampleWithEvents() { // } +func ExampleHandleWS() { + api := shiftapi.New() + + type ServerMsg struct { + Text string `json:"text"` + } + type ClientMsg struct { + Text string `json:"text"` + } + + shiftapi.HandleWS(api, "GET /echo", func(r *http.Request, _ struct{}, ws *shiftapi.WSConn[ServerMsg, ClientMsg]) error { + ctx := r.Context() + for { + msg, err := ws.Receive(ctx) + if err != nil { + return nil + } + if err := ws.Send(ctx, ServerMsg{Text: "echo: " + msg.Text}); err != nil { + return err + } + } + }) + + _ = api +} + +type exServerEvent interface{ exServerEvent() } + +type exChatMessage struct { + User string `json:"user"` + Text string `json:"text"` +} + +func (exChatMessage) exServerEvent() {} + +type exSystemMessage struct { + Info string `json:"info"` +} + +func (exSystemMessage) exServerEvent() {} + +type exClientEvent interface{ exClientEvent() } + +type exUserMessage struct { + Text string `json:"text"` +} + +func (exUserMessage) exClientEvent() {} + +func ExampleWithSendMessages() { + api := shiftapi.New() + + shiftapi.HandleWS(api, "GET /chat", func(r *http.Request, _ struct{}, ws *shiftapi.WSConn[exServerEvent, exClientEvent]) error { + ctx := r.Context() + + // Send discriminated messages + if err := ws.SendEvent(ctx, "chat", exChatMessage{User: "alice", Text: "hi"}); err != nil { + return err + } + if err := ws.SendEvent(ctx, "system", exSystemMessage{Info: "alice joined"}); err != nil { + return err + } + + // Receive discriminated messages + msg, err := ws.ReceiveEvent(ctx) + if err != nil { + return err + } + switch msg.Type { + case "message": + var m exUserMessage + if err := msg.Decode(&m); err != nil { + return err + } + return ws.SendEvent(ctx, "chat", exChatMessage{User: "server", Text: m.Text}) + } + return nil + }, shiftapi.WithSendMessages( + shiftapi.MessageType[exChatMessage]("chat"), + shiftapi.MessageType[exSystemMessage]("system"), + ), shiftapi.WithRecvMessages( + shiftapi.MessageType[exUserMessage]("message"), + )) + + _ = api +} + func ExampleAPI_ServeHTTP() { api := shiftapi.New() diff --git a/examples/greeter/main.go b/examples/greeter/main.go index b8422cb..7218ec4 100644 --- a/examples/greeter/main.go +++ b/examples/greeter/main.go @@ -9,6 +9,30 @@ import ( "github.com/fcjr/shiftapi" ) +type ChatMsg struct { + Text string `json:"text"` +} + +type EchoReply struct { + Text string `json:"text"` +} + +func echo(r *http.Request, _ struct{}, ws *shiftapi.WSConn[EchoReply, ChatMsg]) error { + ctx := r.Context() + for { + msg, err := ws.Receive(ctx) + if shiftapi.WSCloseStatus(err) == shiftapi.WSStatusNormalClosure { + return nil + } + if err != nil { + return err + } + if err := ws.Send(ctx, EchoReply{Text: "echo: " + msg.Text}); err != nil { + return err + } + } +} + type Person struct { Name string `json:"name" validate:"required"` } @@ -170,7 +194,16 @@ func main() { }), ) + shiftapi.HandleWS(api, "GET /echo", echo, + shiftapi.WithRouteInfo(shiftapi.RouteInfo{ + Summary: "Echo WebSocket", + Description: "Echoes back any message sent by the client", + Tags: []string{"websocket"}, + }), + ) + log.Println("listening on :8080") log.Fatal(shiftapi.ListenAndServe(":8080", api)) // docs at http://localhost:8080/docs + // ws docs at http://localhost:8080/docs/ws } diff --git a/go.mod b/go.mod index 4f55520..280e947 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module github.com/fcjr/shiftapi go 1.26.0 require ( + github.com/coder/websocket v1.8.14 github.com/getkin/kin-openapi v0.128.0 github.com/go-playground/validator/v10 v10.30.1 + github.com/swaggest/go-asyncapi v0.8.1 ) require ( @@ -22,5 +24,6 @@ require ( golang.org/x/crypto v0.46.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a224c56..91b5af6 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= @@ -40,6 +42,8 @@ github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggest/go-asyncapi v0.8.1 h1:/gc8R/GC/yYioDrmsqjnLp97y7yTVVyolRx8kftksH0= +github.com/swaggest/go-asyncapi v0.8.1/go.mod h1:45rYqdtBlDOte1/Dnz2ogGS4CZzvJBRaAzPp2+xwvyg= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= @@ -51,5 +55,7 @@ golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handler.go b/handler.go index 72d3539..94de9c2 100644 --- a/handler.go +++ b/handler.go @@ -6,6 +6,8 @@ import ( "log" "net/http" "reflect" + + "github.com/coder/websocket" ) // RawHandlerFunc is a handler function that writes directly to the @@ -227,6 +229,52 @@ func adaptSSE[In, Event any](fn SSEHandlerFunc[In, Event], hc *handlerConfig) ht } } +func adaptWS[In, Send, Recv any](fn WSHandlerFunc[In, Send, Recv], hc *handlerConfig, wsOpts *WSAcceptOptions) http.HandlerFunc { + // Convert our public WSAcceptOptions to the underlying library's AcceptOptions. + var acceptOpts *websocket.AcceptOptions + if wsOpts != nil { + acceptOpts = &websocket.AcceptOptions{ + Subprotocols: wsOpts.Subprotocols, + OriginPatterns: wsOpts.OriginPatterns, + } + } + + // In dev mode, skip origin verification so that cross-origin requests + // from Vite/Next.js dev servers work without extra config. User-provided + // options (e.g. Subprotocols) are preserved. + if devMode { + if acceptOpts == nil { + acceptOpts = &websocket.AcceptOptions{InsecureSkipVerify: true} + } else { + acceptOpts.InsecureSkipVerify = true + } + } + + return func(w http.ResponseWriter, r *http.Request) { + in, ok := parseInput[In](w, r, hc) + if !ok { + return + } + + conn, err := websocket.Accept(w, r, acceptOpts) + if err != nil { + // Accept writes its own error response (e.g. 403 for origin + // violations), so we must not write a second one. + return + } + + ws := &WSConn[Send, Recv]{conn: conn} + if err := fn(r, in, ws); err != nil { + if websocket.CloseStatus(err) != -1 { + // Already a WebSocket close — nothing more to do. + return + } + log.Printf("shiftapi: WS handler error: %v", err) + _ = conn.Close(websocket.StatusInternalError, "internal error") + } + } +} + func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) diff --git a/handlerFuncs.go b/handlerFuncs.go index 485a40c..f0d0f2d 100644 --- a/handlerFuncs.go +++ b/handlerFuncs.go @@ -363,3 +363,96 @@ func HandleSSE[In, Event any](router Router, pattern string, fn SSEHandlerFunc[I registerSSERoute(router, method, path, fn, options...) } + + +func registerWSRoute[In, Send, Recv any]( + router Router, + method string, + path string, + fn WSHandlerFunc[In, Send, Recv], + options ...RouteOption, +) { + s := prepareRoute[In](router, method, path, false, options) + + // Validate no duplicate message names. + validateMessageVariants(s.cfg.wsSendVariants, "WithSendMessages", method, path) + validateMessageVariants(s.cfg.wsRecvVariants, "WithRecvMessages", method, path) + + // Build path field map for AsyncAPI channel parameters. + sendType := reflect.TypeFor[Send]() + recvType := reflect.TypeFor[Recv]() + pathFields := make(map[string]reflect.StructField) + if s.pathType != nil { + pt := s.pathType + for pt.Kind() == reflect.Pointer { + pt = pt.Elem() + } + if pt.Kind() == reflect.Struct { + for f := range pt.Fields() { + if f.IsExported() && hasPathTag(f) { + pathFields[pathFieldName(f)] = f + } + } + } + } + + // Register in AsyncAPI spec. + if err := s.api.addWSChannel( + s.fullPath, sendType, recvType, + s.cfg.wsSendVariants, s.cfg.wsRecvVariants, + s.cfg.info, pathFields, + ); err != nil { + panic(fmt.Sprintf("shiftapi: AsyncAPI generation failed for %s %s: %v", method, s.fullPath, err)) + } + + hc := s.handlerCfg(method, false) + h := adaptWS(fn, hc, s.cfg.wsOptions) + s.wrapAndRegister(router, h) +} + + + +// HandleWS registers a WebSocket handler for the given pattern. The handler +// receives a typed [WSConn] for bidirectional JSON communication. +// Input parsing, validation, and middleware work identically to [Handle]. +// +// WebSocket endpoints are documented in an AsyncAPI 2.4 spec served at +// GET /asyncapi.json, with send and receive schemas describing the +// message types. +// +// shiftapi.HandleWS(api, "GET /chat", func(r *http.Request, in struct{}, ws *shiftapi.WSConn[ServerMsg, ClientMsg]) error { +// ctx := r.Context() +// for { +// msg, err := ws.Receive(ctx) +// if shiftapi.WSCloseStatus(err) == shiftapi.WSStatusNormalClosure { +// return nil +// } +// if err != nil { +// return err +// } +// if err := ws.Send(ctx, ServerMsg{Text: msg.Text}); err != nil { +// return err +// } +// } +// }) +func HandleWS[In, Send, Recv any](router Router, pattern string, fn WSHandlerFunc[In, Send, Recv], options ...RouteOption) { + method, path := parsePattern(pattern) + registerWSRoute(router, method, path, fn, options...) +} + + + +func validateMessageVariants(variants []MessageVariant, optName, method, path string) { + if len(variants) == 0 { + return + } + seen := make(map[string]bool, len(variants)) + for _, v := range variants { + name := v.messageName() + if seen[name] { + panic(fmt.Sprintf("shiftapi: duplicate message name %q in %s for %s %s", name, optName, method, path)) + } + seen[name] = true + } +} + diff --git a/handlerOptions.go b/handlerOptions.go index 22d5bad..720919c 100644 --- a/handlerOptions.go +++ b/handlerOptions.go @@ -14,6 +14,9 @@ type routeConfig struct { contentType string // custom response media type responseSchemaType reflect.Type // optional type for schema generation under the content type eventVariants []EventVariant // SSE event variants for oneOf schema generation + wsOptions *WSAcceptOptions + wsSendVariants []MessageVariant + wsRecvVariants []MessageVariant } func (c *routeConfig) addError(e errorEntry) { @@ -119,3 +122,74 @@ func WithEvents(variants ...EventVariant) routeOptionFunc { cfg.eventVariants = append(cfg.eventVariants, variants...) } } + +// WSAcceptOptions configures the WebSocket upgrade for [HandleWS] routes. +type WSAcceptOptions struct { + // Subprotocols lists the WebSocket subprotocols to negotiate with the + // client. The empty subprotocol is always negotiated per RFC 6455. + Subprotocols []string + + // OriginPatterns lists host patterns for authorized cross-origin requests. + // The request host is always authorized. Each pattern is matched case + // insensitively with [path.Match]. Include a URI scheme ("://") to match + // against "scheme://host". + // + // In dev mode (shiftapidev build tag), all origins are allowed by default. + OriginPatterns []string +} + +// WithWSAcceptOptions sets the WebSocket upgrade options for [HandleWS] routes. +// Use this to configure subprotocols, allowed origins, etc. +// +// shiftapi.HandleWS(api, "GET /ws", handler, +// shiftapi.WithWSAcceptOptions(shiftapi.WSAcceptOptions{ +// Subprotocols: []string{"graphql-ws"}, +// OriginPatterns: []string{"example.com"}, +// }), +// ) +func WithWSAcceptOptions(opts WSAcceptOptions) routeOptionFunc { + return func(cfg *routeConfig) { + cfg.wsOptions = &opts + } +} + +// WithSendMessages registers named server-to-client message types for AsyncAPI +// schema generation on a [HandleWS] route. Each [MessageVariant] maps a type +// name to a payload type, producing a oneOf schema with a discriminator on +// the "type" field. +// +// When using WithSendMessages, send messages via [WSConn.SendEvent] which +// wraps the payload in {"type": name, "data": payload}. +// +// shiftapi.HandleWS(api, "GET /chat", handler, +// shiftapi.WithSendMessages( +// shiftapi.MessageType[ChatMessage]("chat"), +// shiftapi.MessageType[SystemMessage]("system"), +// ), +// ) +func WithSendMessages(variants ...MessageVariant) routeOptionFunc { + return func(cfg *routeConfig) { + cfg.wsSendVariants = append(cfg.wsSendVariants, variants...) + } +} + +// WithRecvMessages registers named client-to-server message types for AsyncAPI +// schema generation on a [HandleWS] route. Each [MessageVariant] maps a type +// name to a payload type, producing a oneOf schema with a discriminator on +// the "type" field. +// +// When using WithRecvMessages, receive messages via [WSConn.ReceiveEvent] +// which parses the {"type": name, "data": payload} envelope into a +// [WSEvent]. +// +// shiftapi.HandleWS(api, "GET /chat", handler, +// shiftapi.WithRecvMessages( +// shiftapi.MessageType[UserMessage]("message"), +// shiftapi.MessageType[UserCommand]("command"), +// ), +// ) +func WithRecvMessages(variants ...MessageVariant) routeOptionFunc { + return func(cfg *routeConfig) { + cfg.wsRecvVariants = append(cfg.wsRecvVariants, variants...) + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e511114 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,126 @@ +{ + "name": "shiftapi", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "turbo": "^2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/.pnpm/turbo-darwin-arm64@2.8.9/node_modules/turbo-darwin-arm64": { + "version": "2.8.9", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/.pnpm/turbo@2.8.9": { + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/.pnpm/turbo@2.8.9/node_modules/turbo": { + "version": "2.8.9", + "dev": true, + "license": "MIT", + "bin": { + "turbo": "bin/turbo" + }, + "optionalDependencies": { + "turbo-darwin-64": "2.8.9", + "turbo-darwin-arm64": "2.8.9", + "turbo-linux-64": "2.8.9", + "turbo-linux-arm64": "2.8.9", + "turbo-windows-64": "2.8.9", + "turbo-windows-arm64": "2.8.9" + } + }, + "node_modules/.pnpm/turbo@2.8.9/node_modules/turbo-darwin-arm64": { + "resolved": "node_modules/.pnpm/turbo-darwin-arm64@2.8.9/node_modules/turbo-darwin-arm64", + "link": true + }, + "node_modules/turbo": { + "resolved": "node_modules/.pnpm/turbo@2.8.9/node_modules/turbo", + "link": true + }, + "node_modules/turbo-darwin-64": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/turbo-darwin-64/-/turbo-darwin-64-2.8.9.tgz", + "integrity": "sha512-KnCw1ZI9KTnEAhdI9avZrnZ/z4wsM++flMA1w8s8PKOqi5daGpFV36qoPafg4S8TmYMe52JPWEoFr0L+lQ5JIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/turbo-linux-64": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/turbo-linux-64/-/turbo-linux-64-2.8.9.tgz", + "integrity": "sha512-OXC9HdCtsHvyH+5KUoH8ds+p5WU13vdif0OPbsFzZca4cUXMwKA3HWwUuCgQetk0iAE4cscXpi/t8A263n3VTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/turbo-linux-arm64": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/turbo-linux-arm64/-/turbo-linux-arm64-2.8.9.tgz", + "integrity": "sha512-yI5n8jNXiFA6+CxnXG0gO7h5ZF1+19K8uO3/kXPQmyl37AdiA7ehKJQOvf9OPAnmkGDHcF2HSCPltabERNRmug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/turbo-windows-64": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/turbo-windows-64/-/turbo-windows-64-2.8.9.tgz", + "integrity": "sha512-/OztzeGftJAg258M/9vK2ZCkUKUzqrWXJIikiD2pm8TlqHcIYUmepDbyZSDfOiUjMy6NzrLFahpNLnY7b5vNgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/turbo-windows-arm64": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/turbo-windows-arm64/-/turbo-windows-arm64-2.8.9.tgz", + "integrity": "sha512-xZ2VTwVTjIqpFZKN4UBxDHCPM3oJ2J5cpRzCBSmRpJ/Pn33wpiYjs+9FB2E03svKaD04/lSSLlEUej0UYsugfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + } + } +} diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 0f1b2ce..154eac9 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -228,7 +228,7 @@ async function initializeDev( const result = await _regenerateTypes(serverEntry, goRoot, baseUrl, true, ""); generatedDts = result.types; const clientJs = nextClientJsTemplate(goPort, baseUrl, DEV_API_PREFIX); - writeGeneratedFiles(configDir, generatedDts, baseUrl, { clientJsContent: clientJs, openapiSource }); + writeGeneratedFiles(configDir, generatedDts, baseUrl, { clientJsContent: clientJs, openapiSource, asyncapiSpec: result.asyncapiSpec }); patchTsConfigPaths(projectRoot, configDir); console.log("[shiftapi] Types generated."); } catch (err) { @@ -264,6 +264,7 @@ async function initializeDev( writeGeneratedFiles(configDir, generatedDts, baseUrl, { clientJsContent: clientJs, openapiSource, + asyncapiSpec: result.asyncapiSpec, }); console.log("[shiftapi] Types regenerated."); } @@ -313,7 +314,7 @@ async function initializeBuild( try { const result = await _regenerateTypes(serverEntry, goRoot, baseUrl, false, ""); const clientJs = nextClientJsTemplate(basePort, baseUrl); - writeGeneratedFiles(configDir, result.types, baseUrl, { clientJsContent: clientJs, openapiSource }); + writeGeneratedFiles(configDir, result.types, baseUrl, { clientJsContent: clientJs, openapiSource, asyncapiSpec: result.asyncapiSpec }); patchTsConfigPaths(projectRoot, configDir); console.log("[shiftapi] Types generated for build."); } catch (err) { diff --git a/packages/shiftapi/src/__tests__/generate.test.ts b/packages/shiftapi/src/__tests__/generate.test.ts index 58b9dd8..528b63e 100644 --- a/packages/shiftapi/src/__tests__/generate.test.ts +++ b/packages/shiftapi/src/__tests__/generate.test.ts @@ -121,8 +121,9 @@ describe("virtualModuleTemplate", () => { expect(source).toContain("import.meta.env.VITE_SHIFTAPI_BASE_URL"); expect(source).toContain("/api"); expect(source).toContain("export { createClient }"); - expect(source).toContain('import { createSubscribe } from "shiftapi/internal"'); + expect(source).toContain('import { createSubscribe, createWebSocket } from "shiftapi/internal"'); expect(source).toContain("export const subscribe = createSubscribe("); + expect(source).toContain("export const websocket = createWebSocket("); // Should NOT contain TypeScript syntax expect(source).not.toContain("interface"); expect(source).not.toContain("type "); diff --git a/packages/shiftapi/src/codegen.ts b/packages/shiftapi/src/codegen.ts index 8cfdce4..3051a41 100644 --- a/packages/shiftapi/src/codegen.ts +++ b/packages/shiftapi/src/codegen.ts @@ -1,7 +1,7 @@ import { resolve, relative } from "node:path"; import { writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs"; import { parse, stringify } from "comment-json"; -import { extractSpec } from "./extract"; +import { extractSpecs } from "./extract"; import { generateTypes } from "./generate"; import { MODULE_ID, DEV_API_PREFIX } from "./constants"; import { dtsTemplate, clientJsTemplate, virtualModuleTemplate } from "./templates"; @@ -12,18 +12,15 @@ export async function regenerateTypes( baseUrl: string, isDev: boolean, previousTypes: string, -): Promise<{ types: string; virtualModuleSource: string; changed: boolean }> { - const spec = extractSpec(serverEntry, resolve(goRoot)) as Record< - string, - unknown - >; - const types = await generateTypes(spec); +): Promise<{ types: string; virtualModuleSource: string; changed: boolean; asyncapiSpec: object | null }> { + const specs = extractSpecs(serverEntry, resolve(goRoot)); + const types = await generateTypes(specs.openapi as Record); const changed = types !== previousTypes; const virtualModuleSource = virtualModuleTemplate( baseUrl, isDev ? DEV_API_PREFIX : undefined, ); - return { types, virtualModuleSource, changed }; + return { types, virtualModuleSource, changed, asyncapiSpec: specs.asyncapi }; } export function writeGeneratedFiles( @@ -33,6 +30,7 @@ export function writeGeneratedFiles( options?: { clientJsContent?: string; openapiSource?: string; + asyncapiSpec?: object | null; }, ): void { const outDir = resolve(typesRoot, ".shiftapi"); @@ -40,7 +38,7 @@ export function writeGeneratedFiles( mkdirSync(outDir, { recursive: true }); } - writeFileSync(resolve(outDir, "client.d.ts"), dtsTemplate(generatedDts)); + writeFileSync(resolve(outDir, "client.d.ts"), dtsTemplate(generatedDts, options?.asyncapiSpec ?? null)); writeFileSync(resolve(outDir, "client.js"), options?.clientJsContent ?? clientJsTemplate(baseUrl)); if (options?.openapiSource) { writeFileSync(resolve(outDir, "openapi-fetch.js"), options.openapiSource); diff --git a/packages/shiftapi/src/extract.ts b/packages/shiftapi/src/extract.ts index a8b15c6..3a08027 100644 --- a/packages/shiftapi/src/extract.ts +++ b/packages/shiftapi/src/extract.ts @@ -1,16 +1,28 @@ import { execFileSync } from "node:child_process"; -import { readFileSync, unlinkSync, rmSync, mkdtempSync } from "node:fs"; +import { readFileSync, existsSync, rmSync, mkdtempSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +export interface ExtractedSpecs { + openapi: object; + asyncapi: object | null; +} + /** - * Extracts the OpenAPI spec from a Go shiftapi server by running it with - * the SHIFTAPI_EXPORT_SPEC environment variable set. The Go binary writes - * the spec to the given path and exits immediately. + * Extracts the OpenAPI and AsyncAPI specs from a Go shiftapi server by + * running it with the SHIFTAPI_EXPORT_SPEC (and optionally + * SHIFTAPI_EXPORT_ASYNCAPI) environment variables set. The Go binary writes + * the specs to the given paths and exits immediately. */ export function extractSpec(serverEntry: string, goRoot: string): object { + const result = extractSpecs(serverEntry, goRoot); + return result.openapi; +} + +export function extractSpecs(serverEntry: string, goRoot: string): ExtractedSpecs { const tempDir = mkdtempSync(join(tmpdir(), "shiftapi-")); const specPath = join(tempDir, "openapi.json"); + const asyncSpecPath = join(tempDir, "asyncapi.json"); try { execFileSync("go", ["run", "-tags", "shiftapidev", serverEntry], { @@ -18,6 +30,7 @@ export function extractSpec(serverEntry: string, goRoot: string): object { env: { ...process.env, SHIFTAPI_EXPORT_SPEC: specPath, + SHIFTAPI_EXPORT_ASYNCAPI: asyncSpecPath, }, stdio: ["ignore", "pipe", "pipe"], timeout: 30_000, @@ -30,16 +43,16 @@ export function extractSpec(serverEntry: string, goRoot: string): object { ? String((err as { stderr: unknown }).stderr) : ""; throw new Error( - `shiftapi: Failed to extract OpenAPI spec.\n` + + `shiftapi: Failed to extract specs.\n` + ` Command: go run ${serverEntry}\n` + ` CWD: ${goRoot}\n` + ` Error: ${stderr || String(err)}`, ); } - let raw: string; + let openapi: object; try { - raw = readFileSync(specPath, "utf-8"); + openapi = JSON.parse(readFileSync(specPath, "utf-8")); } catch { throw new Error( `shiftapi: Spec file was not created at ${specPath}.\n` + @@ -47,13 +60,21 @@ export function extractSpec(serverEntry: string, goRoot: string): object { ); } + let asyncapi: object | null = null; + try { + if (existsSync(asyncSpecPath)) { + asyncapi = JSON.parse(readFileSync(asyncSpecPath, "utf-8")); + } + } catch { + // AsyncAPI spec is optional — ignore parse errors. + } + // Cleanup temp dir try { - unlinkSync(specPath); rmSync(tempDir, { recursive: true }); } catch { // ignore cleanup errors } - return JSON.parse(raw); + return { openapi, asyncapi }; } diff --git a/packages/shiftapi/src/internal.ts b/packages/shiftapi/src/internal.ts index 42b76db..5d0135e 100644 --- a/packages/shiftapi/src/internal.ts +++ b/packages/shiftapi/src/internal.ts @@ -1,7 +1,8 @@ export { defineConfig, loadConfig, findConfigDir } from "./config"; export type { ShiftAPIConfig, ShiftAPIPluginOptions } from "./config"; export { regenerateTypes, writeGeneratedFiles, patchTsConfigPaths } from "./codegen"; -export { extractSpec } from "./extract"; +export { extractSpec, extractSpecs } from "./extract"; +export type { ExtractedSpecs } from "./extract"; export { generateTypes } from "./generate"; export { dtsTemplate, clientJsTemplate, nextClientJsTemplate, virtualModuleTemplate } from "./templates"; export { MODULE_ID, RESOLVED_MODULE_ID, DEV_API_PREFIX } from "./constants"; @@ -9,3 +10,5 @@ export { GoServerManager } from "./goServer"; export { findFreePort } from "./ports"; export { createSubscribe } from "./subscribe"; export type { SSEStream, SubscribeOptions, SubscribeFn } from "./subscribe"; +export { createWebSocket } from "./websocket"; +export type { WSConnection, WebSocketOptions, WebSocketFn } from "./websocket"; diff --git a/packages/shiftapi/src/templates.ts b/packages/shiftapi/src/templates.ts index a29f2fe..24a362c 100644 --- a/packages/shiftapi/src/templates.ts +++ b/packages/shiftapi/src/templates.ts @@ -31,14 +31,134 @@ const BODY_SERIALIZER = `(body) => { return fd; }`; -export function dtsTemplate(generatedTypes: string): string { +/** + * Builds the WSChannels type declaration from an AsyncAPI spec. + * Maps each channel to its send (subscribe) and receive (publish) schema + * types, referencing the openapi-typescript-generated component schemas. + */ +function buildWSChannelsType(asyncapiSpec: object | null): string { + if (!asyncapiSpec) return ""; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const spec = asyncapiSpec as any; + const channels = spec.channels; + if (!channels || Object.keys(channels).length === 0) return ""; + + const entries: string[] = []; + for (const [path, channel] of Object.entries(channels) as [string, any][]) { + const sendType = resolveMessageType(channel.subscribe?.message, spec); + const recvType = resolveMessageType(channel.publish?.message, spec); + entries.push(` ${JSON.stringify(path)}: {\n send: ${sendType};\n receive: ${recvType};\n };`); + } + + return ` + interface WSChannels { +${entries.join("\n")} + } + + type WSPaths = keyof WSChannels; + type WSSend

= WSChannels[P]["send"]; + type WSRecv

= WSChannels[P]["receive"]; + + export function websocket

( + path: P, + options?: { params?: Record; protocols?: string[] } + ): WSConnection, WSRecv

>; +`; +} + +/** + * Resolves an AsyncAPI message definition to a TypeScript type string. + * Handles inline messages (single-type channels) and oneOf + * (discriminated union variants with {type, data} envelopes). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function resolveMessageType(message: any, spec: any): string { + if (!message) return "unknown"; + + // Inline message with payload (single-message channels) + if (message.payload) { + return resolvePayloadType(message.payload, spec); + } + + // oneOf — discriminated union + if (message.oneOf) { + const variants = message.oneOf + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((m: any) => { + if (m.$ref) { + const msgName = m.$ref.replace("#/components/messages/", ""); + const msg = spec.components?.messages?.[msgName]; + if (!msg) return "unknown"; + return resolveEnvelopeType(msg.payload, spec); + } + return resolveEnvelopeType(m.payload, spec); + }) + .filter((t: string) => t !== "unknown"); + return variants.length > 0 ? variants.join(" | ") : "unknown"; + } + + return "unknown"; +} + +/** + * Resolves a payload schema to a TypeScript type referencing + * openapi-typescript-generated component schemas. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function resolvePayloadType(payload: any, _spec: any): string { + if (!payload) return "unknown"; + if (payload.$ref) { + const name = payload.$ref.replace("#/components/schemas/", ""); + return `components["schemas"]["${name}"]`; + } + return "unknown"; +} + +/** + * Resolves an envelope schema ({type, data}) to a TypeScript + * discriminated union variant type. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function resolveEnvelopeType(payload: any, spec: any): string { + if (!payload) return "unknown"; + + // If it's a $ref to an envelope schema in components + if (payload.$ref) { + const name = payload.$ref.replace("#/components/schemas/", ""); + const schema = spec.components?.schemas?.[name]; + if (!schema) return "unknown"; + return resolveEnvelopeFromSchema(schema, spec); + } + + return resolveEnvelopeFromSchema(payload, spec); +} + +/** + * Given an inline envelope schema with {type: enum, data: $ref}, + * produces a TypeScript type like { type: "chat"; data: ChatMessage }. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function resolveEnvelopeFromSchema(schema: any, spec: any): string { + const typeEnum = schema.properties?.type?.enum; + const dataRef = schema.properties?.data; + if (!typeEnum || typeEnum.length !== 1 || !dataRef) return "unknown"; + const typeName = JSON.stringify(typeEnum[0]); + const dataType = resolvePayloadType(dataRef, spec); + return `{ type: ${typeName}; data: ${dataType} }`; +} + +export function dtsTemplate(generatedTypes: string, asyncapiSpec: object | null = null): string { + const wsSection = buildWSChannelsType(asyncapiSpec); + const wsImport = wsSection ? '\n import type { WSConnection } from "shiftapi/internal";' : ""; + return `\ // Auto-generated by shiftapi. Do not edit. declare module "@shiftapi/client" { ${indent(generatedTypes)} import type createClient from "openapi-fetch"; - import type { SSEStream } from "shiftapi/internal"; + import type { SSEStream } from "shiftapi/internal";${wsImport} type SSEPaths = { [P in keyof paths]: paths[P] extends { @@ -58,7 +178,7 @@ ${indent(generatedTypes)} path: P, options?: { params?: SSEParams

; signal?: AbortSignal } ): SSEStream>; - +${wsSection} export const client: ReturnType>; export { createClient }; } @@ -69,7 +189,7 @@ export function clientJsTemplate(baseUrl: string): string { return `\ // Auto-generated by shiftapi. Do not edit. import createClient from "openapi-fetch"; -import { createSubscribe } from "shiftapi/internal"; +import { createSubscribe, createWebSocket } from "shiftapi/internal"; /** Pre-configured, fully-typed API client. */ export const client = createClient({ @@ -78,6 +198,7 @@ export const client = createClient({ }); export const subscribe = createSubscribe(${JSON.stringify(baseUrl)}); +export const websocket = createWebSocket(${JSON.stringify(baseUrl)}); export { createClient }; `; @@ -92,7 +213,7 @@ export function nextClientJsTemplate( return `\ // Auto-generated by @shiftapi/next. Do not edit. import createClient from "./openapi-fetch.js"; -import { createSubscribe } from "shiftapi/internal"; +import { createSubscribe, createWebSocket } from "shiftapi/internal"; const baseUrl = process.env.NEXT_PUBLIC_SHIFTAPI_BASE_URL || ${JSON.stringify(baseUrl)}; @@ -104,6 +225,7 @@ export const client = createClient({ }); export const subscribe = createSubscribe(baseUrl); +export const websocket = createWebSocket(baseUrl); export { createClient }; `; @@ -113,7 +235,7 @@ export { createClient }; return `\ // Auto-generated by @shiftapi/next. Do not edit. import createClient from "./openapi-fetch.js"; -import { createSubscribe } from "shiftapi/internal"; +import { createSubscribe, createWebSocket } from "shiftapi/internal"; const baseUrl = process.env.NEXT_PUBLIC_SHIFTAPI_BASE_URL || @@ -128,6 +250,7 @@ export const client = createClient({ }); export const subscribe = createSubscribe(baseUrl); +export const websocket = createWebSocket(baseUrl); export { createClient }; `; @@ -144,7 +267,7 @@ export function virtualModuleTemplate( return `\ // Auto-generated by @shiftapi/vite-plugin import createClient from "openapi-fetch"; -import { createSubscribe } from "shiftapi/internal"; +import { createSubscribe, createWebSocket } from "shiftapi/internal"; const baseUrl = ${baseUrlExpr}; @@ -155,6 +278,7 @@ export const client = createClient({ }); export const subscribe = createSubscribe(baseUrl); +export const websocket = createWebSocket(baseUrl); export { createClient }; `; diff --git a/packages/shiftapi/src/websocket.ts b/packages/shiftapi/src/websocket.ts new file mode 100644 index 0000000..969c70b --- /dev/null +++ b/packages/shiftapi/src/websocket.ts @@ -0,0 +1,141 @@ +/** + * A typed WebSocket connection returned by {@link createWebSocket}. + * Provides type-safe send/receive and an async iterable interface. + */ +export interface WSConnection { + /** Send a JSON-encoded message to the server. Waits for the connection to open. */ + send(data: Send): Promise; + /** Receive the next JSON message from the server. */ + receive(): Promise; + /** Async iterable that yields parsed JSON messages until close. */ + [Symbol.asyncIterator](): AsyncIterableIterator; + /** Close the WebSocket connection. */ + close(code?: number, reason?: string): void; + /** The current readyState of the underlying WebSocket. */ + readonly readyState: number; +} + +/** Options accepted by a `websocket` function created via {@link createWebSocket}. */ +export interface WebSocketOptions { + params?: { + path?: Record; + query?: Record; + header?: Record; + }; + protocols?: string[]; +} + +/** A websocket function returned by {@link createWebSocket}. */ +export type WebSocketFn = ( + path: string, + options?: WebSocketOptions, +) => WSConnection; + +/** + * Creates a type-safe `websocket` function bound to the given base URL. + * + * The returned function opens a WebSocket connection, applying path and query + * parameter substitution, and wraps it in a typed {@link WSConnection}. + */ +export function createWebSocket(baseUrl: string) { + return function websocket( + path: string, + options: WebSocketOptions = {}, + ): WSConnection { + let url = baseUrl + path; + const { params } = options; + + if (params?.path) { + for (const [k, v] of Object.entries(params.path)) + url = url.replace("{" + k + "}", encodeURIComponent(String(v))); + } + + if (params?.query) { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(params.query)) qs.set(k, String(v)); + const str = qs.toString(); + if (str) url += "?" + str; + } + + // Replace http(s):// with ws(s):// + url = url.replace(/^http/, "ws"); + + const ws = new WebSocket(url, options.protocols); + + // Resolves when the connection is open and ready to send. + const ready = new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }); + ws.addEventListener("error", (e) => reject(e), { once: true }); + }); + + // Queue of pending receive resolvers. + const queue: { + resolve: (value: Recv) => void; + reject: (reason: unknown) => void; + }[] = []; + // Buffer of messages received before anyone called receive(). + const buffer: Recv[] = []; + let closeError: Event | undefined; + + ws.addEventListener("message", (event) => { + const data = JSON.parse(event.data as string) as Recv; + const pending = queue.shift(); + if (pending) { + pending.resolve(data); + } else { + buffer.push(data); + } + }); + + ws.addEventListener("close", (event) => { + closeError = event; + // Reject all pending receivers. + for (const pending of queue) { + pending.reject(event); + } + queue.length = 0; + }); + + ws.addEventListener("error", (event) => { + closeError = event; + for (const pending of queue) { + pending.reject(event); + } + queue.length = 0; + }); + + return { + async send(data: Send): Promise { + await ready; + ws.send(JSON.stringify(data)); + }, + receive(): Promise { + const buffered = buffer.shift(); + if (buffered !== undefined) { + return Promise.resolve(buffered); + } + if (closeError) { + return Promise.reject(closeError); + } + return new Promise((resolve, reject) => { + queue.push({ resolve, reject }); + }); + }, + async *[Symbol.asyncIterator](): AsyncIterableIterator { + try { + while (true) { + yield await this.receive(); + } + } catch { + // Connection closed — stop iteration. + } + }, + close(code?: number, reason?: string): void { + ws.close(code, reason); + }, + get readyState(): number { + return ws.readyState; + }, + }; + }; +} diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index be11b40..5000ab1 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -24,6 +24,7 @@ export default function shiftapiPlugin(opts?: ShiftAPIPluginOptions): Plugin { let virtualModuleSource = ""; let generatedDts = ""; + let asyncapiSpec: object | null = null; let debounceTimer: ReturnType | null = null; let projectRoot = process.cwd(); let configDir = ""; @@ -132,8 +133,9 @@ export default function shiftapiPlugin(opts?: ShiftAPIPluginOptions): Plugin { async buildStart() { const result = await regenerateTypes(); generatedDts = result.types; + asyncapiSpec = result.asyncapiSpec; virtualModuleSource = result.virtualModuleSource; - writeGeneratedFiles(configDir, generatedDts, baseUrl); + writeGeneratedFiles(configDir, generatedDts, baseUrl, { asyncapiSpec }); }, resolveId(id, importer) { @@ -146,6 +148,9 @@ export default function shiftapiPlugin(opts?: ShiftAPIPluginOptions): Plugin { if (id === "shiftapi/internal" && importer === RESOLVED_MODULE_ID) { return createRequire(import.meta.url).resolve("shiftapi/internal"); } + if (id === "shiftapi/internal" && importer === RESOLVED_MODULE_ID) { + return createRequire(import.meta.url).resolve("shiftapi/internal"); + } }, load(id) { @@ -175,8 +180,9 @@ export default function shiftapiPlugin(opts?: ShiftAPIPluginOptions): Plugin { const result = await regenerateTypes(); if (result.changed) { generatedDts = result.types; + asyncapiSpec = result.asyncapiSpec; virtualModuleSource = result.virtualModuleSource; - writeGeneratedFiles(configDir, generatedDts, baseUrl); + writeGeneratedFiles(configDir, generatedDts, baseUrl, { asyncapiSpec }); const mod = server.moduleGraph.getModuleById(RESOLVED_MODULE_ID); if (mod) { server.moduleGraph.invalidateModule(mod); diff --git a/serve.go b/serve.go index 9288c9a..35c4ca8 100644 --- a/serve.go +++ b/serve.go @@ -4,6 +4,8 @@ package shiftapi import "net/http" +const devMode = false + // ListenAndServe starts the HTTP server on the given address. // // In production builds this is a direct call to [http.ListenAndServe] with diff --git a/serve_dev.go b/serve_dev.go index 7011236..4faf64d 100644 --- a/serve_dev.go +++ b/serve_dev.go @@ -9,6 +9,8 @@ import ( "os" ) +const devMode = true + // ListenAndServe starts the HTTP server on the given address. // // In production builds this is a direct call to [http.ListenAndServe] with @@ -26,6 +28,11 @@ func ListenAndServe(addr string, api *API) error { if err := exportSpec(api, specPath); err != nil { return err } + if asyncPath := os.Getenv("SHIFTAPI_EXPORT_ASYNCAPI"); asyncPath != "" { + if err := exportAsyncSpec(api, asyncPath); err != nil { + return err + } + } os.Exit(0) } if port := os.Getenv("SHIFTAPI_PORT"); port != "" { @@ -36,6 +43,14 @@ func ListenAndServe(addr string, api *API) error { } func exportSpec(api *API, path string) error { + return exportJSON(api.spec, path) +} + +func exportAsyncSpec(api *API, path string) error { + return exportJSON(api.asyncSpec, path) +} + +func exportJSON(v any, path string) error { f, err := os.Create(path) if err != nil { return err @@ -43,7 +58,7 @@ func exportSpec(api *API, path string) error { enc := json.NewEncoder(f) enc.SetIndent("", " ") - if err := enc.Encode(api.spec); err != nil { + if err := enc.Encode(v); err != nil { _ = f.Close() return err } diff --git a/server.go b/server.go index 2de76b0..20e5504 100644 --- a/server.go +++ b/server.go @@ -9,6 +9,7 @@ import ( "github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3gen" "github.com/go-playground/validator/v10" + spec "github.com/swaggest/go-asyncapi/spec-2.4.0" ) // API is the central type that collects typed handler registrations, generates @@ -19,6 +20,7 @@ import ( // interactive documentation at GET /docs. type API struct { spec *openapi3.T + asyncSpec *spec.AsyncAPI specGen *openapi3gen.Generator mux *http.ServeMux validate *validator.Validate @@ -44,6 +46,9 @@ func New(options ...APIOption) *API { Schemas: make(openapi3.Schemas), }, }, + asyncSpec: &spec.AsyncAPI{ + DefaultContentType: "application/json", + }, mux: http.NewServeMux(), validate: validator.New(), maxUploadSize: 32 << 20, // 32 MB @@ -100,8 +105,17 @@ func New(options ...APIOption) *API { }, } + // Copy API info to AsyncAPI spec. + if api.spec.Info != nil { + api.asyncSpec.Info.Title = api.spec.Info.Title + api.asyncSpec.Info.Version = api.spec.Info.Version + api.asyncSpec.Info.Description = api.spec.Info.Description + } + api.mux.HandleFunc("GET /openapi.json", api.serveSpec) + api.mux.HandleFunc("GET /asyncapi.json", api.serveAsyncSpec) api.mux.HandleFunc("GET /docs", api.serveDocs) + api.mux.HandleFunc("GET /docs/ws", api.serveAsyncDocs) api.mux.HandleFunc("GET /", api.redirectTo("/docs")) return api } @@ -163,6 +177,23 @@ func (a *API) serveDocs(w http.ResponseWriter, r *http.Request) { } } +func (a *API) serveAsyncDocs(w http.ResponseWriter, r *http.Request) { + if len(a.asyncSpec.Channels) == 0 { + http.NotFound(w, r) + return + } + title := "" + if a.spec.Info != nil { + title = a.spec.Info.Title + " — WebSockets" + } + if err := genAsyncDocsHTML(docsData{ + Title: title, + SpecURL: "/asyncapi.json", + }, w); err != nil { + http.Error(w, "error generating docs", http.StatusInternalServerError) + } +} + func (a *API) redirectTo(path string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, path, http.StatusTemporaryRedirect) diff --git a/ws.go b/ws.go new file mode 100644 index 0000000..6630288 --- /dev/null +++ b/ws.go @@ -0,0 +1,158 @@ +package shiftapi + +import ( + "context" + "encoding/json" + "net/http" + "reflect" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" +) + +// WSHandlerFunc is a handler function for WebSocket endpoints. It receives +// the parsed input and a typed [WSConn] for bidirectional communication. +// +// The handler is called after the WebSocket upgrade has completed. Use +// [WSConn.Send] and [WSConn.Receive] for typed message exchange and return +// nil when the connection is complete. If the handler returns an error, the +// connection is closed with [WSStatusInternalError] and the error is logged. +// +// Input parsing and validation errors (before upgrade) produce a normal +// JSON error response, identical to [Handle]. +type WSHandlerFunc[In, Send, Recv any] func(r *http.Request, in In, ws *WSConn[Send, Recv]) error + +// WSConn is a typed WebSocket connection wrapper. It provides type-safe +// Send and Receive methods over the underlying [websocket.Conn]. +// +// For single-type endpoints, use [WSConn.Send] and [WSConn.Receive] for +// direct typed messaging. For multi-type endpoints (registered with +// [WithSendMessages] / [WithRecvMessages]), use [WSConn.SendEvent] and +// [WSConn.ReceiveEvent] which wrap messages in a discriminated +// {"type", "data"} envelope. +// +// WSConn is created internally by [HandleWS] and should not be constructed +// directly. +type WSConn[Send, Recv any] struct { + conn *websocket.Conn +} + +// Send writes a JSON-encoded message to the WebSocket connection. +// Use this for single-type endpoints without [WithSendMessages]. +func (ws *WSConn[Send, Recv]) Send(ctx context.Context, v Send) error { + return wsjson.Write(ctx, ws.conn, v) +} + +// Receive reads and JSON-decodes a message from the WebSocket connection. +// Use this for single-type endpoints without [WithRecvMessages]. +func (ws *WSConn[Send, Recv]) Receive(ctx context.Context) (Recv, error) { + var v Recv + err := wsjson.Read(ctx, ws.conn, &v) + return v, err +} + +// SendEvent writes a discriminated JSON message to the WebSocket connection. +// The value is wrapped in an envelope: {"type": eventType, "data": v}. +// Use this for multi-type endpoints registered with [WithSendMessages]. +func (ws *WSConn[Send, Recv]) SendEvent(ctx context.Context, eventType string, v Send) error { + envelope := wsEnvelope[Send]{Type: eventType, Data: v} + return wsjson.Write(ctx, ws.conn, envelope) +} + +// ReceiveEvent reads a discriminated JSON message from the WebSocket +// connection. It expects an envelope: {"type": "...", "data": ...} and +// returns a [WSEvent] with the type name and raw data. Call +// [WSEvent.Decode] to unmarshal into the concrete type. +// Use this for multi-type endpoints registered with [WithRecvMessages]. +func (ws *WSConn[Send, Recv]) ReceiveEvent(ctx context.Context) (WSEvent, error) { + var msg WSEvent + err := wsjson.Read(ctx, ws.conn, &msg) + return msg, err +} + +// Close closes the WebSocket connection with the given status code and reason. +func (ws *WSConn[Send, Recv]) Close(status WSStatusCode, reason string) error { + return ws.conn.Close(websocket.StatusCode(status), reason) +} + +// Conn returns the underlying [websocket.Conn] for advanced use cases +// such as binary frames, ping/pong, or custom close handling. Most +// handlers should use [Send], [Receive], [SendEvent], and [ReceiveEvent] +// instead. +func (ws *WSConn[Send, Recv]) Conn() *websocket.Conn { + return ws.conn +} + +// WSStatusCode represents a WebSocket close status code as defined in +// RFC 6455 section 7.4. +type WSStatusCode int + +// Standard WebSocket close status codes. +const ( + WSStatusNormalClosure WSStatusCode = 1000 + WSStatusGoingAway WSStatusCode = 1001 + WSStatusProtocolError WSStatusCode = 1002 + WSStatusUnsupportedData WSStatusCode = 1003 + WSStatusInternalError WSStatusCode = 1011 +) + +// WSCloseStatus extracts the WebSocket close status code from an error. +// Returns -1 if the error is nil or not a WebSocket close error. +func WSCloseStatus(err error) WSStatusCode { + return WSStatusCode(websocket.CloseStatus(err)) +} + +// wsEnvelope is the wire format for discriminated WebSocket messages. +type wsEnvelope[T any] struct { + Type string `json:"type"` + Data T `json:"data"` +} + +// WSEvent represents a received discriminated WebSocket message. +// Use [WSEvent.Decode] to unmarshal the data into a concrete type +// after switching on [WSEvent.Type]. +type WSEvent struct { + Type string `json:"type"` + Data json.RawMessage `json:"data"` +} + +// Decode unmarshals the message data into the given value. +func (m WSEvent) Decode(v any) error { + return json.Unmarshal(m.Data, v) +} + +// MessageVariant describes a named WebSocket message type for AsyncAPI schema +// generation. Created by [MessageType] and passed to [WithSendMessages] or +// [WithRecvMessages]. +type MessageVariant interface { + messageName() string + messagePayloadType() reflect.Type +} + +type messageVariant[T any] struct { + name string +} + +func (m messageVariant[T]) messageName() string { return m.name } +func (m messageVariant[T]) messagePayloadType() reflect.Type { return reflect.TypeFor[T]() } + +// MessageType creates a [MessageVariant] that maps a message type name to a +// payload type T. Use with [WithSendMessages] or [WithRecvMessages] to +// register discriminated message types for a WebSocket endpoint. +// +// shiftapi.HandleWS(api, "GET /chat", chatHandler, +// shiftapi.WithSendMessages( +// shiftapi.MessageType[ChatMessage]("chat"), +// shiftapi.MessageType[SystemMessage]("system"), +// ), +// shiftapi.WithRecvMessages( +// shiftapi.MessageType[UserMessage]("message"), +// shiftapi.MessageType[UserCommand]("command"), +// ), +// ) +func MessageType[T any](name string) MessageVariant { + if name == "" { + panic("shiftapi: MessageType name must not be empty") + } + return messageVariant[T]{name: name} +} diff --git a/ws_test.go b/ws_test.go new file mode 100644 index 0000000..7635bf9 --- /dev/null +++ b/ws_test.go @@ -0,0 +1,595 @@ +package shiftapi_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + "github.com/fcjr/shiftapi" +) + +type wsServerMsg struct { + Text string `json:"text"` +} + +type wsClientMsg struct { + Text string `json:"text"` +} + +func TestHandleWS_AsyncAPISpec(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleWS(api, "GET /ws", func(r *http.Request, _ struct{}, ws *shiftapi.WSConn[wsServerMsg, wsClientMsg]) error { + return nil + }, shiftapi.WithRouteInfo(shiftapi.RouteInfo{ + Summary: "Echo WS", + Tags: []string{"websocket"}, + })) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/asyncapi.json", nil) + api.ServeHTTP(w, r) + + var spec map[string]any + if err := json.NewDecoder(w.Body).Decode(&spec); err != nil { + t.Fatalf("decode spec: %v", err) + } + + // Verify asyncapi version. + if spec["asyncapi"] != "2.4.0" { + t.Errorf("asyncapi = %v, want 2.4.0", spec["asyncapi"]) + } + + channels, ok := spec["channels"].(map[string]any) + if !ok { + t.Fatal("no channels in async spec") + } + ch, ok := channels["/ws"].(map[string]any) + if !ok { + t.Fatal("no /ws channel in async spec") + } + + // subscribe = server→client = Send type + sub, ok := ch["subscribe"].(map[string]any) + if !ok { + t.Fatal("no subscribe operation on /ws channel") + } + if sub["operationId"] == nil { + t.Error("subscribe missing operationId") + } + if sub["message"] == nil { + t.Error("subscribe missing message") + } + if sub["summary"] != "Echo WS" { + t.Errorf("subscribe summary = %v, want Echo WS", sub["summary"]) + } + + // publish = client→server = Recv type + pub, ok := ch["publish"].(map[string]any) + if !ok { + t.Fatal("no publish operation on /ws channel") + } + if pub["message"] == nil { + t.Error("publish missing message") + } + if pub["summary"] != "Echo WS" { + t.Errorf("publish summary = %v, want Echo WS", pub["summary"]) + } + + // Both operations should have tags. + for _, opName := range []string{"subscribe", "publish"} { + op := ch[opName].(map[string]any) + tags, ok := op["tags"].([]any) + if !ok || len(tags) == 0 { + t.Errorf("%s missing tags", opName) + } else { + tag := tags[0].(map[string]any) + if tag["name"] != "websocket" { + t.Errorf("%s tag = %v, want websocket", opName, tag["name"]) + } + } + } + + // Verify schemas are in AsyncAPI components. + components, ok := spec["components"].(map[string]any) + if !ok { + t.Fatal("no components in async spec") + } + schemas, ok := components["schemas"].(map[string]any) + if !ok { + t.Fatal("no schemas in async spec components") + } + if _, ok := schemas["wsServerMsg"]; !ok { + t.Error("missing wsServerMsg schema in async spec") + } + if _, ok := schemas["wsClientMsg"]; !ok { + t.Error("missing wsClientMsg schema in async spec") + } + + // Single-message case should NOT create components/messages. + if _, ok := components["messages"]; ok { + t.Error("single-message case should not create components/messages") + } + + // Verify WS path is NOT in OpenAPI spec. + w2 := httptest.NewRecorder() + r2 := httptest.NewRequest("GET", "/openapi.json", nil) + api.ServeHTTP(w2, r2) + + var oaSpec map[string]any + if err := json.NewDecoder(w2.Body).Decode(&oaSpec); err != nil { + t.Fatalf("decode openapi spec: %v", err) + } + if paths, ok := oaSpec["paths"].(map[string]any); ok { + if _, ok := paths["/ws"]; ok { + t.Error("WS path /ws should not be in OpenAPI spec") + } + } + + // Verify schemas are in OpenAPI components (for openapi-typescript). + oaComponents, ok := oaSpec["components"].(map[string]any) + if !ok { + t.Fatal("no components in OpenAPI spec") + } + oaSchemas, ok := oaComponents["schemas"].(map[string]any) + if !ok { + t.Fatal("no schemas in OpenAPI components") + } + if _, ok := oaSchemas["wsServerMsg"]; !ok { + t.Error("missing wsServerMsg schema in OpenAPI components") + } + if _, ok := oaSchemas["wsClientMsg"]; !ok { + t.Error("missing wsClientMsg schema in OpenAPI components") + } +} + +func TestHandleWS_InputParsing(t *testing.T) { + api := shiftapi.New() + + type Input struct { + Channel string `query:"channel" validate:"required"` + } + + shiftapi.HandleWS(api, "GET /ws", func(r *http.Request, in Input, ws *shiftapi.WSConn[wsServerMsg, wsClientMsg]) error { + return ws.Send(r.Context(), wsServerMsg{Text: "channel=" + in.Channel}) + }) + + srv := httptest.NewServer(api) + defer srv.Close() + + ctx := context.Background() + conn, _, err := websocket.Dial(ctx, srv.URL+"/ws?channel=general", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.CloseNow() + + var msg wsServerMsg + if err := wsjson.Read(ctx, conn, &msg); err != nil { + t.Fatalf("read: %v", err) + } + if msg.Text != "channel=general" { + t.Errorf("got %q, want %q", msg.Text, "channel=general") + } + conn.Close(websocket.StatusNormalClosure, "") +} + +func TestHandleWS_SendReceiveRoundtrip(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleWS(api, "GET /echo", func(r *http.Request, _ struct{}, ws *shiftapi.WSConn[wsServerMsg, wsClientMsg]) error { + ctx := r.Context() + for { + msg, err := ws.Receive(ctx) + if shiftapi.WSCloseStatus(err) == shiftapi.WSStatusNormalClosure { + return nil + } + if err != nil { + return err + } + if err := ws.Send(ctx, wsServerMsg{Text: "echo: " + msg.Text}); err != nil { + return err + } + } + }) + + srv := httptest.NewServer(api) + defer srv.Close() + + ctx := context.Background() + conn, _, err := websocket.Dial(ctx, srv.URL+"/echo", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.CloseNow() + + // Send and receive multiple messages. + for _, text := range []string{"hello", "world"} { + if err := wsjson.Write(ctx, conn, wsClientMsg{Text: text}); err != nil { + t.Fatalf("write %q: %v", text, err) + } + var resp wsServerMsg + if err := wsjson.Read(ctx, conn, &resp); err != nil { + t.Fatalf("read: %v", err) + } + want := "echo: " + text + if resp.Text != want { + t.Errorf("got %q, want %q", resp.Text, want) + } + } + + conn.Close(websocket.StatusNormalClosure, "") +} + +func TestHandleWS_ErrorBeforeUpgrade(t *testing.T) { + api := shiftapi.New() + + type Input struct { + Token string `query:"token" validate:"required"` + } + + shiftapi.HandleWS(api, "GET /ws", func(r *http.Request, in Input, ws *shiftapi.WSConn[wsServerMsg, wsClientMsg]) error { + return nil + }) + + // Missing required query param → should get JSON error, not upgrade. + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/ws", nil) + api.ServeHTTP(w, r) + + if w.Code != http.StatusUnprocessableEntity { + t.Errorf("status = %d, want %d", w.Code, http.StatusUnprocessableEntity) + } +} + +func TestHandleWS_ErrorAfterUpgrade(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleWS(api, "GET /ws", func(r *http.Request, _ struct{}, ws *shiftapi.WSConn[wsServerMsg, wsClientMsg]) error { + return fmt.Errorf("something went wrong") + }) + + srv := httptest.NewServer(api) + defer srv.Close() + + ctx := context.Background() + conn, _, err := websocket.Dial(ctx, srv.URL+"/ws", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.CloseNow() + + // The server should close the connection with StatusInternalError. + _, _, err = conn.Read(ctx) + if err == nil { + t.Fatal("expected error from read") + } + status := websocket.CloseStatus(err) + if status != websocket.StatusInternalError { + t.Errorf("close status = %d, want %d", status, websocket.StatusInternalError) + } +} + +func TestHandleWS_WithWSAcceptOptions(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleWS(api, "GET /ws", func(r *http.Request, _ struct{}, ws *shiftapi.WSConn[wsServerMsg, wsClientMsg]) error { + return ws.Send(r.Context(), wsServerMsg{Text: "ok"}) + }, shiftapi.WithWSAcceptOptions(shiftapi.WSAcceptOptions{ + Subprotocols: []string{"test-proto"}, + })) + + srv := httptest.NewServer(api) + defer srv.Close() + + ctx := context.Background() + conn, resp, err := websocket.Dial(ctx, srv.URL+"/ws", &websocket.DialOptions{ + Subprotocols: []string{"test-proto"}, + }) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.CloseNow() + + // Verify the subprotocol was negotiated. + if got := resp.Header.Get("Sec-WebSocket-Protocol"); got != "test-proto" { + t.Errorf("subprotocol = %q, want %q", got, "test-proto") + } + + var msg wsServerMsg + if err := wsjson.Read(ctx, conn, &msg); err != nil { + t.Fatalf("read: %v", err) + } + if msg.Text != "ok" { + t.Errorf("got %q, want %q", msg.Text, "ok") + } + conn.Close(websocket.StatusNormalClosure, "") +} + +func TestHandleWS_PathParams(t *testing.T) { + api := shiftapi.New() + + type Input struct { + ID string `path:"id"` + } + + shiftapi.HandleWS(api, "GET /rooms/{id}", func(r *http.Request, in Input, ws *shiftapi.WSConn[wsServerMsg, wsClientMsg]) error { + return ws.Send(r.Context(), wsServerMsg{Text: "room=" + in.ID}) + }) + + srv := httptest.NewServer(api) + defer srv.Close() + + ctx := context.Background() + conn, _, err := websocket.Dial(ctx, srv.URL+"/rooms/abc", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.CloseNow() + + var msg wsServerMsg + if err := wsjson.Read(ctx, conn, &msg); err != nil { + t.Fatalf("read: %v", err) + } + if msg.Text != "room=abc" { + t.Errorf("got %q, want %q", msg.Text, "room=abc") + } + conn.Close(websocket.StatusNormalClosure, "") +} + +// --- Multi-message (WithSendMessages / WithRecvMessages) tests --- + +type wsServerEvent interface{ wsServerEvent() } + +type wsChatMsg struct { + User string `json:"user"` + Text string `json:"text"` +} + +func (wsChatMsg) wsServerEvent() {} + +type wsSystemMsg struct { + Info string `json:"info"` +} + +func (wsSystemMsg) wsServerEvent() {} + +type wsClientEvent interface{ wsClientEvent() } + +type wsUserMsg struct { + Text string `json:"text"` +} + +func (wsUserMsg) wsClientEvent() {} + +type wsUserCmd struct { + Command string `json:"command"` +} + +func (wsUserCmd) wsClientEvent() {} + +func TestHandleWS_SendEvent(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleWS(api, "GET /ws", func(r *http.Request, _ struct{}, ws *shiftapi.WSConn[wsServerEvent, wsClientEvent]) error { + ctx := r.Context() + if err := ws.SendEvent(ctx, "chat", wsChatMsg{User: "alice", Text: "hi"}); err != nil { + return err + } + return ws.SendEvent(ctx, "system", wsSystemMsg{Info: "joined"}) + }, shiftapi.WithSendMessages( + shiftapi.MessageType[wsChatMsg]("chat"), + shiftapi.MessageType[wsSystemMsg]("system"), + )) + + srv := httptest.NewServer(api) + defer srv.Close() + + ctx := context.Background() + conn, _, err := websocket.Dial(ctx, srv.URL+"/ws", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.CloseNow() + + // Read first message — chat + var msg1 shiftapi.WSEvent + if err := wsjson.Read(ctx, conn, &msg1); err != nil { + t.Fatalf("read 1: %v", err) + } + if msg1.Type != "chat" { + t.Errorf("msg1.Type = %q, want %q", msg1.Type, "chat") + } + var chat wsChatMsg + if err := msg1.Decode(&chat); err != nil { + t.Fatalf("decode chat: %v", err) + } + if chat.User != "alice" || chat.Text != "hi" { + t.Errorf("chat = %+v, want {alice, hi}", chat) + } + + // Read second message — system + var msg2 shiftapi.WSEvent + if err := wsjson.Read(ctx, conn, &msg2); err != nil { + t.Fatalf("read 2: %v", err) + } + if msg2.Type != "system" { + t.Errorf("msg2.Type = %q, want %q", msg2.Type, "system") + } + var sys wsSystemMsg + if err := msg2.Decode(&sys); err != nil { + t.Fatalf("decode system: %v", err) + } + if sys.Info != "joined" { + t.Errorf("sys.Info = %q, want %q", sys.Info, "joined") + } + conn.Close(websocket.StatusNormalClosure, "") +} + +func TestHandleWS_ReceiveEvent(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleWS(api, "GET /ws", func(r *http.Request, _ struct{}, ws *shiftapi.WSConn[wsServerEvent, wsClientEvent]) error { + ctx := r.Context() + msg, err := ws.ReceiveEvent(ctx) + if err != nil { + return err + } + // Echo back what we received as a chat message + return ws.SendEvent(ctx, "chat", wsChatMsg{User: "server", Text: "got type=" + msg.Type}) + }, shiftapi.WithRecvMessages( + shiftapi.MessageType[wsUserMsg]("message"), + shiftapi.MessageType[wsUserCmd]("command"), + ), shiftapi.WithSendMessages( + shiftapi.MessageType[wsChatMsg]("chat"), + )) + + srv := httptest.NewServer(api) + defer srv.Close() + + ctx := context.Background() + conn, _, err := websocket.Dial(ctx, srv.URL+"/ws", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.CloseNow() + + // Send a discriminated message + envelope := map[string]any{"type": "command", "data": map[string]any{"command": "quit"}} + if err := wsjson.Write(ctx, conn, envelope); err != nil { + t.Fatalf("write: %v", err) + } + + // Read the echo + var resp shiftapi.WSEvent + if err := wsjson.Read(ctx, conn, &resp); err != nil { + t.Fatalf("read: %v", err) + } + if resp.Type != "chat" { + t.Errorf("resp.Type = %q, want %q", resp.Type, "chat") + } + var chat wsChatMsg + if err := resp.Decode(&chat); err != nil { + t.Fatalf("decode: %v", err) + } + if chat.Text != "got type=command" { + t.Errorf("chat.Text = %q, want %q", chat.Text, "got type=command") + } + conn.Close(websocket.StatusNormalClosure, "") +} + +func TestHandleWS_WithMessages_AsyncAPISpec(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleWS(api, "GET /ws", func(r *http.Request, _ struct{}, ws *shiftapi.WSConn[wsServerEvent, wsClientEvent]) error { + return nil + }, shiftapi.WithSendMessages( + shiftapi.MessageType[wsChatMsg]("chat"), + shiftapi.MessageType[wsSystemMsg]("system"), + ), shiftapi.WithRecvMessages( + shiftapi.MessageType[wsUserMsg]("message"), + shiftapi.MessageType[wsUserCmd]("command"), + )) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/asyncapi.json", nil) + api.ServeHTTP(w, r) + + var spec map[string]any + if err := json.NewDecoder(w.Body).Decode(&spec); err != nil { + t.Fatalf("decode spec: %v", err) + } + + channels := spec["channels"].(map[string]any) + ch := channels["/ws"].(map[string]any) + + // subscribe = server→client = Send with oneOf variants + sub := ch["subscribe"].(map[string]any) + subMsg := sub["message"].(map[string]any) + // The message should have a oneOf array (via MessageOneOf1.OneOf0) + subOneOf, ok := subMsg["oneOf"].([]any) + if !ok { + t.Fatal("subscribe message missing oneOf") + } + if len(subOneOf) != 2 { + t.Fatalf("subscribe oneOf has %d items, want 2", len(subOneOf)) + } + + // publish = client→server = Recv with oneOf variants + pub := ch["publish"].(map[string]any) + pubMsg := pub["message"].(map[string]any) + pubOneOf, ok := pubMsg["oneOf"].([]any) + if !ok { + t.Fatal("publish message missing oneOf") + } + if len(pubOneOf) != 2 { + t.Fatalf("publish oneOf has %d items, want 2", len(pubOneOf)) + } + + // Verify envelope schemas exist in components. + components := spec["components"].(map[string]any) + schemas := components["schemas"].(map[string]any) + + // Payload schemas + if _, ok := schemas["wsChatMsg"]; !ok { + t.Error("missing wsChatMsg schema") + } + if _, ok := schemas["wsSystemMsg"]; !ok { + t.Error("missing wsSystemMsg schema") + } + if _, ok := schemas["wsUserMsg"]; !ok { + t.Error("missing wsUserMsg schema") + } + if _, ok := schemas["wsUserCmd"]; !ok { + t.Error("missing wsUserCmd schema") + } + + // Envelope schemas + if _, ok := schemas["chat_wsChatMsg"]; !ok { + t.Error("missing chat_wsChatMsg envelope schema") + } + if _, ok := schemas["system_wsSystemMsg"]; !ok { + t.Error("missing system_wsSystemMsg envelope schema") + } +} + +func TestMessageType_EmptyNamePanics(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic for empty message name") + } + msg, ok := r.(string) + if !ok || !strings.Contains(msg, "must not be empty") { + t.Errorf("unexpected panic message: %v", r) + } + }() + shiftapi.MessageType[wsClientMsg]("") +} + +func TestWithSendMessages_DuplicateNamePanics(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic for duplicate message name") + } + msg, ok := r.(string) + if !ok || !strings.Contains(msg, "duplicate message name") { + t.Errorf("unexpected panic message: %v", r) + } + }() + api := shiftapi.New() + shiftapi.HandleWS(api, "GET /dup", func(r *http.Request, _ struct{}, ws *shiftapi.WSConn[wsServerEvent, wsClientEvent]) error { + return nil + }, shiftapi.WithSendMessages( + shiftapi.MessageType[wsChatMsg]("same"), + shiftapi.MessageType[wsSystemMsg]("same"), + )) +} + From eb06e3f3e196c52150d47ffb4fd0de597f1f7c04 Mon Sep 17 00:00:00 2001 From: "Frank Chiarulli Jr." Date: Tue, 10 Mar 2026 14:41:00 -0400 Subject: [PATCH 03/20] subscribe -> sse --- apps/landing/src/components/Features.tsx | 2 +- .../docs/docs/core-concepts/handlers.mdx | 2 +- .../docs/core-concepts/server-sent-events.mdx | 32 +++++++++---------- .../src/content/docs/docs/frontend/nextjs.mdx | 2 +- .../src/content/docs/docs/frontend/vite.mdx | 6 ++-- .../docs/getting-started/introduction.mdx | 2 +- .../create-shiftapi/templates/next/app/api.ts | 4 +-- .../templates/react/packages/api/src/index.ts | 4 +-- .../svelte/packages/api/src/index.ts | 4 +-- .../shiftapi/src/__tests__/generate.test.ts | 4 +-- packages/shiftapi/src/internal.ts | 4 +-- .../shiftapi/src/{subscribe.ts => sse.ts} | 14 ++++---- packages/shiftapi/src/templates.ts | 18 +++++------ 13 files changed, 49 insertions(+), 49 deletions(-) rename packages/shiftapi/src/{subscribe.ts => sse.ts} (88%) diff --git a/apps/landing/src/components/Features.tsx b/apps/landing/src/components/Features.tsx index 54669ce..4f2e579 100644 --- a/apps/landing/src/components/Features.tsx +++ b/apps/landing/src/components/Features.tsx @@ -27,7 +27,7 @@ const features = [ { icon: , title: "Real-Time", - desc: <>HandleSSE gives you typed server push with a subscribe helper. HandleWS adds typed bidirectional WebSockets with a connect helper., + desc: <>HandleSSE gives you typed server push with an sse helper. HandleWS adds typed bidirectional WebSockets with a connect helper., }, ]; diff --git a/apps/landing/src/content/docs/docs/core-concepts/handlers.mdx b/apps/landing/src/content/docs/docs/core-concepts/handlers.mdx index bdf7355..68b4488 100644 --- a/apps/landing/src/content/docs/docs/core-concepts/handlers.mdx +++ b/apps/landing/src/content/docs/docs/core-concepts/handlers.mdx @@ -225,7 +225,7 @@ ShiftAPI provides specialized handler functions for different use cases: | Function | Use case | |----------|----------| | `Handle` | JSON API endpoints (this page) | -| [`HandleSSE`](/docs/core-concepts/server-sent-events) | Server-Sent Events with a typed writer and auto-generated TypeScript `subscribe` helper | +| [`HandleSSE`](/docs/core-concepts/server-sent-events) | Server-Sent Events with a typed writer and auto-generated TypeScript `sse` helper | | [`HandleRaw`](/docs/core-concepts/raw-handlers) | File downloads, WebSocket upgrades, and other responses that need direct `ResponseWriter` access | See also: [Middleware](/docs/core-concepts/middleware), [Error Handling](/docs/core-concepts/error-handling), [Options](/docs/core-concepts/options). diff --git a/apps/landing/src/content/docs/docs/core-concepts/server-sent-events.mdx b/apps/landing/src/content/docs/docs/core-concepts/server-sent-events.mdx index 8a26e9d..5438b04 100644 --- a/apps/landing/src/content/docs/docs/core-concepts/server-sent-events.mdx +++ b/apps/landing/src/content/docs/docs/core-concepts/server-sent-events.mdx @@ -5,7 +5,7 @@ sidebar: order: 8 --- -`HandleSSE` is a dedicated handler for Server-Sent Events that gives you a typed writer, automatic SSE headers, and end-to-end type safety from Go to TypeScript — including a generated `subscribe` helper and framework-specific hooks. +`HandleSSE` is a dedicated handler for Server-Sent Events that gives you a typed writer, automatic SSE headers, and end-to-end type safety from Go to TypeScript — including a generated `sse` helper and framework-specific hooks. ## Registering SSE handlers @@ -167,16 +167,16 @@ You don't need to use `WithContentType` or `ResponseSchema` — `HandleSSE` sets ## TypeScript client -`shiftapi prepare` generates a fully-typed `subscribe` function constrained to SSE paths only — calling `subscribe` on a non-SSE path is a compile-time error. +`shiftapi prepare` generates a fully-typed `sse` function constrained to SSE paths only — calling `sse` on a non-SSE path is a compile-time error. -### subscribe +### sse -`subscribe` returns an async iterable stream with a `close()` method: +`sse` returns an async iterable stream with a `close()` method: ```typescript -import { subscribe } from "@shiftapi/client"; +import { sse } from "@shiftapi/client"; -const stream = subscribe("/events", { +const stream = sse("/events", { params: { query: { channel: "general" } }, }); @@ -196,7 +196,7 @@ You can also pass an `AbortSignal`: ```typescript const controller = new AbortController(); -const stream = subscribe("/events", { signal: controller.signal }); +const stream = sse("/events", { signal: controller.signal }); // later... controller.abort(); @@ -207,7 +207,7 @@ controller.abort(); Path parameters and request headers are type-checked. If your Go handler declares `path:"room_id"` or `header:"X-Token"` on the input struct, the generated types require them: ```typescript -const stream = subscribe("/rooms/{room_id}/events", { +const stream = sse("/rooms/{room_id}/events", { params: { path: { room_id: "abc" }, header: { "X-Token": "secret" }, @@ -217,17 +217,17 @@ const stream = subscribe("/rooms/{room_id}/events", { ### React -Use `subscribe` with React state. You control how events are accumulated: +Use `sse` with React state. You control how events are accumulated: ```tsx -import { subscribe } from "@myapp/api"; +import { sse } from "@myapp/api"; import { useEffect, useState } from "react"; function ChatFeed() { const [messages, setMessages] = useState([]); useEffect(() => { - const stream = subscribe("/chat"); + const stream = sse("/chat"); (async () => { for await (const event of stream) { setMessages((prev) => [...prev, event]); @@ -253,7 +253,7 @@ function TickerDisplay() { const [tick, setTick] = useState(null); useEffect(() => { - const stream = subscribe("/ticks"); + const stream = sse("/ticks"); (async () => { for await (const event of stream) setTick(event); })(); @@ -269,11 +269,11 @@ function TickerDisplay() { ```svelte