diff --git a/apps/landing/src/components/Features.tsx b/apps/landing/src/components/Features.tsx index b7d1853..4f2e579 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: "Real-Time", + 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 f4e905f..ccd08b4 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,15 @@ 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 `sse` helper | +| [`HandleWS`](/docs/core-concepts/websockets) | Bidirectional WebSocket with typed send/receive, auto upgrade, and auto-generated TypeScript `websocket` helper | +| [`HandleRaw`](/docs/core-concepts/raw-handlers) | File downloads, custom framing, 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..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,14 +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 +## 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) | `HandleRaw` + `WithContentType("text/event-stream")` | +| 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/server-sent-events.mdx b/apps/landing/src/content/docs/docs/core-concepts/server-sent-events.mdx new file mode 100644 index 0000000..5f6d46a --- /dev/null +++ b/apps/landing/src/content/docs/docs/core-concepts/server-sent-events.mdx @@ -0,0 +1,437 @@ +--- +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 + badge: + text: Experimental + variant: caution +--- + +:::caution +`HandleSSE` is experimental. The API may change in future releases. +::: + +`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 + +```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 +}, shiftapi.SSESends( + shiftapi.SSEEventType[Message]("message"), +)) +``` + +## 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 { + // ... +}, shiftapi.SSESends( + shiftapi.SSEEventType[Event]("event"), +)) +``` + +## SSEWriter + +`SSEWriter[Event]` provides a single method for sending events: + +### Send + +`Send` automatically determines the event name from the concrete Go type registered via `SSESends`: + +```go +sse.Send(Message{Text: "hello"}) +``` + +Produces: + +``` +event: message +data: {"text":"hello"} + +``` + +`Send` JSON-encodes the value, writes it in SSE format, and flushes 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 +}, shiftapi.SSESends( + shiftapi.SSEEventType[Event]("event"), +)) +``` + +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 +}, shiftapi.SSESends( + shiftapi.SSEEventType[Event]("event"), +)) +``` + +## 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`, 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.SSESends(shiftapi.SSEEventType[Event]("event")), + 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, + shiftapi.SSESends(shiftapi.SSEEventType[ChatEvent]("chat")), +) +``` + +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 `sse` function constrained to SSE paths only — calling `sse` on a non-SSE path is a compile-time error. + +### sse + +`sse` returns an async iterable stream with a `close()` method: + +```typescript +import { sse } from "@shiftapi/client"; + +const stream = sse("/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 = sse("/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 = sse("/rooms/{room_id}/events", { + params: { + path: { room_id: "abc" }, + header: { "X-Token": "secret" }, + }, +}); +``` + +### React + +Use `sse` with React state. You control how events are accumulated: + +```tsx +import { sse } from "@myapp/api"; +import { useEffect, useState } from "react"; + +function ChatFeed() { + const [messages, setMessages] = useState([]); + + useEffect(() => { + const stream = sse("/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 = sse("/ticks"); + (async () => { + for await (const event of stream) setTick(event); + })(); + return () => stream.close(); + }, []); + + if (!tick) return
Connecting...
; + return
Last tick: {tick.time}
; +} +``` + +### Svelte + +```svelte + + + +``` + +## Route options + +All standard route options work with `HandleSSE`: + +```go +shiftapi.HandleSSE(api, "GET /events", handler, + shiftapi.SSESends(shiftapi.SSEEventType[Event]("event")), + 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 | +| `SSESends(variants...)` | Declare discriminated union event types (see above) | + +## Discriminated union events + +For endpoints that emit multiple event types, use `SSESends` 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 SSESends + +Pass `SSEEventType[T]` descriptors to `SSESends` to declare each named event. When `SSESends` is used, `Send` automatically determines the event name from the concrete Go type — no need to pass the name manually: + +```go +shiftapi.HandleSSE(api, "GET /chat", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[ChatEvent]) error { + if err := sse.Send(MessageData{User: "alice", Text: "hi"}); err != nil { + return err + } + return sse.Send(JoinData{User: "bob"}) +}, shiftapi.SSESends( + shiftapi.SSEEventType[MessageData]("message"), + shiftapi.SSEEventType[JoinData]("join"), +)) +``` + +### Generated OpenAPI schema + +`SSESends` 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 { sse } from "@shiftapi/client"; + +const stream = sse("/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 + +`SSESends` is always required. For endpoints with a single event type, pass a single `SSEEventType`: + +```go +shiftapi.HandleSSE(api, "GET /ticks", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[Tick]) error { + return sse.Send(Tick{Time: time.Now()}) +}, shiftapi.SSESends( + shiftapi.SSEEventType[Tick]("tick"), +)) +``` + +With a single event type, the schema uses the type directly — no `oneOf`, no discriminator. Multiple event types produce a `oneOf` schema with a discriminator. + +## 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")` | +| Bidirectional real-time | [`HandleWS`](/docs/core-concepts/websockets) — typed handlers, auto dispatch | +| 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/core-concepts/websockets.mdx b/apps/landing/src/content/docs/docs/core-concepts/websockets.mdx new file mode 100644 index 0000000..1fcd18a --- /dev/null +++ b/apps/landing/src/content/docs/docs/core-concepts/websockets.mdx @@ -0,0 +1,500 @@ +--- +title: WebSockets +description: Use HandleWS for type-safe bidirectional WebSocket communication with automatic AsyncAPI spec generation and typed TypeScript clients. +sidebar: + order: 9 + badge: + text: Experimental + variant: caution +--- + +:::caution +`HandleWS` is experimental. The API may change in future releases. +::: + +`HandleWS` is a dedicated handler for WebSocket connections that gives you typed message handlers, 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 /echo", + shiftapi.Websocket( + func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) { return struct{}{}, nil }, + shiftapi.WSSends{shiftapi.WSMessageType[ServerMsg]("server")}, + shiftapi.WSOn("echo", func(s *shiftapi.WSSender, _ struct{}, msg ClientMsg) error { + return s.Send(ServerMsg{Text: "echo: " + msg.Text}) + }), + ), +) +``` + +## How it works + +`HandleWS` uses a **typed collector pattern** — you pass a setup function, send types, and typed handlers to `Websocket()` in a single call, then pass the result to `HandleWS`: + +- **`Websocket(setup, sends, handlers...)`** — creates a `*WSMessages[In]` value. Both `In` and `State` are inferred from the setup function's signature. The setup function runs after the WebSocket upgrade, receives the parsed input, and returns a `State` value that is passed to all handlers. +- **`WSOn("name", fn)`** — registers a typed handler for messages with the given type name. The `State` and `Msg` type parameters are inferred from the handler function. `State` must match the setup function's return type. +- **`WSSends{variants...}`** — a `[]WSMessageVariant` that registers named send types for auto-wrap envelopes and AsyncAPI schema generation. + +Everything is configured in one place — no mutation after construction. + +## WSOn handlers + +Each `WSOn` handler has this signature: + +```go +func(sender *shiftapi.WSSender, state State, msg Msg) error +``` + +- **`sender *WSSender`** — for sending messages and accessing the connection context via `sender.Context()` +- **`state State`** — the value returned by the setup function for this connection +- **`msg Msg`** — the decoded message payload + +The framework reads `{"type": "name", "data": ...}` envelopes from the client, matches `type` to the registered `WSOn` name, and decodes `data` into `Msg`. Unknown types are logged and skipped by default — use `WSOnUnknownMessage` to customize this. + +## WSSender + +`WSSender` provides methods for sending messages and managing the connection: + +### Send + +Writes a JSON-encoded message wrapped in a `{"type": name, "data": value}` envelope. The event name is automatically determined from the concrete Go type registered via `WSSends`: + +```go +s.Send(ChatMessage{User: "alice", Text: "hi"}) +// → {"type": "chat", "data": {"user": "alice", "text": "hi"}} +``` + +### Context + +Returns the connection's context, which is cancelled when the connection closes: + +```go +ctx := s.Context() +``` + +### Close + +Closes the connection with a status code and reason: + +```go +s.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"` +} + +type ChatState struct { + Room string + Token string +} + +shiftapi.HandleWS(api, "GET /rooms/{room}", + shiftapi.Websocket( + func(r *http.Request, s *shiftapi.WSSender, in ChatInput) (*ChatState, error) { + // in.Room and in.Token are parsed and validated before upgrade. + return &ChatState{Room: in.Room, Token: in.Token}, nil + }, + shiftapi.WSSends{shiftapi.WSMessageType[ChatMessage]("chat")}, + shiftapi.WSOn("message", func(s *shiftapi.WSSender, state *ChatState, m UserMessage) error { + return s.Send(ChatMessage{Room: state.Room, Text: m.Text}) + }), + ), +) +``` + +The setup function receives the parsed `In` value and returns a `State` that carries any needed data to handlers. + +## Connection setup and state + +The setup function (the first argument to `Websocket`) runs after the WebSocket upgrade but before the dispatch loop starts. It returns a `State` value that is passed to every `WSOn` handler, providing type-safe per-connection state: + +```go +type JoinInput struct { + Room string `path:"room" validate:"required"` +} + +type RoomState struct { + Room *Room + User *User +} + +shiftapi.HandleWS(api, "GET /rooms/{room}", + shiftapi.Websocket( + func(r *http.Request, s *shiftapi.WSSender, in JoinInput) (*RoomState, error) { + user, err := authenticate(r) + if err != nil { + return nil, err // closes connection with StatusInternalError + } + room := rooms.Join(in.Room, user) + return &RoomState{Room: room, User: user}, nil + }, + shiftapi.WSSends{shiftapi.WSMessageType[ChatMessage]("chat")}, + shiftapi.WSOn("message", func(s *shiftapi.WSSender, state *RoomState, m UserMessage) error { + return s.Send(ChatMessage{Room: state.Room.Name, User: state.User.Name, Text: m.Text}) + }), + ), +) +``` + +The `State` type parameter is inferred from the setup function and enforced at compile time across all `WSOn` handlers — if a handler's `State` type doesn't match the setup function's return type, the code won't compile. + +If the setup function returns an error, the connection is closed with `WSStatusInternalError` and the error is logged. + +Use `struct{}` as the state type when no per-connection state is needed. + +:::note +Use a **pointer type** for state (e.g. `*RoomState`) when handlers need to mutate shared state across messages. Value types are copied per handler call, so mutations would be lost. +::: + +### Cleanup on disconnect + +The request context (`r.Context()`) is cancelled when the WebSocket connection closes. Use this in the setup function to run cleanup logic: + +```go +func(r *http.Request, s *shiftapi.WSSender, in JoinInput) (*RoomState, error) { + room := rooms.Join(in.Room, user) + go func() { + <-r.Context().Done() + rooms.Leave(in.Room, user) + }() + return &RoomState{Room: room}, nil +} +``` + +## Error handling + +Error behavior depends on when the error occurs: + +- **Before upgrade** — if input parsing or validation fails, a JSON error response is written (just like `Handle`). +- **After upgrade** — if a `WSOn` handler returns an error, the connection is closed with `WSStatusInternalError` and the error is logged. Handle recoverable errors within the handler itself (return `nil` to continue). +- **Decode errors** — if a client sends a malformed payload that cannot be decoded into the expected type, the framework logs the error and continues reading. The connection is not closed. Use `WSOnDecodeError` to customize this behavior. +- **WebSocket close** — if a handler returns an error that is already a WebSocket close (e.g. client disconnect), the framework recognizes it and does not double-close. + +## Callbacks + +Callbacks are passed as `WSHandler` values to `Websocket()`, alongside `WSOn` handlers. They receive the same `(sender, state)` pair as message handlers: + +### WSOnDecodeError + +`WSOnDecodeError` registers a handler for message payloads that cannot be decoded into the expected type. If not registered, the framework logs the error and continues reading. The connection is never closed for decode errors. + +```go +shiftapi.Websocket( + func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (*RoomState, error) { + return &RoomState{}, nil + }, + shiftapi.WSSends{shiftapi.WSMessageType[ChatMessage]("chat")}, + shiftapi.WSOn("message", handleMessage), + shiftapi.WSOnDecodeError(func(s *shiftapi.WSSender, state *RoomState, err *shiftapi.WSDecodeError) { + log.Printf("bad payload for %s: %v", err.MessageType(), err.Unwrap()) + }), +) +``` + +### WSOnUnknownMessage + +`WSOnUnknownMessage` registers a handler for messages whose `type` field does not match any registered `WSOn` handler. If not registered, the framework logs the unknown type and continues reading. + +```go +shiftapi.Websocket( + func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (*RoomState, error) { + return &RoomState{}, nil + }, + shiftapi.WSSends{shiftapi.WSMessageType[ChatMessage]("chat")}, + shiftapi.WSOn("message", handleMessage), + shiftapi.WSOnUnknownMessage(func(s *shiftapi.WSSender, state *RoomState, msgType string, data json.RawMessage) { + log.Printf("unknown message type: %s", msgType) + }), +) +``` + +## 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 derived from `WSSends` and `WSOn` registrations: + +```go +shiftapi.HandleWS(api, "GET /echo", + shiftapi.Websocket( + func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) { return struct{}{}, nil }, + shiftapi.WSSends{shiftapi.WSMessageType[ServerMsg]("server")}, + shiftapi.WSOn("echo", func(s *shiftapi.WSSender, _ struct{}, msg ClientMsg) error { + return s.Send(ServerMsg{Text: msg.Text}) + }), + ), +) +``` + +Produces: + +```yaml +asyncapi: 2.4.0 +defaultContentType: application/json +channels: + /echo: + subscribe: + message: + name: server + payload: + $ref: '#/components/schemas/ServerMsg' + publish: + message: + name: echo + payload: + $ref: '#/components/schemas/ClientMsg' +``` + +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("/echo", { + 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 "@shiftapi/client"; +import { useEffect, useRef, useState } from "react"; + +function Chat() { + const [messages, setMessages] = useState([]); + const wsRef = useRef> | null>(null); + + useEffect(() => { + const ws = websocket("/echo"); + 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}
  • + ))} +
+ +
+ ); +} +``` + +## Multi-type messages + +For endpoints that send multiple message types, use `WSSends` to register each variant. This generates `oneOf` schemas with a `discriminator` on the `type` field, producing **TypeScript discriminated unions**. + +### Register with WSSends + +Pass `WSMessageType[T]` descriptors to declare each named send type. `WSSender.Send` automatically wraps the value in a `{"type": name, "data": value}` envelope based on its concrete Go type: + +```go +shiftapi.HandleWS(api, "GET /chat", + shiftapi.Websocket( + func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) { return struct{}{}, nil }, + shiftapi.WSSends{ + shiftapi.WSMessageType[ChatMessage]("chat"), + shiftapi.WSMessageType[SystemMessage]("system"), + }, + shiftapi.WSOn("message", func(s *shiftapi.WSSender, _ struct{}, m UserMessage) error { + return s.Send(ChatMessage{User: "server", Text: m.Text}) + }), + ), +) +``` + +### Wire format + +`WSSender.Send` wraps the payload automatically: + +```json +{"type": "chat", "data": {"user": "server", "text": "hello"}} +``` + +The client sends messages in the same envelope format, and the framework dispatches to the matching `WSOn` handler: + +```json +{"type": "message", "data": {"text": "hello"}} +``` + +### Generated AsyncAPI schema + +`WSSends` produces `oneOf` message definitions in the AsyncAPI spec with `{type, data}` envelope payloads: + +```yaml +channels: + /chat: + subscribe: + message: + oneOf: + - $ref: '#/components/messages/chat_ChatMessage' + - $ref: '#/components/messages/system_SystemMessage' + publish: + message: + name: message + payload: + $ref: '#/components/schemas/UserMessage' +``` + +### 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 + } +} +``` + +### Multiple receive handlers + +You can register multiple `WSOn` handlers for different client message types: + +```go +shiftapi.HandleWS(api, "GET /chat", + shiftapi.Websocket( + func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) { return struct{}{}, nil }, + shiftapi.WSSends{ + shiftapi.WSMessageType[ChatMessage]("chat"), + shiftapi.WSMessageType[SystemMessage]("system"), + }, + shiftapi.WSOn("message", func(s *shiftapi.WSSender, _ struct{}, m UserMessage) error { + return s.Send(ChatMessage{User: "echo", Text: m.Text}) + }), + shiftapi.WSOn("command", func(s *shiftapi.WSSender, _ struct{}, cmd UserCommand) error { + return s.Send(SystemMessage{Info: "executed: " + cmd.Command}) + }), + ), +) +``` + +Each `WSOn` provides both runtime dispatch and AsyncAPI schema generation — no separate type registration needed for receive messages. + +## 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", ws, + shiftapi.WithWSAcceptOptions(shiftapi.WSAcceptOptions{ + OriginPatterns: []string{"example.com"}, + }), +) +``` + +## Route options + +Standard route options and WS-specific options are passed to `HandleWS` (not to `Websocket`): + +```go +shiftapi.HandleWS(api, "GET /ws", ws, + 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 AsyncAPI summary, description, and tags | +| `WithError[T](code)` | Declare an error type at a status code (pre-upgrade only) | +| `WithMiddleware(mw...)` | Apply HTTP middleware | +| `WithWSAcceptOptions(opts)` | Configure WebSocket upgrade (origins, subprotocols) | + +The following are arguments to `Websocket()`: + +| Argument | Description | +|----------|-------------| +| `setup` | Connection setup function — returns `(State, error)` (required, first arg) | +| `WSSends{...}` | Server-to-client message types (required, second arg) | +| `WSOn("name", fn)` | Typed message handler — variadic (required, at least one) | +| `WSOnDecodeError(fn)` | Handle malformed message payloads (default: log + continue) | +| `WSOnUnknownMessage(fn)` | Handle unrecognized message types (default: log + continue) | + +## When to use HandleWS vs HandleSSE vs Handle + +| Use case | Recommendation | +|----------|---------------| +| Bidirectional real-time (chat, games) | `HandleWS` — typed handlers, auto dispatch, 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 72438ee..ceb5073 100644 --- a/apps/landing/src/content/docs/docs/frontend/nextjs.mdx +++ b/apps/landing/src/content/docs/docs/frontend/nextjs.mdx @@ -59,3 +59,40 @@ 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 an `sse` 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. + +### 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 94a20ab..e9e447e 100644 --- a/apps/landing/src/content/docs/docs/frontend/vite.mdx +++ b/apps/landing/src/content/docs/docs/frontend/vite.mdx @@ -67,3 +67,51 @@ 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 an `sse` function for SSE endpoints registered with `HandleSSE`. It returns a typed async iterable: + +```typescript +import { sse } from "@shiftapi/client"; + +const stream = sse("/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. + +### 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 5257920..2538f94 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,7 @@ 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 `sse` helper with React/Svelte hooks +- **WebSockets** — `HandleWS` gives you typed message handlers with automatic upgrade handling and a generated `websocket` 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..4ee7629 --- /dev/null +++ b/asyncapi.go @@ -0,0 +1,230 @@ +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 []WSMessageVariant, + recvVariants []WSMessageVariant, + 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). + if sendType != nil || len(sendVariants) > 0 { + 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). + if recvType != nil || len(recvVariants) > 0 { + 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 []WSMessageVariant) (*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 []WSMessageVariant) (*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/doc.go b/doc.go index b53af96..be0e7e7 100644 --- a/doc.go +++ b/doc.go @@ -119,6 +119,66 @@ // 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 +// }, shiftapi.SSESends( +// shiftapi.SSEEventType[ChatEvent]("chat"), +// )) +// +// [SSEWriter] automatically sets Content-Type, Cache-Control, and Connection +// headers on the first write. [SSEWriter.Send] automatically determines the +// event name from the concrete Go type registered via [SSESends]. +// +// [SSESends] is required for [HandleSSE]. It registers event types for +// auto-wrap and OpenAPI schema generation. For multiple event types, define +// a marker interface: +// +// 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.Send(MessageData{User: "alice", Text: "hi"}) +// return sse.Send(JoinData{User: "bob"}) +// }, shiftapi.SSESends( +// shiftapi.SSEEventType[MessageData]("message"), +// shiftapi.SSEEventType[JoinData]("join"), +// )) +// +// [SSESends] 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/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 f8df709..af8696f 100644 --- a/example_test.go +++ b/example_test.go @@ -341,6 +341,133 @@ 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 + }, shiftapi.SSESends( + shiftapi.SSEEventType[Message]("message"), + )) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/events", nil) + api.ServeHTTP(w, r) + fmt.Println(w.Body.String()) + // Output: + // event: message + // data: {"text":"hello"} + // + // event: message + // 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 ExampleSSESends() { + api := shiftapi.New() + + shiftapi.HandleSSE(api, "GET /chat", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[exChatEvent]) error { + if err := sse.Send(exMessageData{User: "alice", Text: "hi"}); err != nil { + return err + } + return sse.Send(exJoinData{User: "bob"}) + }, shiftapi.SSESends( + shiftapi.SSEEventType[exMessageData]("message"), + shiftapi.SSEEventType[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 ExampleHandleWS() { + api := shiftapi.New() + + type ServerMsg struct { + Text string `json:"text"` + } + type ClientMsg struct { + Text string `json:"text"` + } + + shiftapi.HandleWS(api, "GET /echo", + shiftapi.Websocket( + func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) { return struct{}{}, nil }, + shiftapi.WSSends{shiftapi.WSMessageType[ServerMsg]("server")}, + shiftapi.WSOn("echo", func(s *shiftapi.WSSender, _ struct{}, msg ClientMsg) error { + return s.Send(ServerMsg{Text: "echo: " + msg.Text}) + }), + ), + ) + + _ = api +} + +type exChatMessage struct { + User string `json:"user"` + Text string `json:"text"` +} + +type exSystemMessage struct { + Info string `json:"info"` +} + +type exUserMessage struct { + Text string `json:"text"` +} + +func ExampleHandleWS_multiType() { + api := shiftapi.New() + + shiftapi.HandleWS(api, "GET /chat", + shiftapi.Websocket( + func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) { return struct{}{}, nil }, + shiftapi.WSSends{ + shiftapi.WSMessageType[exChatMessage]("chat"), + shiftapi.WSMessageType[exSystemMessage]("system"), + }, + shiftapi.WSOn("message", func(s *shiftapi.WSSender, _ struct{}, m exUserMessage) error { + return s.Send(exChatMessage{User: "server", Text: m.Text}) + }), + ), + ) + + _ = api +} + func ExampleAPI_ServeHTTP() { api := shiftapi.New() diff --git a/examples/greeter/main.go b/examples/greeter/main.go index b8422cb..0406cfa 100644 --- a/examples/greeter/main.go +++ b/examples/greeter/main.go @@ -9,6 +9,14 @@ import ( "github.com/fcjr/shiftapi" ) +type ChatMsg struct { + Text string `json:"text"` +} + +type EchoReply struct { + Text string `json:"text"` +} + type Person struct { Name string `json:"name" validate:"required"` } @@ -170,7 +178,34 @@ func main() { }), ) + type JoinReq struct { + ID string `path:"id" validate:"required"` + } + + type State struct { + ID string + } + + shiftapi.HandleWS(api, "GET /join/{id}", + shiftapi.Websocket( + func(r *http.Request, s *shiftapi.WSSender, req JoinReq) (*State, error) { + return &State{ID: req.ID}, nil + }, + shiftapi.WSSends{shiftapi.WSMessageType[EchoReply]("echo")}, + shiftapi.WSOn("chat", func(s *shiftapi.WSSender, state *State, msg ChatMsg) error { + fmt.Println(state) + return s.Send(EchoReply{Text: "echo: " + msg.Text}) + }), + ), + 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 2e31763..9cfc699 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 @@ -201,6 +203,87 @@ func adaptRaw[In any](fn RawHandlerFunc[In], hc *handlerConfig) http.HandlerFunc } } +func adaptSSE[In, Event any](fn SSEHandlerFunc[In, Event], hc *handlerConfig, sendVariants map[reflect.Type]string) 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), + sendVariants: sendVariants, + } + 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 adaptWSMessages[In any]( + dispatch map[string]wsOnHandler, + sendVariants map[reflect.Type]string, + hc *handlerConfig, + wsOpts *WSAcceptOptions, + cb wsCallbacks, + setup func(r *http.Request, ws *WSSender, in In) (any, error), +) 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 := &WSSender{conn: conn, ctx: r.Context(), sendVariants: sendVariants} + + state, err := setup(r, ws, in) + if err != nil { + log.Printf("shiftapi: WS setup error: %v", err) + _ = conn.Close(websocket.StatusInternalError, "setup error") + return + } + + runWSDispatchLoop(r, conn, ws, state, dispatch, cb) + } +} + 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..df97267 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,246 @@ 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], + sseOpts sseRouteConfig, +) { + // SSESends is required — it provides both the auto-wrap event names + // and the schema for TypeScript type generation. + if len(sseOpts.eventVariants) == 0 { + panic(fmt.Sprintf("shiftapi: HandleSSE requires SSESends to define event types for %s %s", method, path)) + } + sendVariants := make(map[reflect.Type]string, len(sseOpts.eventVariants)) + seen := make(map[string]bool, len(sseOpts.eventVariants)) + for _, ev := range sseOpts.eventVariants { + name := ev.eventName() + if seen[name] { + panic(fmt.Sprintf("shiftapi: duplicate event name %q in SSESends for %s %s", name, method, path)) + } + seen[name] = true + sendVariants[ev.eventPayloadType()] = name + } + + // Build a routeConfig from the sseRouteConfig so we can reuse prepareRoute. + routeOpts := []RouteOption{} + if sseOpts.info != nil { + routeOpts = append(routeOpts, WithRouteInfo(*sseOpts.info)) + } + for _, e := range sseOpts.errors { + routeOpts = append(routeOpts, routeOptionFunc(func(cfg *routeConfig) { + cfg.addError(e) + })) + } + if len(sseOpts.middleware) > 0 { + routeOpts = append(routeOpts, routeOptionFunc(func(cfg *routeConfig) { + cfg.addMiddleware(sseOpts.middleware) + })) + } + for _, h := range sseOpts.staticRespHeaders { + routeOpts = append(routeOpts, routeOptionFunc(func(cfg *routeConfig) { + cfg.addStaticResponseHeader(h) + })) + } + + s := prepareRoute[In](router, method, path, false, routeOpts) + s.cfg.contentType = "text/event-stream" + s.cfg.eventVariants = sseOpts.eventVariants + + 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, sendVariants) + 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 +// }, shiftapi.SSESends( +// shiftapi.SSEEventType[Message]("message"), +// )) +func HandleSSE[In, Event any](router Router, pattern string, fn SSEHandlerFunc[In, Event], options ...SSEOption) { + method, path := parsePattern(pattern) + sseOpts := applySSEOptions(options) + registerSSERoute(router, method, path, fn, sseOpts) +} + + + +func registerWSRoute[In any]( + router Router, + method string, + path string, + msgs *WSMessages[In], + wsOpts wsRouteConfig, +) { + if len(msgs.cfg.handlers) == 0 { + panic(fmt.Sprintf("shiftapi: HandleWS requires at least one WSOn handler for %s %s", method, path)) + } + if len(msgs.cfg.sendVariants) == 0 { + panic(fmt.Sprintf("shiftapi: HandleWS requires WSSends to define server-to-client message types for %s %s", method, path)) + } + // Build a routeConfig from the wsRouteConfig so we can reuse prepareRoute. + routeOpts := []RouteOption{} + if wsOpts.info != nil { + routeOpts = append(routeOpts, WithRouteInfo(*wsOpts.info)) + } + for _, e := range wsOpts.errors { + routeOpts = append(routeOpts, routeOptionFunc(func(cfg *routeConfig) { + cfg.addError(e) + })) + } + if len(wsOpts.middleware) > 0 { + routeOpts = append(routeOpts, routeOptionFunc(func(cfg *routeConfig) { + cfg.addMiddleware(wsOpts.middleware) + })) + } + for _, h := range wsOpts.staticRespHeaders { + routeOpts = append(routeOpts, routeOptionFunc(func(cfg *routeConfig) { + cfg.addStaticResponseHeader(h) + })) + } + + s := prepareRoute[In](router, method, path, false, routeOpts) + + // Extract recv variants from On handlers. + recvVariants := make([]WSMessageVariant, len(msgs.cfg.handlers)) + for i, h := range msgs.cfg.handlers { + recvVariants[i] = rawWSMessageVariant{name: h.messageName(), payloadType: h.messagePayloadType()} + } + + // Validate no duplicate message names. + validateWSMessageVariants(msgs.cfg.sendVariants, "WSSends", method, path) + validateWSMessageVariants(recvVariants, "WSOn", method, path) + + // Build dispatch map for the receive loop. + dispatch := make(map[string]wsOnHandler, len(msgs.cfg.handlers)) + for _, h := range msgs.cfg.handlers { + dispatch[h.messageName()] = h + } + + // Build send variants map for WSSender auto-wrapping. + var sendVariantMap map[reflect.Type]string + if len(msgs.cfg.sendVariants) > 0 { + sendVariantMap = make(map[reflect.Type]string, len(msgs.cfg.sendVariants)) + for _, v := range msgs.cfg.sendVariants { + sendVariantMap[v.messagePayloadType()] = v.messageName() + } + } + + // Build path field map for AsyncAPI channel parameters. + 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 + } + } + } + } + + // For AsyncAPI, use nil types when variants are present (variants carry the types). + var sendType, recvType reflect.Type + if len(msgs.cfg.sendVariants) == 0 { + sendType = nil + } + if len(recvVariants) == 0 { + recvType = nil + } + + // Register in AsyncAPI spec. + if err := s.api.addWSChannel( + s.fullPath, sendType, recvType, + msgs.cfg.sendVariants, recvVariants, + wsOpts.info, pathFields, + ); err != nil { + panic(fmt.Sprintf("shiftapi: AsyncAPI generation failed for %s %s: %v", method, s.fullPath, err)) + } + + cb := wsCallbacks{ + onDecodeError: msgs.cfg.onDecodeError, + onUnknownMsg: msgs.cfg.onUnknownMsg, + } + + // Wrap the type-erased setup back into a typed function for adaptWSMessages. + typedSetup := func(r *http.Request, ws *WSSender, in In) (any, error) { + return msgs.cfg.setup(r, ws, in) + } + + hc := s.handlerCfg(method, false) + h := adaptWSMessages(dispatch, sendVariantMap, hc, wsOpts.wsAcceptOptions, cb, typedSetup) + s.wrapAndRegister(router, h) +} + + + +// HandleWS registers a WebSocket endpoint for the given pattern. Message +// handling is defined by [WSOn] handlers collected in a [Websocket] block. +// The framework manages the receive loop, dispatching incoming messages +// to the matching handler. +// +// Input parsing, validation, and middleware work identically to [Handle]. +// WebSocket endpoints are documented in an AsyncAPI 2.4 spec served at +// GET /asyncapi.json. +// +// shiftapi.HandleWS(api, "GET /chat", +// shiftapi.Websocket( +// func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) { return struct{}{}, nil }, +// shiftapi.WSSends{shiftapi.WSMessageType[ChatMessage]("chat")}, +// shiftapi.WSOn("message", func(s *shiftapi.WSSender, _ struct{}, m UserMessage) error { +// return s.Send(ChatMessage{User: "echo", Text: m.Text}) +// }), +// ), +// ) +func HandleWS[In any](router Router, pattern string, msgs *WSMessages[In], options ...WSOption) { + method, path := parsePattern(pattern) + wsOpts := applyWSOptions(options) + registerWSRoute(router, method, path, msgs, wsOpts) +} + +// rawWSMessageVariant is a non-generic WSMessageVariant implementation built +// from On handlers at registration time. +type rawWSMessageVariant struct { + name string + payloadType reflect.Type +} + +func (r rawWSMessageVariant) messageName() string { return r.name } +func (r rawWSMessageVariant) messagePayloadType() reflect.Type { return r.payloadType } + +func validateWSMessageVariants(variants []WSMessageVariant, 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 424ccfa..9d399e5 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 []SSEEventVariant // SSE event variants, set by registerSSERoute } func (c *routeConfig) addError(e errorEntry) { @@ -43,15 +44,29 @@ type RouteInfo struct { Tags []string } +// routeAndWSAndSSEOption implements RouteOption, WSOption, and SSEOption for +// options that need to work on Handle, HandleSSE, and HandleWS routes. +type routeAndWSAndSSEOption struct { + routeFn func(*routeConfig) + wsFn func(*wsRouteConfig) + sseFn func(*sseRouteConfig) +} + +func (o routeAndWSAndSSEOption) applyToRoute(cfg *routeConfig) { o.routeFn(cfg) } +func (o routeAndWSAndSSEOption) applyToWS(cfg *wsRouteConfig) { o.wsFn(cfg) } +func (o routeAndWSAndSSEOption) applyToSSE(cfg *sseRouteConfig) { o.sseFn(cfg) } + // WithRouteInfo sets the route's OpenAPI metadata (summary, description, tags). // // shiftapi.Handle(api, "POST /greet", greet, shiftapi.WithRouteInfo(shiftapi.RouteInfo{ // Summary: "Greet a person", // Tags: []string{"greetings"}, // })) -func WithRouteInfo(info RouteInfo) routeOptionFunc { - return func(cfg *routeConfig) { - cfg.info = &info +func WithRouteInfo(info RouteInfo) routeAndWSAndSSEOption { + return routeAndWSAndSSEOption{ + routeFn: func(cfg *routeConfig) { cfg.info = &info }, + wsFn: func(cfg *wsRouteConfig) { cfg.info = &info }, + sseFn: func(cfg *sseRouteConfig) { cfg.info = &info }, } } @@ -98,3 +113,4 @@ func WithContentType(contentType string, opts ...ResponseSchemaOption) routeOpti } } } + 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/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..b2bbeb1 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, sse } from "@shiftapi/client"; export const api = createClient(client); -export { client }; +export { client, sse }; 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..b2bbeb1 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, sse } from "@shiftapi/client"; export const api = createClient(client); -export { client }; +export { client, sse }; 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..aa5797a 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, sse } from "@shiftapi/client"; export const api = createClient(client); -export { client }; +export { client, sse }; 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/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/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..ef948a3 100644 --- a/packages/shiftapi/src/__tests__/generate.test.ts +++ b/packages/shiftapi/src/__tests__/generate.test.ts @@ -121,6 +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 { createSSE, createWebSocket } from "shiftapi/internal"'); + expect(source).toContain("export const sse = createSSE("); + 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 a19ef21..b1b1ca4 100644 --- a/packages/shiftapi/src/internal.ts +++ b/packages/shiftapi/src/internal.ts @@ -1,9 +1,14 @@ 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"; export { GoServerManager } from "./goServer"; export { findFreePort } from "./ports"; +export { createSSE } from "./sse"; +export type { SSEStream, SubscribeOptions, SSEFn } from "./sse"; +export { createWebSocket } from "./websocket"; +export type { WSConnection, WebSocketOptions, WebSocketFn } from "./websocket"; diff --git a/packages/shiftapi/src/sse.ts b/packages/shiftapi/src/sse.ts new file mode 100644 index 0000000..98a5420 --- /dev/null +++ b/packages/shiftapi/src/sse.ts @@ -0,0 +1,109 @@ +/** + * SSE stream returned by {@link createSSE}. + * An async iterable of parsed events with a `close()` method to abort. + */ +export interface SSEStream { + [Symbol.asyncIterator](): AsyncIterableIterator; + close(): void; +} + +/** Options accepted by an `sse` function created via {@link createSSE}. */ +export interface SubscribeOptions { + params?: { + path?: Record; + query?: Record; + header?: Record; + }; + signal?: AbortSignal; +} + +/** An SSE function returned by {@link createSSE}. */ +export type SSEFn = ( + path: string, + options?: SubscribeOptions, +) => SSEStream; + +/** + * Creates a type-safe SSE `sse` 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 createSSE(baseUrl: string) { + return function sse( + 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..5ea6478 100644 --- a/packages/shiftapi/src/templates.ts +++ b/packages/shiftapi/src/templates.ts @@ -31,14 +31,154 @@ 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";${wsImport} + + 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 sse

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

; signal?: AbortSignal } + ): SSEStream>; +${wsSection} export const client: ReturnType>; export { createClient }; } @@ -49,6 +189,7 @@ export function clientJsTemplate(baseUrl: string): string { return `\ // Auto-generated by shiftapi. Do not edit. import createClient from "openapi-fetch"; +import { createSSE, createWebSocket } from "shiftapi/internal"; /** Pre-configured, fully-typed API client. */ export const client = createClient({ @@ -56,6 +197,9 @@ export const client = createClient({ bodySerializer: ${BODY_SERIALIZER}, }); +export const sse = createSSE(${JSON.stringify(baseUrl)}); +export const websocket = createWebSocket(${JSON.stringify(baseUrl)}); + export { createClient }; `; } @@ -69,6 +213,7 @@ export function nextClientJsTemplate( return `\ // Auto-generated by @shiftapi/next. Do not edit. import createClient from "./openapi-fetch.js"; +import { createSSE, createWebSocket } from "shiftapi/internal"; const baseUrl = process.env.NEXT_PUBLIC_SHIFTAPI_BASE_URL || ${JSON.stringify(baseUrl)}; @@ -79,6 +224,9 @@ export const client = createClient({ bodySerializer: ${BODY_SERIALIZER}, }); +export const sse = createSSE(baseUrl); +export const websocket = createWebSocket(baseUrl); + export { createClient }; `; } @@ -87,6 +235,7 @@ export { createClient }; return `\ // Auto-generated by @shiftapi/next. Do not edit. import createClient from "./openapi-fetch.js"; +import { createSSE, createWebSocket } from "shiftapi/internal"; const baseUrl = process.env.NEXT_PUBLIC_SHIFTAPI_BASE_URL || @@ -100,6 +249,9 @@ export const client = createClient({ bodySerializer: ${BODY_SERIALIZER}, }); +export const sse = createSSE(baseUrl); +export const websocket = createWebSocket(baseUrl); + export { createClient }; `; } @@ -115,13 +267,19 @@ export function virtualModuleTemplate( return `\ // Auto-generated by @shiftapi/vite-plugin import createClient from "openapi-fetch"; +import { createSSE, createWebSocket } from "shiftapi/internal"; + +const baseUrl = ${baseUrlExpr}; /** Pre-configured, fully-typed API client. */ export const client = createClient({ - baseUrl: ${baseUrlExpr}, + baseUrl, bodySerializer: ${BODY_SERIALIZER}, }); +export const sse = createSSE(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/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..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) { @@ -143,6 +145,12 @@ 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"); + } + if (id === "shiftapi/internal" && importer === RESOLVED_MODULE_ID) { + return createRequire(import.meta.url).resolve("shiftapi/internal"); + } }, load(id) { @@ -172,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/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..9280c2d 100644 --- a/schema.go +++ b/schema.go @@ -32,6 +32,7 @@ type schemaInput struct { staticHeaders []staticResponseHeader contentType string responseSchemaType reflect.Type + eventVariants []SSEEventVariant // 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 registered event variants — 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/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/sse.go b/sse.go new file mode 100644 index 0000000..0853188 --- /dev/null +++ b/sse.go @@ -0,0 +1,164 @@ +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] 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. +// +// [SSEWriter.Send] automatically determines the event name from the concrete +// Go type registered via [SSESends]. On the first call, SSEWriter sets the +// required SSE headers (Content-Type, Cache-Control, Connection). +type SSEWriter[Event any] struct { + w http.ResponseWriter + rc *http.ResponseController + started bool + sendVariants map[reflect.Type]string +} + +// Send writes an SSE event. The event name is automatically determined from the +// concrete Go type registered via [SSESends]: +// +// event: {name}\ndata: {json}\n\n +// +// The response is flushed after each event. +func (s *SSEWriter[Event]) Send(v Event) error { + name, ok := s.sendVariants[reflect.TypeOf(v)] + if !ok { + return fmt.Errorf("shiftapi: unregistered SSE event type %T; register with SSESends", v) + } + 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", name, 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") +} + +// SSEEventVariant describes a named SSE event type for OpenAPI schema generation. +// Created by [SSEEventType] and passed to [SSESends]. +type SSEEventVariant interface { + eventName() string + eventPayloadType() reflect.Type +} + +type sseEventVariant[T any] struct { + name string +} + +func (e sseEventVariant[T]) eventName() string { return e.name } +func (e sseEventVariant[T]) eventPayloadType() reflect.Type { return reflect.TypeFor[T]() } + +// SSEEventType creates an [SSEEventVariant] that maps an SSE event name to a +// payload type T. Use with [SSESends] to register event types for a [HandleSSE] +// 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.SSESends( +// shiftapi.SSEEventType[MessageData]("message"), +// shiftapi.SSEEventType[JoinData]("join"), +// ), +// ) +func SSEEventType[T any](name string) SSEEventVariant { + if name == "" { + panic("shiftapi: SSEEventType name must not be empty") + } + return sseEventVariant[T]{name: name} +} + +// SSEOption configures a [HandleSSE] route. General options like +// [WithRouteInfo], [WithError], and [WithMiddleware] implement both +// [RouteOption] and [SSEOption]. SSE-specific options like [SSESends] +// implement only [SSEOption]. +type SSEOption interface { + applyToSSE(*sseRouteConfig) +} + +// sseRouteConfig holds the registration-time configuration for an SSE +// route, built from [SSEOption] values. +type sseRouteConfig struct { + info *RouteInfo + errors []errorEntry + middleware []func(http.Handler) http.Handler + staticRespHeaders []staticResponseHeader + eventVariants []SSEEventVariant +} + +func (c *sseRouteConfig) addError(e errorEntry) { + c.errors = append(c.errors, e) +} + +func (c *sseRouteConfig) addMiddleware(mw []func(http.Handler) http.Handler) { + c.middleware = append(c.middleware, mw...) +} + +func (c *sseRouteConfig) addStaticResponseHeader(h staticResponseHeader) { + c.staticRespHeaders = append(c.staticRespHeaders, h) +} + +func applySSEOptions(opts []SSEOption) sseRouteConfig { + var cfg sseRouteConfig + for _, opt := range opts { + opt.applyToSSE(&cfg) + } + return cfg +} + +// sseOptionFunc is a function that implements [SSEOption]. +type sseOptionFunc func(*sseRouteConfig) + +func (f sseOptionFunc) applyToSSE(cfg *sseRouteConfig) { f(cfg) } + +// SSESends registers named SSE event types for auto-wrap and OpenAPI schema +// generation. Each [SSEEventVariant] 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. SSESends +// is required for [HandleSSE]. +// +// When SSESends is used, [SSEWriter.Send] automatically determines the event +// name from the concrete Go type. +// +// shiftapi.HandleSSE(api, "GET /chat", chatHandler, +// shiftapi.SSESends( +// shiftapi.SSEEventType[MessageData]("message"), +// shiftapi.SSEEventType[JoinData]("join"), +// ), +// ) +func SSESends(variants ...SSEEventVariant) sseOptionFunc { + return func(cfg *sseRouteConfig) { + cfg.eventVariants = append(cfg.eventVariants, variants...) + } +} + +// Ensure that the shared Option type also implements SSEOption. +func (f Option) applyToSSE(cfg *sseRouteConfig) { f(cfg) } diff --git a/sse_test.go b/sse_test.go new file mode 100644 index 0000000..99abcc6 --- /dev/null +++ b/sse_test.go @@ -0,0 +1,430 @@ +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"}) + }, shiftapi.SSESends( + shiftapi.SSEEventType[sseMessage]("message"), + )) + + 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].Event != "message" { + t.Errorf("event[0].Event = %q, want %q", events[0].Event, "message") + } + if events[0].Data != `{"text":"hello"}` { + t.Errorf("event[0].Data = %q, want %q", events[0].Data, `{"text":"hello"}`) + } + if events[1].Event != "message" { + t.Errorf("event[1].Event = %q, want %q", events[1].Event, "message") + } + if events[1].Data != `{"text":"world"}` { + t.Errorf("event[1].Data = %q, want %q", events[1].Data, `{"text":"world"}`) + } +} + +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}) + }, shiftapi.SSESends( + shiftapi.SSEEventType[sseMessage]("message"), + )) + + 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"}) + }, shiftapi.SSESends( + shiftapi.SSEEventType[sseMessage]("message"), + )) + + 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") + }, shiftapi.SSESends( + shiftapi.SSEEventType[sseMessage]("message"), + )) + + 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}) + }, shiftapi.SSESends( + shiftapi.SSEEventType[sseMessage]("message"), + )) + + 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 + }, shiftapi.SSESends( + shiftapi.SSEEventType[sseMessage]("message"), + )) + + 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 (SSESends) 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_SSESends_AutoWrapSend(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleSSE(api, "GET /chat", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[chatEvent]) error { + if err := sse.Send(messageData{User: "alice", Text: "hi"}); err != nil { + return err + } + return sse.Send(joinData{User: "bob"}) + }, shiftapi.SSESends( + shiftapi.SSEEventType[messageData]("message"), + shiftapi.SSEEventType[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_SSESends_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.SSESends( + shiftapi.SSEEventType[messageData]("message"), + shiftapi.SSEEventType[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_MissingSSESendsPanics(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic for missing SSESends") + } + msg, ok := r.(string) + if !ok || !strings.Contains(msg, "requires SSESends") { + t.Errorf("unexpected panic message: %v", r) + } + }() + api := shiftapi.New() + shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[sseMessage]) error { + return nil + }) +} + +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.SSEEventType[sseMessage]("") +} + +func TestSSESends_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.SSESends( + shiftapi.SSEEventType[messageData]("same"), + shiftapi.SSEEventType[joinData]("same"), + )) +} + +func TestHandleSSE_SSESends_UnregisteredTypeErrors(t *testing.T) { + api := shiftapi.New() + + // joinData satisfies chatEvent but is not registered in SSESends. + shiftapi.HandleSSE(api, "GET /chat", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[chatEvent]) error { + return sse.Send(joinData{User: "bob"}) + }, shiftapi.SSESends( + shiftapi.SSEEventType[messageData]("message"), + )) + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/chat", nil) + api.ServeHTTP(w, r) + + // The handler returned an error before any events were written, + // so we expect a 500 error response. + if w.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want %d", w.Code, http.StatusInternalServerError) + } +} + +// 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 +} diff --git a/ws.go b/ws.go new file mode 100644 index 0000000..f6872b5 --- /dev/null +++ b/ws.go @@ -0,0 +1,410 @@ +package shiftapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "reflect" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" +) + +// WSSender is the send-only WebSocket connection passed to [WSOn] message +// handlers. It provides [WSSender.Send] for writing messages and +// [WSSender.Close] for closing the connection. +// +// [WSSender.Send] automatically wraps the value in a discriminated +// {"type", "data"} envelope based on the concrete Go type registered +// via [WSSends]. +type WSSender struct { + conn *websocket.Conn + ctx context.Context + sendVariants map[reflect.Type]string // nil = raw mode +} + +// Send writes a JSON-encoded message to the WebSocket connection. The value +// is automatically wrapped in a {"type": name, "data": value} envelope based +// on its concrete Go type, using the types registered via [WSSends]. +func (ws *WSSender) Send(v any) error { + name, ok := ws.sendVariants[reflect.TypeOf(v)] + if !ok { + return fmt.Errorf("shiftapi: unregistered send type %T; register with WSSends", v) + } + envelope := wsEnvelope[any]{Type: name, Data: v} + return wsjson.Write(ws.ctx, ws.conn, envelope) +} + +// Context returns the connection's context. It is cancelled when the +// WebSocket connection is closed. +func (ws *WSSender) Context() context.Context { + return ws.ctx +} + +// Close closes the WebSocket connection with the given status code and reason. +func (ws *WSSender) Close(status WSStatusCode, reason string) error { + return ws.conn.Close(websocket.StatusCode(status), reason) +} + +// 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 with raw +// data for deferred decoding. +type wsEvent struct { + Type string `json:"type"` + Data json.RawMessage `json:"data"` +} + +// websocketConfig holds the internal configuration for a WebSocket endpoint. +type websocketConfig struct { + handlers []wsOnHandler + sendVariants []WSMessageVariant + setup func(r *http.Request, ws *WSSender, input any) (any, error) + onDecodeError func(ws *WSSender, state any, err *WSDecodeError) + onUnknownMsg func(ws *WSSender, state any, msgType string, data json.RawMessage) +} + +// wsOnHandler is the internal interface for a typed message handler +// created by [WSOn]. It provides the message name and payload type for +// AsyncAPI schema generation, and a handle method for runtime dispatch. +type wsOnHandler interface { + messageName() string + messagePayloadType() reflect.Type + handle(sender *WSSender, state any, data json.RawMessage) error +} + +// onHandlerImpl is the concrete implementation of [wsOnHandler] created +// by the [WSOn] function. +type onHandlerImpl[State, Msg any] struct { + name string + fn func(ws *WSSender, state State, msg Msg) error +} + +func (h *onHandlerImpl[State, Msg]) messageName() string { return h.name } +func (h *onHandlerImpl[State, Msg]) messagePayloadType() reflect.Type { return reflect.TypeFor[Msg]() } + +func (h *onHandlerImpl[State, Msg]) handle(sender *WSSender, state any, data json.RawMessage) error { + var msg Msg + if err := json.Unmarshal(data, &msg); err != nil { + return &WSDecodeError{msgType: h.name, err: err} + } + return h.fn(sender, state.(State), msg) +} + +// WSDecodeError is returned when a WebSocket message payload cannot be +// decoded into the expected type. Decode errors are non-fatal — the +// framework logs them and continues reading. +type WSDecodeError struct { + msgType string + err error +} + +// MessageType returns the name of the message type that failed to decode. +func (e *WSDecodeError) MessageType() string { return e.msgType } + +func (e *WSDecodeError) Error() string { + return fmt.Sprintf("shiftapi: decode %q message: %v", e.msgType, e.err) +} + +func (e *WSDecodeError) Unwrap() error { return e.err } + +// WSMessages holds the WebSocket endpoint configuration. Create one with +// [Websocket], passing a setup function, [WSSends], and [WSOn] handlers. +// Pass it to [HandleWS] to register the route. +type WSMessages[In any] struct { + cfg *websocketConfig +} + +// WSHandler is a typed configuration unit for a [Websocket] endpoint. +// Create one with [WSOn], [WSOnDecodeError], or [WSOnUnknownMessage]. +// The State type parameter must match the setup function's return type. +type WSHandler[State any] struct { + apply func(cfg *websocketConfig) +} + +// WSSends declares the named server-to-client message types for a WebSocket +// endpoint. Pass [WSMessageType] values to register each type. +// [WSSender.Send] automatically wraps messages in a discriminated +// {"type", "data"} envelope based on the concrete Go type. +// +// shiftapi.WSSends( +// shiftapi.WSMessageType[ChatMessage]("chat"), +// shiftapi.WSMessageType[SystemMessage]("system"), +// ) +type WSSends []WSMessageVariant + +// Websocket creates a new WebSocket endpoint configuration. The type +// parameters In and State are both inferred from the setup function: +// In from the input parameter, State from the return value. Handlers +// receive the State value returned by setup on each connection. +// +// Use a pointer type for State (e.g. *MyState) when handlers need to +// mutate shared state across messages. Value types are copied per handler +// call, so mutations would be lost. +// +// Use struct{} for both In and State when no input or state is needed. +// +// shiftapi.HandleWS(api, "GET /echo", +// shiftapi.Websocket( +// func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) { +// return struct{}{}, nil +// }, +// shiftapi.WSSends(shiftapi.WSMessageType[ServerMsg]("server")), +// shiftapi.WSOn("echo", func(s *shiftapi.WSSender, _ struct{}, msg ClientMsg) error { +// return s.Send(ServerMsg{Text: msg.Text}) +// }), +// ), +// ) +func Websocket[In, State any](setup func(r *http.Request, sender *WSSender, in In) (State, error), sends WSSends, handlers ...WSHandler[State]) *WSMessages[In] { + cfg := &websocketConfig{ + sendVariants: []WSMessageVariant(sends), + setup: func(r *http.Request, ws *WSSender, input any) (any, error) { + return setup(r, ws, input.(In)) + }, + } + for _, h := range handlers { + h.apply(cfg) + } + return &WSMessages[In]{cfg: cfg} +} + +// WSOn creates a typed message handler for a [Websocket] endpoint. +// The State and Msg type parameters are inferred from the handler function. +// State must match the setup function's return type. +// +// shiftapi.WSOn("message", func(s *shiftapi.WSSender, state *Room, msg UserMessage) error { +// state.Broadcast(msg) +// return nil +// }) +func WSOn[State, Msg any](name string, fn func(sender *WSSender, state State, msg Msg) error) WSHandler[State] { + if name == "" { + panic("shiftapi: WSOn name must not be empty") + } + return WSHandler[State]{ + apply: func(cfg *websocketConfig) { + cfg.handlers = append(cfg.handlers, &onHandlerImpl[State, Msg]{name: name, fn: fn}) + }, + } +} + +// WSMessageVariant describes a named WebSocket message type for AsyncAPI schema +// generation. Created by [WSMessageType] and passed to [WSSends]. +type WSMessageVariant 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]() } + +// WSMessageType creates a [WSMessageVariant] that maps a message type name to a +// payload type T. Use with [WSSends] to register discriminated server-to-client +// message types for a WebSocket endpoint. +// +// shiftapi.WSSends( +// shiftapi.WSMessageType[ChatMessage]("chat"), +// shiftapi.WSMessageType[SystemMessage]("system"), +// ) +func WSMessageType[T any](name string) WSMessageVariant { + if name == "" { + panic("shiftapi: WSMessageType name must not be empty") + } + return messageVariant[T]{name: name} +} + +// wsCallbacks holds the optional user callbacks for the dispatch loop. +type wsCallbacks struct { + onDecodeError func(ws *WSSender, state any, err *WSDecodeError) + onUnknownMsg func(ws *WSSender, state any, msgType string, data json.RawMessage) +} + +// runWSDispatchLoop runs the framework-managed receive loop for multi-type +// WebSocket endpoints. It reads discriminated messages, dispatches to the +// matching [WSOn] handler, and stops on close or error. +func runWSDispatchLoop(r *http.Request, conn *websocket.Conn, ws *WSSender, state any, dispatch map[string]wsOnHandler, cb wsCallbacks) { + ctx := r.Context() + for { + var envelope wsEvent + if err := wsjson.Read(ctx, conn, &envelope); err != nil { + if websocket.CloseStatus(err) != -1 { + return // clean close + } + log.Printf("shiftapi: WS read error: %v", err) + _ = conn.Close(websocket.StatusInternalError, "internal error") + return + } + + handler, ok := dispatch[envelope.Type] + if !ok { + if cb.onUnknownMsg != nil { + cb.onUnknownMsg(ws, state, envelope.Type, envelope.Data) + } else { + log.Printf("shiftapi: unknown WS message type: %q", envelope.Type) + } + continue + } + + if err := handler.handle(ws, state, envelope.Data); err != nil { + if websocket.CloseStatus(err) != -1 { + return // handler triggered a close + } + // Decode errors are non-fatal — log and continue reading. + var decErr *WSDecodeError + if errors.As(err, &decErr) { + if cb.onDecodeError != nil { + cb.onDecodeError(ws, state, decErr) + } else { + log.Printf("shiftapi: %v", err) + } + continue + } + // Handler errors are fatal — log and close. + log.Printf("shiftapi: WS handler error: %v", err) + _ = conn.Close(websocket.StatusInternalError, "internal error") + return + } + } +} + +// WSOption configures a [HandleWS] route. General options like +// [WithRouteInfo], [WithError], and [WithMiddleware] implement both +// [RouteOption] and [WSOption]. WebSocket-specific options like +// [WithWSAcceptOptions] implement only [WSOption]. +type WSOption interface { + applyToWS(*wsRouteConfig) +} + +// wsRouteConfig holds the registration-time configuration for a WebSocket +// route, built from [WSOption] values. +type wsRouteConfig struct { + info *RouteInfo + errors []errorEntry + middleware []func(http.Handler) http.Handler + staticRespHeaders []staticResponseHeader + wsAcceptOptions *WSAcceptOptions +} + +func (c *wsRouteConfig) addError(e errorEntry) { + c.errors = append(c.errors, e) +} + +func (c *wsRouteConfig) addMiddleware(mw []func(http.Handler) http.Handler) { + c.middleware = append(c.middleware, mw...) +} + +func (c *wsRouteConfig) addStaticResponseHeader(h staticResponseHeader) { + c.staticRespHeaders = append(c.staticRespHeaders, h) +} + +func applyWSOptions(opts []WSOption) wsRouteConfig { + var cfg wsRouteConfig + for _, opt := range opts { + opt.applyToWS(&cfg) + } + return cfg +} + +// 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", ws, +// shiftapi.WithWSAcceptOptions(shiftapi.WSAcceptOptions{ +// Subprotocols: []string{"graphql-ws"}, +// OriginPatterns: []string{"example.com"}, +// }), +// ) +func WithWSAcceptOptions(opts WSAcceptOptions) wsOptionFunc { + return func(cfg *wsRouteConfig) { + cfg.wsAcceptOptions = &opts + } +} + +// WSOnDecodeError creates a handler that is called when a message payload +// cannot be decoded into the expected type. If not registered, the framework +// logs the error and continues reading. The connection is never closed for +// decode errors. The State type parameter must match the setup function's +// return type. +// +// shiftapi.WSOnDecodeError(func(s *shiftapi.WSSender, state *Room, err *shiftapi.WSDecodeError) { +// log.Printf("bad payload in room %s: %v", state.Name, err) +// }) +func WSOnDecodeError[State any](fn func(sender *WSSender, state State, err *WSDecodeError)) WSHandler[State] { + return WSHandler[State]{ + apply: func(cfg *websocketConfig) { + cfg.onDecodeError = func(ws *WSSender, state any, err *WSDecodeError) { + fn(ws, state.(State), err) + } + }, + } +} + +// WSOnUnknownMessage creates a handler that is called when the client sends +// a message whose "type" field does not match any registered [WSOn] handler. +// If not registered, the framework logs the unknown type and continues reading. +// The State type parameter must match the setup function's return type. +// +// shiftapi.WSOnUnknownMessage(func(s *shiftapi.WSSender, state *Room, msgType string, data json.RawMessage) { +// log.Printf("unknown message in room %s: %s", state.Name, msgType) +// }) +func WSOnUnknownMessage[State any](fn func(sender *WSSender, state State, msgType string, data json.RawMessage)) WSHandler[State] { + return WSHandler[State]{ + apply: func(cfg *websocketConfig) { + cfg.onUnknownMsg = func(ws *WSSender, state any, msgType string, data json.RawMessage) { + fn(ws, state.(State), msgType, data) + } + }, + } +} + +// wsOptionFunc is a function that implements [WSOption]. +type wsOptionFunc func(*wsRouteConfig) + +func (f wsOptionFunc) applyToWS(cfg *wsRouteConfig) { f(cfg) } + +// Ensure that the shared Option type also implements WSOption. +func (f Option) applyToWS(cfg *wsRouteConfig) { f(cfg) } diff --git a/ws_test.go b/ws_test.go new file mode 100644 index 0000000..181e1d2 --- /dev/null +++ b/ws_test.go @@ -0,0 +1,884 @@ +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 noSetup(r *http.Request, sender *shiftapi.WSSender, _ struct{}) (struct{}, error) { + return struct{}{}, nil +} + +func TestHandleWS_AsyncAPISpec(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleWS(api, "GET /ws", + shiftapi.Websocket( + noSetup, + shiftapi.WSSends{shiftapi.WSMessageType[wsServerMsg]("server")}, + shiftapi.WSOn("echo", func(sender *shiftapi.WSSender, _ struct{}, msg wsClientMsg) error { + return sender.Send(wsServerMsg(msg)) + }), + ), + 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") + } + + // 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"` + } + + type inputState struct { + Channel string + } + + shiftapi.HandleWS(api, "GET /ws", + shiftapi.Websocket( + func(r *http.Request, sender *shiftapi.WSSender, in Input) (*inputState, error) { + return &inputState{Channel: in.Channel}, nil + }, + shiftapi.WSSends{shiftapi.WSMessageType[wsServerMsg]("server")}, + shiftapi.WSOn("msg", func(sender *shiftapi.WSSender, state *inputState, msg wsClientMsg) error { + return sender.Send(wsServerMsg{Text: "channel=" + state.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() //nolint:errcheck + + // Send a message to trigger the handler. + if err := wsjson.Write(ctx, conn, map[string]any{"type": "msg", "data": map[string]any{"text": "hi"}}); err != nil { + t.Fatalf("write: %v", err) + } + + var envelope struct { + Type string `json:"type"` + Data wsServerMsg `json:"data"` + } + if err := wsjson.Read(ctx, conn, &envelope); err != nil { + t.Fatalf("read: %v", err) + } + if envelope.Data.Text != "channel=general" { + t.Errorf("got %q, want %q", envelope.Data.Text, "channel=general") + } + conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck +} + +func TestHandleWS_OnDispatch(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleWS(api, "GET /echo", + shiftapi.Websocket( + noSetup, + shiftapi.WSSends{shiftapi.WSMessageType[wsServerMsg]("server")}, + shiftapi.WSOn("echo", func(sender *shiftapi.WSSender, _ struct{}, msg wsClientMsg) error { + return sender.Send(wsServerMsg{Text: "echo: " + msg.Text}) + }), + ), + ) + + 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() //nolint:errcheck + + // Send and receive multiple messages. + for _, text := range []string{"hello", "world"} { + envelope := map[string]any{"type": "echo", "data": map[string]any{"text": text}} + if err := wsjson.Write(ctx, conn, envelope); err != nil { + t.Fatalf("write %q: %v", text, err) + } + var resp struct { + Type string `json:"type"` + Data wsServerMsg `json:"data"` + } + if err := wsjson.Read(ctx, conn, &resp); err != nil { + t.Fatalf("read: %v", err) + } + want := "echo: " + text + if resp.Data.Text != want { + t.Errorf("got %q, want %q", resp.Data.Text, want) + } + } + + conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck +} + +func TestHandleWS_AutoWrapSend(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleWS(api, "GET /ws", + shiftapi.Websocket( + noSetup, + shiftapi.WSSends{shiftapi.WSMessageType[wsServerMsg]("server")}, + shiftapi.WSOn("ping", func(sender *shiftapi.WSSender, _ struct{}, msg wsClientMsg) error { + return sender.Send(wsServerMsg{Text: "pong"}) + }), + ), + ) + + 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() //nolint:errcheck + + // Send a message + if err := wsjson.Write(ctx, conn, map[string]any{"type": "ping", "data": map[string]any{"text": "hi"}}); err != nil { + t.Fatalf("write: %v", err) + } + + // Read — should be wrapped in envelope {"type":"server","data":{...}} + var envelope struct { + Type string `json:"type"` + Data wsServerMsg `json:"data"` + } + if err := wsjson.Read(ctx, conn, &envelope); err != nil { + t.Fatalf("read: %v", err) + } + if envelope.Type != "server" { + t.Errorf("envelope.Type = %q, want %q", envelope.Type, "server") + } + if envelope.Data.Text != "pong" { + t.Errorf("envelope.Data.Text = %q, want %q", envelope.Data.Text, "pong") + } + conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck +} + +func TestHandleWS_ErrorBeforeUpgrade(t *testing.T) { + api := shiftapi.New() + + type Input struct { + Token string `query:"token" validate:"required"` + } + + shiftapi.HandleWS(api, "GET /ws", + shiftapi.Websocket( + func(r *http.Request, sender *shiftapi.WSSender, in Input) (struct{}, error) { + return struct{}{}, nil + }, + shiftapi.WSSends{shiftapi.WSMessageType[wsServerMsg]("server")}, + shiftapi.WSOn("msg", func(sender *shiftapi.WSSender, _ struct{}, msg 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", + shiftapi.Websocket( + noSetup, + shiftapi.WSSends{shiftapi.WSMessageType[wsServerMsg]("server")}, + shiftapi.WSOn("msg", func(sender *shiftapi.WSSender, _ struct{}, msg 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() //nolint:errcheck + + // Send a message to trigger the handler error. + if err := wsjson.Write(ctx, conn, map[string]any{"type": "msg", "data": map[string]any{"text": "hi"}}); err != nil { + t.Fatalf("write: %v", err) + } + + // 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_WSOnUnknownMessage(t *testing.T) { + api := shiftapi.New() + + var gotType string + shiftapi.HandleWS(api, "GET /ws", + shiftapi.Websocket( + noSetup, + shiftapi.WSSends{shiftapi.WSMessageType[wsServerMsg]("server")}, + shiftapi.WSOn("msg", func(sender *shiftapi.WSSender, _ struct{}, msg wsClientMsg) error { + return sender.Send(wsServerMsg{Text: "ok"}) + }), + shiftapi.WSOnUnknownMessage(func(sender *shiftapi.WSSender, _ struct{}, msgType string, data json.RawMessage) { + gotType = msgType + sender.Send(wsServerMsg{Text: "unknown: " + msgType}) //nolint:errcheck + }), + ), + ) + + 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() //nolint:errcheck + + // Send an unknown message type. + if err := wsjson.Write(ctx, conn, map[string]any{"type": "bogus", "data": map[string]any{"text": "hi"}}); err != nil { + t.Fatalf("write: %v", err) + } + + // The callback should have sent a response. + var envelope struct { + Type string `json:"type"` + Data wsServerMsg `json:"data"` + } + if err := wsjson.Read(ctx, conn, &envelope); err != nil { + t.Fatalf("read: %v", err) + } + if envelope.Data.Text != "unknown: bogus" { + t.Errorf("got %q, want %q", envelope.Data.Text, "unknown: bogus") + } + if gotType != "bogus" { + t.Errorf("gotType = %q, want %q", gotType, "bogus") + } + conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck +} + +func TestHandleWS_WithWSAcceptOptions(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleWS(api, "GET /ws", + shiftapi.Websocket( + noSetup, + shiftapi.WSSends{shiftapi.WSMessageType[wsServerMsg]("server")}, + shiftapi.WSOn("msg", func(sender *shiftapi.WSSender, _ struct{}, msg wsClientMsg) error { + return sender.Send(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() //nolint:errcheck + + // 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") + } + + // Send a message to trigger the handler. + if err := wsjson.Write(ctx, conn, map[string]any{"type": "msg", "data": map[string]any{"text": "hi"}}); err != nil { + t.Fatalf("write: %v", err) + } + + var envelope struct { + Type string `json:"type"` + Data wsServerMsg `json:"data"` + } + if err := wsjson.Read(ctx, conn, &envelope); err != nil { + t.Fatalf("read: %v", err) + } + if envelope.Data.Text != "ok" { + t.Errorf("got %q, want %q", envelope.Data.Text, "ok") + } + conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck +} + +func TestHandleWS_PathParams(t *testing.T) { + api := shiftapi.New() + + type Input struct { + ID string `path:"id"` + } + + type pathState struct { + ID string + } + + shiftapi.HandleWS(api, "GET /rooms/{id}", + shiftapi.Websocket( + func(r *http.Request, sender *shiftapi.WSSender, in Input) (*pathState, error) { + return &pathState{ID: in.ID}, nil + }, + shiftapi.WSSends{shiftapi.WSMessageType[wsServerMsg]("server")}, + shiftapi.WSOn("msg", func(sender *shiftapi.WSSender, state *pathState, msg wsClientMsg) error { + return sender.Send(wsServerMsg{Text: "room=" + state.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() //nolint:errcheck + + // Send a message to trigger the handler. + if err := wsjson.Write(ctx, conn, map[string]any{"type": "msg", "data": map[string]any{"text": "hi"}}); err != nil { + t.Fatalf("write: %v", err) + } + + var envelope struct { + Type string `json:"type"` + Data wsServerMsg `json:"data"` + } + if err := wsjson.Read(ctx, conn, &envelope); err != nil { + t.Fatalf("read: %v", err) + } + if envelope.Data.Text != "room=abc" { + t.Errorf("got %q, want %q", envelope.Data.Text, "room=abc") + } + conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck +} + +// --- Multi-message (WSSends) tests --- + +type wsChatMsg struct { + User string `json:"user"` + Text string `json:"text"` +} + +type wsSystemMsg struct { + Info string `json:"info"` +} + +type wsUserMsg struct { + Text string `json:"text"` +} + +type wsUserCmd struct { + Command string `json:"command"` +} + +func TestHandleWS_MultiTypeDispatch(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleWS(api, "GET /ws", + shiftapi.Websocket( + noSetup, + shiftapi.WSSends{ + shiftapi.WSMessageType[wsChatMsg]("chat"), + shiftapi.WSMessageType[wsSystemMsg]("system"), + }, + shiftapi.WSOn("message", func(sender *shiftapi.WSSender, _ struct{}, m wsUserMsg) error { + return sender.Send(wsChatMsg{User: "server", Text: "got: " + m.Text}) + }), + shiftapi.WSOn("command", func(sender *shiftapi.WSSender, _ struct{}, cmd wsUserCmd) error { + return sender.Send(wsSystemMsg{Info: "executed: " + cmd.Command}) + }), + ), + ) + + 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() //nolint:errcheck + + // Send a "message" type + if err := wsjson.Write(ctx, conn, map[string]any{"type": "message", "data": map[string]any{"text": "hello"}}); err != nil { + t.Fatalf("write: %v", err) + } + + var msg1 struct { + Type string `json:"type"` + Data wsChatMsg `json:"data"` + } + 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") + } + if msg1.Data.Text != "got: hello" { + t.Errorf("msg1.Data.Text = %q, want %q", msg1.Data.Text, "got: hello") + } + + // Send a "command" type + if err := wsjson.Write(ctx, conn, map[string]any{"type": "command", "data": map[string]any{"command": "quit"}}); err != nil { + t.Fatalf("write: %v", err) + } + + var msg2 struct { + Type string `json:"type"` + Data wsSystemMsg `json:"data"` + } + 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") + } + if msg2.Data.Info != "executed: quit" { + t.Errorf("msg2.Data.Info = %q, want %q", msg2.Data.Info, "executed: quit") + } + + conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck +} + +func TestHandleWS_WithMessages_AsyncAPISpec(t *testing.T) { + api := shiftapi.New() + + shiftapi.HandleWS(api, "GET /ws", + shiftapi.Websocket( + noSetup, + shiftapi.WSSends{ + shiftapi.WSMessageType[wsChatMsg]("chat"), + shiftapi.WSMessageType[wsSystemMsg]("system"), + }, + shiftapi.WSOn("message", func(sender *shiftapi.WSSender, _ struct{}, m wsUserMsg) error { + return nil + }), + shiftapi.WSOn("command", func(sender *shiftapi.WSSender, _ struct{}, cmd wsUserCmd) error { + return nil + }), + ), + ) + + 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) + 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 (from On handlers) + 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.WSMessageType[wsClientMsg]("") +} + +func TestHandleWS_DuplicateSendNamePanics(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", + shiftapi.Websocket( + noSetup, + shiftapi.WSSends{ + shiftapi.WSMessageType[wsChatMsg]("same"), + shiftapi.WSMessageType[wsSystemMsg]("same"), + }, + shiftapi.WSOn("msg", func(sender *shiftapi.WSSender, _ struct{}, m wsClientMsg) error { + return nil + }), + ), + ) +} + +func TestHandleWS_DuplicateOnNamePanics(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic for duplicate On 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", + shiftapi.Websocket( + noSetup, + shiftapi.WSSends{shiftapi.WSMessageType[wsServerMsg]("server")}, + shiftapi.WSOn("msg", func(sender *shiftapi.WSSender, _ struct{}, m wsClientMsg) error { + return nil + }), + shiftapi.WSOn("msg", func(sender *shiftapi.WSSender, _ struct{}, m wsUserMsg) error { + return nil + }), + ), + ) +} + +func TestHandleWS_Setup(t *testing.T) { + api := shiftapi.New() + + type joinInput struct { + Room string `query:"room"` + } + + type roomState struct { + Room string + } + + shiftapi.HandleWS(api, "GET /chat", + shiftapi.Websocket( + func(r *http.Request, sender *shiftapi.WSSender, in joinInput) (*roomState, error) { + if in.Room == "" { + return nil, fmt.Errorf("room required") + } + return &roomState{Room: in.Room}, nil + }, + shiftapi.WSSends{shiftapi.WSMessageType[wsServerMsg]("server")}, + shiftapi.WSOn("message", func(sender *shiftapi.WSSender, state *roomState, msg wsClientMsg) error { + return sender.Send(wsServerMsg{Text: "[" + state.Room + "] " + msg.Text}) + }), + ), + ) + + srv := httptest.NewServer(api) + defer srv.Close() + + ctx := context.Background() + + // Test successful setup — query param available to handlers. + conn, _, err := websocket.Dial(ctx, srv.URL+"/chat?room=general", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.CloseNow() //nolint:errcheck + + envelope := map[string]any{"type": "message", "data": map[string]any{"text": "hello"}} + if err := wsjson.Write(ctx, conn, envelope); err != nil { + t.Fatalf("write: %v", err) + } + + var resp struct { + Type string `json:"type"` + Data wsServerMsg `json:"data"` + } + if err := wsjson.Read(ctx, conn, &resp); err != nil { + t.Fatalf("read: %v", err) + } + if resp.Data.Text != "[general] hello" { + t.Errorf("got %q, want %q", resp.Data.Text, "[general] hello") + } + conn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck +} + +func TestHandleWS_Setup_Error(t *testing.T) { + api := shiftapi.New() + + type joinInput struct { + Room string `query:"room"` + } + + shiftapi.HandleWS(api, "GET /chat", + shiftapi.Websocket( + func(r *http.Request, sender *shiftapi.WSSender, in joinInput) (struct{}, error) { + return struct{}{}, fmt.Errorf("setup failed") + }, + shiftapi.WSSends{shiftapi.WSMessageType[wsServerMsg]("server")}, + shiftapi.WSOn("message", func(sender *shiftapi.WSSender, _ struct{}, msg wsClientMsg) error { + return sender.Send(wsServerMsg{Text: "should not reach"}) + }), + ), + ) + + srv := httptest.NewServer(api) + defer srv.Close() + + ctx := context.Background() + conn, _, err := websocket.Dial(ctx, srv.URL+"/chat?room=general", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.CloseNow() //nolint:errcheck + + // The connection should be closed by the server due to setup error. + var msg json.RawMessage + err = wsjson.Read(ctx, conn, &msg) + if err == nil { + t.Fatal("expected error reading from connection closed by setup failure") + } + if websocket.CloseStatus(err) != websocket.StatusInternalError { + t.Errorf("close status = %d, want %d (StatusInternalError)", websocket.CloseStatus(err), websocket.StatusInternalError) + } +} + +func TestOn_EmptyNamePanics(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic for empty On name") + } + msg, ok := r.(string) + if !ok || !strings.Contains(msg, "must not be empty") { + t.Errorf("unexpected panic message: %v", r) + } + }() + shiftapi.WSOn("", func(sender *shiftapi.WSSender, _ struct{}, m wsClientMsg) error { + return nil + }) +} + +func TestWebsocket_NoHandlersPanics(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic for no On handlers") + } + msg, ok := r.(string) + if !ok || !strings.Contains(msg, "at least one WSOn handler") { + t.Errorf("unexpected panic message: %v", r) + } + }() + api := shiftapi.New() + shiftapi.HandleWS(api, "GET /ws", + shiftapi.Websocket( + noSetup, + shiftapi.WSSends{shiftapi.WSMessageType[wsServerMsg]("server")}, + ), + ) +}