diff --git a/.changeset/remix-adapter.md b/.changeset/remix-adapter.md new file mode 100644 index 0000000000..f1f2fad3fc --- /dev/null +++ b/.changeset/remix-adapter.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-remix': minor +--- + +Add `@tanstack/ai-remix` with Remix 3 `createChat` and a typed headless chat UI on `@tanstack/ai-remix/ui`. Call `createChatHook({ options, ...components })` once at module scope, then `createAppChat(handle)` in setup. diff --git a/docs/api/ai-remix.md b/docs/api/ai-remix.md new file mode 100644 index 0000000000..f8f6aa9d1a --- /dev/null +++ b/docs/api/ai-remix.md @@ -0,0 +1,396 @@ +--- +title: "@tanstack/ai-remix" +id: ai-remix +order: 9 +description: "API reference for @tanstack/ai-remix. Remix 3 helpers including createChat for streaming chat with full type safety." +keywords: + - tanstack ai + - "@tanstack/ai-remix" + - remix + - createChat + - createChatHook + - api reference +--- + +Install `@tanstack/ai-remix`, then call `createChat(handle, options)` in a Remix setup function. The package publishes uncompiled source. Remix compiles JSX through `jsxImportSource` `remix/ui`. + +For a typed headless chat UI, see [Remix Chat UI](../ui/remix). Import `createChatHook` from `@tanstack/ai-remix/ui`. + +## Installation + + + +remix: @tanstack/ai-remix remix + + + +`remix` is a required peer. + +## Server + +A Remix controller action can return the same SSE `Response` as any other host. + +```typescript +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { createController } from 'remix/router' +import { post, route } from 'remix/routes' + +const routes = route({ + chat: { + stream: post('/chat'), + }, +}) + +export default createController(routes.chat, { + actions: { + async stream({ request }) { + const { messages, threadId, runId } = + await chatParamsFromRequest(request) + + const stream = chat({ + adapter: openaiText('gpt-5.6'), + messages, + threadId, + runId, + }) + + return toServerSentEventsResponse(stream) + }, + }, +}) +``` + +The matching client calls `createChat` and streams from that route. See [Quick Start](../getting-started/quick-start). + +## `createChat(handle, options)` + +Manages chat state in a Remix component. Pass the component `Handle` as the first argument. Put `connection` and `tools` in setup. They are not serializable `clientEntry` props. + +```tsx +import { createChat, fetchServerSentEvents } from '@tanstack/ai-remix' +import { + createChatClientOptions, + type InferChatMessages, +} from '@tanstack/ai-client' +import { toolDefinition } from '@tanstack/ai' +import { clientEntry, on, type Handle } from 'remix/ui' +import { z } from 'zod' + +const updateUIDef = toolDefinition({ + name: 'updateUI', + description: 'Show a notification in the UI', + inputSchema: z.object({ message: z.string() }), +}) + +export const ChatComponent = clientEntry( + import.meta.url, + function ChatComponent(handle: Handle) { + let notification: string | null = null + const updateUI = updateUIDef.client((input) => { + notification = input.message + return { success: true } + }) + const tools = [updateUI] + + const chatOptions = createChatClientOptions({ + connection: fetchServerSentEvents('/chat'), + tools, + }) + + type ChatMessages = InferChatMessages + + const chat = createChat(handle, chatOptions) + + return () => ( +
+ {notification} + {chat.isLoading ? 'Loading' : null} + {chat.error ? chat.error.message : null} + + {chat.messages.length} +
+ ) + }, +) +``` + +Read `chat.messages` and `chat.isLoading` in the render function so each paint sees the latest values. The default thread id is `options.threadId ?? handle.id`. When `handle.signal` aborts, cleanup runs. + +### Options you pass first + +Extends `ChatClientOptions` from `@tanstack/ai-client`. Pass `connection` or `fetcher`, not both. `Handle` is the first argument, not an option. + +- `connection` or `fetcher` - how the helper talks to your server +- `tools?` - client tool implementations from `.client()` +- `threadId?` - the only identity for this chat. Required when persistence is on +- `initialMessages?` - starting transcript +- `forwardedProps?` - JSON sent to the server on the AG-UI `forwardedProps` field + +### Options you add later + +- `live?` - subscribe on setup, unsubscribe on dispose +- `queue?` - what to do when `sendMessage` runs while a turn is in flight. Default queues +- `interrupts?` - typed interrupt definitions +- `context?` - client-only runtime context for client tools. Not sent to the server +- `onResponse?` / `onChunk?` / `onFinish?` / `onError?` / `onInterruptStateChange?` +- `devtools?` - display options. The helper always tags `framework: 'remix'` +- `body?` - deprecated. Use `forwardedProps` + +Client tools run automatically. + +### Returns + +```typescript +import type { UIMessage } from '@tanstack/ai-remix' +import type { ModelMessage } from '@tanstack/ai/client' +import type { + BoundInterrupts, + MultimodalContent, + ChatClientState, + ConnectionStatus, + QueuedMessage, + SendMessageOptions, +} from '@tanstack/ai-client' + +interface CreateChatReturn { + messages: Array + sendMessage: ( + content: string | MultimodalContent, + options?: SendMessageOptions, + ) => Promise + append: (message: ModelMessage | UIMessage) => Promise + addToolResult: (result: { + toolCallId: string + tool: string + output: unknown + state?: 'output-available' | 'output-error' + errorText?: string + }) => Promise + interrupts: BoundInterrupts + resolveInterrupts: (approved: boolean) => void + reload: () => Promise + stop: () => void + isLoading: boolean + error: Error | undefined + status: ChatClientState + isSubscribed: boolean + connectionStatus: ConnectionStatus + sessionGenerating: boolean + setMessages: (messages: Array) => void + clear: () => void + queue: Array + cancelQueued: (id: string) => void + runId: string | null +} +``` + +State fields are getters. `queue` holds sends that wait while a run is busy. `runId` is the in-flight turn, or `null`. + +For a tool with `needsApproval: true`, read `chat.interrupts`. Call `interrupt.resolveInterrupt(true)` on the bound `tool-approval` item. + +## Connection adapters + +Re-exported from `@tanstack/ai-client`: + +```typescript +import { + fetchServerSentEvents, + fetchHttpStream, + stream, + type ConnectionAdapter, +} from '@tanstack/ai-remix' +``` + +## Example: basic chat + +```tsx +import { createChat, fetchServerSentEvents } from '@tanstack/ai-remix' +import { clientEntry, on, type Handle } from 'remix/ui' + +export const Chat = clientEntry( + import.meta.url, + function Chat(handle: Handle) { + const chat = createChat(handle, { + connection: fetchServerSentEvents('/chat'), + }) + + return () => ( +
+ {chat.messages.map((message) => ( +
+ {message.role}: + {message.parts + .filter((part) => part.type === 'text') + .map((part) => part.content) + .join('')} +
+ ))} +
{ + event.preventDefault() + const form = event.currentTarget + const text = String(new FormData(form).get('message') ?? '').trim() + if (text === '') { + return + } + form.reset() + void chat.sendMessage(text) + })} + > + + +
+
+ ) + }, +) +``` + +## Headless chat UI + +For a typed chat layout with your own Remix components, see [Remix Chat UI](../ui/remix). Call `createChatHook` from `@tanstack/ai-remix/ui` once at module scope. Your app calls `createAppChat(handle)` and renders ``. + +## Other helpers + +Each helper takes the Remix `Handle` as the first argument. Call it in setup. + +### `createByok(handle, client)` + +Subscribe to a BYOK snapshot. The return is a getter for the latest snapshot. + +```tsx +import { createByok } from '@tanstack/ai-remix' +import type { Handle } from 'remix/ui' +import { byok } from './byok' + +function Keys(handle: Handle) { + const getSnapshot = createByok(handle, byok) + return () =>

{JSON.stringify(getSnapshot().status)}

+} +``` + +Call `byok.update(provider, value)` from your own UI to save a key. See [Bring Your Own Key](../advanced/byok). + +### `createRealtimeChat(handle, options)` + +Realtime voice chat. Pass `getToken` and `adapter`. + +```tsx +import { createRealtimeChat } from '@tanstack/ai-remix' +import { openaiRealtime } from '@tanstack/ai-openai' +import { on, type Handle } from 'remix/ui' + +function VoiceChat(handle: Handle) { + const chat = createRealtimeChat(handle, { + getToken: () => fetch('/api/realtime-token').then((response) => response.json()), + adapter: openaiRealtime(), + }) + + return () => ( +
+

Status: {chat.status}

+ +
+ ) +} +``` + +### `createGeneration(handle, options)` + +Base helper for one-shot generation. Pass `connection` or `fetcher`. Call `generate()`. + +```tsx +import { createGeneration, fetchServerSentEvents } from '@tanstack/ai-remix' +import { on, type Handle } from 'remix/ui' + +function CustomGenerator(handle: Handle) { + const gen = createGeneration(handle, { + connection: fetchServerSentEvents('/api/generate/custom'), + }) + + return () => ( +
+ + {gen.isLoading ?

Generating

: null} +
+ ) +} +``` + +### `createGenerateImage(handle, options)` + +Image generation. `generate()` accepts `ImageGenerateInput`. The result is `ImageGenerationResult`. + +```tsx +import { createGenerateImage, fetchServerSentEvents } from '@tanstack/ai-remix' +import { on, type Handle } from 'remix/ui' + +function ImageGenerator(handle: Handle) { + const image = createGenerateImage(handle, { + connection: fetchServerSentEvents('/api/generate/image'), + }) + + return () => ( +
+ + {image.isLoading ?

Generating

: null} +
+ ) +} +``` + +The package also exports `createGenerateAudio`, `createGenerateSpeech`, `createGenerateVideo`, `createTranscription`, `createSummarize`, `createAudioRecorder`, and `createMcpAppBridge`. + +## Types + +Re-exported from `@tanstack/ai-client`: + +- `UIMessage` +- `ChatClientOptions` +- `InferChatMessages` +- `QueuedMessage`, `SendMessageOptions`, `WhenBusy` + +## Next + +- [Quick Start](../getting-started/quick-start) +- [Tools](../tools/tools) +- [Client tools](../tools/client-tools) diff --git a/docs/config.json b/docs/config.json index 81edfac676..65ba392298 100644 --- a/docs/config.json +++ b/docs/config.json @@ -13,13 +13,13 @@ "label": "Overview", "to": "getting-started/overview", "addedAt": "2026-04-15", - "updatedAt": "2026-08-24" + "updatedAt": "2026-09-01" }, { "label": "Quick Start", "to": "getting-started/quick-start", "addedAt": "2026-04-15", - "updatedAt": "2026-08-30" + "updatedAt": "2026-09-01" }, { "label": "Quick Start: React Native", @@ -269,6 +269,11 @@ "addedAt": "2026-09-01", "updatedAt": "2026-09-01" }, + { + "label": "Remix", + "to": "ui/remix", + "addedAt": "2026-09-01" + }, { "label": "Angular", "to": "ui/angular", @@ -279,7 +284,7 @@ "label": "Custom Adapters", "to": "ui/custom-adapters", "addedAt": "2026-08-26", - "updatedAt": "2026-08-26" + "updatedAt": "2026-09-01" } ] }, @@ -291,7 +296,8 @@ "label": "Overview", "to": "ui/recipes/index", "tab": "guides", - "addedAt": "2026-08-31" + "addedAt": "2026-08-31", + "updatedAt": "2026-09-01" }, { "label": "Chat with no tools", @@ -1119,7 +1125,7 @@ "label": "Chat UI packages", "to": "migration/create-ui", "addedAt": "2026-08-26", - "updatedAt": "2026-08-31" + "updatedAt": "2026-09-01" }, { "label": "From Vercel AI SDK", @@ -1196,6 +1202,12 @@ "label": "@tanstack/ai-octane", "to": "api/ai-octane", "addedAt": "2026-08-21" + }, + { + "label": "@tanstack/ai-remix", + "to": "api/ai-remix", + "addedAt": "2026-09-01", + "updatedAt": "2026-09-01" } ] }, diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md index 33e6e765d8..487161718f 100644 --- a/docs/getting-started/overview.md +++ b/docs/getting-started/overview.md @@ -34,6 +34,7 @@ The framework-agnostic core of TanStack AI provides the building blocks for crea - **Next.js** - API routes and App Router - **TanStack Start** - React Start or Solid Start (recommended!) +- **Remix 3** - Controllers return SSE. Call `createChat` in a `clientEntry` island - **React Native / Expo** - Native chat screens with `useChat`, absolute server URLs, and XHR streaming transports - **Express** - Node.js server - **React Router v7** - Loaders and actions diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index a4a93bb51c..0e337177e7 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -18,6 +18,7 @@ keywords: - preact - angular - octane + - remix redirect_from: - /getting-started/quick-start-vue - /getting-started/quick-start-svelte @@ -46,6 +47,7 @@ preact: @tanstack/ai @tanstack/ai-preact @tanstack/ai-openai angular: @tanstack/ai @tanstack/ai-angular @tanstack/ai-openai vanilla: @tanstack/ai @tanstack/ai-client @tanstack/ai-openai octane: @tanstack/ai @tanstack/ai-octane @tanstack/ai-openai octane +remix: @tanstack/ai @tanstack/ai-remix @tanstack/ai-openai remix @@ -77,6 +79,8 @@ export async function POST(request: Request) { This works with TanStack Start, Next.js, SvelteKit, Hono, and any host that returns a Web `Response`. +A Remix controller action can return this same `Response`. + If your server is Node streams (Express), see [Quick Start: Server Only](./quick-start-server). Put the API key on the server: @@ -465,6 +469,65 @@ The hook calls `attach()` on mount. It calls `detach()` and `dispose()` on unmou See the [Octane API](../api/ai-octane). +# Remix + +Call `createChat(handle, options)` from `@tanstack/ai-remix` inside a `clientEntry` island. Put `connection` and `tools` in setup. They are not serializable `clientEntry` props. + +`@tanstack/ai-remix` publishes uncompiled source. Remix compiles JSX through `jsxImportSource` `remix/ui`. Bind the form with the Remix `on` mixin. + +```tsx ignore +import { createChat, fetchServerSentEvents } from "@tanstack/ai-remix"; +import { clientEntry, on, type Handle } from "remix/ui"; + +export const Chat = clientEntry( + import.meta.url, + function Chat(handle: Handle) { + const chat = createChat(handle, { + connection: fetchServerSentEvents("/api/chat"), + }); + + return () => ( +
+ {chat.messages.map((message) => ( +
+ {message.parts.map((part, index) => + part.type === "text" ?

{part.content}

: null, + )} +
+ ))} +
{ + event.preventDefault(); + const form = event.currentTarget; + const text = String( + new FormData(form).get("message") ?? "", + ).trim(); + if (text === "") { + return; + } + form.reset(); + void chat.sendMessage(text); + })} + > + + {chat.isLoading ? ( + + ) : ( + + )} +
+
+ ); + }, +); +``` + +Read `chat.messages` and `chat.isLoading` in the render function so each paint sees the latest values. + +See the [Remix API](../api/ai-remix). For a typed headless chat UI, see [Remix Chat UI](../ui/remix). + Send a message. Tokens show up in the UI. diff --git a/docs/migration/create-ui.md b/docs/migration/create-ui.md index 71af110a2e..ff7fbb19c1 100644 --- a/docs/migration/create-ui.md +++ b/docs/migration/create-ui.md @@ -37,6 +37,7 @@ New imports: - `@tanstack/ai-preact` (next minor) `/ui` - `@tanstack/ai-octane` (next minor) `/ui` - `@tanstack/ai-angular` (next minor) `/ui` +- `@tanstack/ai-remix` (next minor) `/ui` Deprecated re-exports, removed in `1.0.0`: @@ -50,4 +51,4 @@ Old `*-ui` packages and new `/ui` imports can live in the same app until `1.0.0` ## Typed chat UI -The `/ui` subpath also exports `createChatHook`, a typed headless chat UI that derives tool, part, and interrupt components from your chat options. It is new, not a replacement you need to move to. The components above keep working. See the [React UI guide](../ui/react) for how it fits together, and [Solid](../ui/solid), [Vue](../ui/vue), [Svelte](../ui/svelte), [Preact](../ui/preact), [Octane](../ui/octane), or [Angular](../ui/angular) for the other adapters. +The `/ui` subpath also exports `createChatHook`, a typed headless chat UI that derives tool, part, and interrupt components from your chat options. It is new, not a replacement you need to move to. The components above keep working. See the [React UI guide](../ui/react) for how it fits together, and [Solid](../ui/solid), [Vue](../ui/vue), [Svelte](../ui/svelte), [Preact](../ui/preact), [Octane](../ui/octane), [Remix](../ui/remix), or [Angular](../ui/angular) for the other adapters. diff --git a/docs/ui/angular.md b/docs/ui/angular.md index d6aaccdf51..f333d2ef29 100644 --- a/docs/ui/angular.md +++ b/docs/ui/angular.md @@ -1,7 +1,7 @@ --- title: Angular Chat UI id: typed-headless-ui-angular -order: 7 +order: 8 description: "Build a typed, headless Angular chat UI with createChatHook. Widgets are standalone components. Chat state is signals." keywords: - tanstack ai diff --git a/docs/ui/custom-adapters.md b/docs/ui/custom-adapters.md index 96d9277eaa..8a82002b92 100644 --- a/docs/ui/custom-adapters.md +++ b/docs/ui/custom-adapters.md @@ -31,4 +31,4 @@ Call `selectChatUI({ messages, interrupts, inlineToolNames })`. Automatic traver Warn once per missing runtime key in development. Each build tool detects development mode differently, so the adapter prints the warning. -See the [React](./react), [Solid](./solid), [Vue](./vue), and [Svelte](./svelte) adapters for the public names to match: `Chat`, `Provider`, `Messages`, `Message`, `Part`, `Interrupts`, `Interrupt`, `useChatContext`, and `createChatHookContexts`. +See the [React](./react), [Solid](./solid), [Vue](./vue), [Svelte](./svelte), and [Remix](./remix) adapters for the public names to match: `Chat`, `Provider`, `Messages`, `Message`, `Part`, `Interrupts`, `Interrupt`, `useChatContext`, and `createChatHookContexts`. diff --git a/docs/ui/recipes/index.md b/docs/ui/recipes/index.md index d88a662ce6..a8808c7ad6 100644 --- a/docs/ui/recipes/index.md +++ b/docs/ui/recipes/index.md @@ -20,6 +20,6 @@ Start with the first if you have not built a chat here yet. 4. [Ask the user your own question](./custom-interrupt). Define an interrupt with your own schemas and render it. 5. [Send the current user to the server](./request-context). Pass a tenant or user id per request, out of the prompt. -The code is React. The [Solid](../solid), [Vue](../vue), and [Svelte](../svelte) guides use the same option groups and the same component names, so each example maps across with only the framework syntax changing. +The code is React. The [Solid](../solid), [Vue](../vue), [Svelte](../svelte), and [Remix](../remix) guides use the same option groups and the same component names, so each example maps across with only the framework syntax changing. For the full component map in one place, see the [React guide](../react). diff --git a/docs/ui/remix.md b/docs/ui/remix.md new file mode 100644 index 0000000000..be5fb1f322 --- /dev/null +++ b/docs/ui/remix.md @@ -0,0 +1,148 @@ +--- +title: Remix Chat UI +id: typed-headless-ui-remix +order: 7 +description: "Build a typed, headless Remix 3 chat UI with createChatHook. Widgets are Remix setup functions." +keywords: + - tanstack ai + - createChatHook + - remix + - headless ui + - ToolProps +--- + +Install `@tanstack/ai-remix`. Import the UI factory from `@tanstack/ai-remix/ui`. Call `createChatHook({ options, ...components })` once at module scope. Your app calls `createAppChat(handle)` in a Remix setup function. Render ``. + +The factory needs a `toolsComponents` entry for every tool name in `chatOptions`. It also needs an `interruptsComponents.generic` entry for every interrupt id. `generic.fallback` is optional. Widgets go in `components`, `partsComponents`, `toolsComponents`, and `interruptsComponents`, the same way Form and Table register components. + +Each widget is a Remix setup function. Read props from `handle.props`. If a widget needs live chat, call `useChatContext(handle)` in setup. + +The server route matches the [React page](./react). Use `gpt-5.6` on the OpenAI text adapter. + +The [chat UI recipes](./recipes/index) show the same option groups one at a time. The code there is React, and the shape carries over. + +## Client + +```tsx +import { fetchServerSentEvents } from '@tanstack/ai-remix' +import { + createChatHook, + type LayoutProps, + type MessageProps, + type PartProps, + type ToolProps, +} from '@tanstack/ai-remix/ui' +import { toolDefinition } from '@tanstack/ai' +import { clientEntry, type Handle } from 'remix/ui' +import { z } from 'zod' + +const getWeather = toolDefinition({ + name: 'getWeather', + description: 'Look up weather', + inputSchema: z.object({ city: z.string() }), + outputSchema: z.object({ temperature: z.number() }), +}).client() + +const chatOptions = { + connection: fetchServerSentEvents('/api/chat'), + tools: [getWeather], +} + +const { createAppChat, ui } = createChatHook({ + options: chatOptions, + components: { + layout(handle: Handle>) { + return () => { + const { Messages, Interrupts, Queue, Input } = handle.props + return ( +
+ + + + +
+ ) + } + }, + message(handle: Handle>) { + return () => { + const { Parts } = handle.props + return ( +
+ +
+ ) + } + }, + }, + partsComponents: { + fallback(handle: Handle>) { + return () => {handle.props.part.type} + }, + }, + toolsComponents: { + getWeather(handle: Handle>) { + return () => {handle.props.part.input?.city} + }, + }, +}) + +export const ChatScreen = clientEntry( + import.meta.url, + function ChatScreen(handle: Handle) { + const chat = createAppChat(handle) + return () => + }, +) +``` + +`layout` receives these components. Render them as tags, not as calls: + +- `Messages` +- `Interrupts` +- `Queue` +- `Input` + +Register `input` on `components` to draw the composer. Until then, `Input` renders nothing. Register `queue` on `components` to draw pending sends. Call `item.cancelQueued()` on a queue item to drop it. `message` receives `Parts`. A tool with an approval receives prop `interrupt`. + +## Type a component in its own file + +Type the `handle.props` of a tool file with `ToolProps`. Share the same `chatOptions` module that you pass to `createChatHook`. + +```tsx +import type { ToolProps } from '@tanstack/ai-remix/ui' +import type { Handle } from 'remix/ui' +import { chatOptions } from './chat-options' + +export function getWeather(handle: Handle>) { + return () => {handle.props.part.input?.city} +} +``` + +Part components use `PartProps`. Then `part` is already a text part. + +Interrupt components use `InterruptProps`. Then `interrupt.payload` matches the definition. + +Mapped components do not receive `chat` as a prop. If a component needs live chat, call `ui.useChatContext(handle)`. + +## Read chat from `ui.useChatContext(handle)` + +Import the same `ui` kit in a child file. Call `ui.useChatContext(handle)` only under `ui.Chat` or `ui.Provider`. Call it in setup, not in the render function. + +```tsx +import { ui } from './chat-ui' +import type { Handle } from 'remix/ui' + +function MessageCount(handle: Handle) { + const chat = ui.useChatContext(handle) + return () =>

{chat.messages.length} messages

+} +``` + +`createAppChat(handle)` owns the state. `ui.useChatContext(handle)` reads the instance you passed into `ui.Chat`. A call outside that tree throws. + +## Interrupts + +Tool approvals sit in the tool when you read the `interrupt` prop. Put a component on `interruptsComponents.tools` to send that approval to the list instead. Generic interrupts always sit in the list under `interruptsComponents.generic`: `{ choosePlan, fallback }`. An unbound interrupt uses `fallback`. If the copy must differ, branch on `interrupt.kind === 'unbound'`. + +The full map is on the [React page](./react). diff --git a/examples/ts-remix-chat/.agents/skills/remix/SKILL.md b/examples/ts-remix-chat/.agents/skills/remix/SKILL.md new file mode 100644 index 0000000000..51de239512 --- /dev/null +++ b/examples/ts-remix-chat/.agents/skills/remix/SKILL.md @@ -0,0 +1,452 @@ +--- +name: remix +description: Build and review Remix 3 applications using the `remix` npm package and subpath imports. Use when working on Remix app structure, routes, controllers, middleware, validation, data access, auth, sessions, file uploads, server setup, UI components, hydration, HMR, navigation, or tests. +--- + +# Build a Remix App + +Use this skill for end-to-end Remix app work. This skill helps you choose the right layer first, reach for the right package, and avoid the most common Remix-specific mistakes. + +## Full Package Documentation + +This skill is the quick guide. When you need fuller API documentation, examples, or package-specific details for a `remix/*` subpath, first look for a README next to the relevant generated source file in the published `remix` package: `node_modules/remix/src//README.md`. These published README files are generated mirrors; in the Remix source repository, the canonical README lives in the owning `packages/*` package and the `packages/remix/src/**/README.md` mirrors are intentionally ignored. If that README does not exist, look for the nearest parent README because some subpaths share their parent package documentation. + +Examples: + +- `remix/router` -> `node_modules/remix/src/fetch-router/README.md` +- `remix/ui/button` -> `node_modules/remix/src/ui/button/README.md` + +## What Remix Is + +Remix 3 is a server-first web framework built on Web APIs such as `Request`, `Response`, `URL`, and `FormData`. All packages ship from a single npm package, `remix`, and are imported via subpath. There is no top-level `remix` import. + +A Remix app has four main pieces: + +- **Routes** in `app/routes.ts` define the typed URL contract and power `href()` generation. +- **Controllers** in `app/actions` implement that contract and return `Response` objects. +- **Middleware** composes request lifecycle behavior and populates typed context via `context.set(Key, value)`. +- **Components** render UI with `remix/ui`. This is not React. A component receives a `handle`, reads current props from `handle.props`, and returns a zero-argument render function. + +## When To Use This Skill + +Use this skill for: + +- new features or refactors that touch routing, controllers, middleware, data, auth, sessions, UI, or tests +- reviewing Remix app code for correctness, architecture, or framework usage +- answering "how should this be structured in Remix?" questions +- finding the right package, reference doc, or default pattern for a task + +## Load Only The References You Need + +Classify the task first, then load the smallest useful reference set. Each reference file starts with a "What This Covers" section that lists the topics inside it — read that first to confirm the file is relevant before reading the rest. + +Use the table below to find candidates. Loading more than two or three files at once is usually a sign that the task hasn't been narrowed enough yet. + +| Task involves... | Start with | +| ------------------------------------------------------------------------------------------- | ------------------------------------------- | +| Defining URLs, writing controllers and actions, returning responses | `references/routing-and-controllers.md` | +| Composing the request lifecycle, ordering middleware, bridging to a server, development HMR | `references/middleware-and-server.md` | +| Compiling and serving browser modules, asset URL namespaces, preloads, browser HMR | `references/assets-and-browser-modules.md` | +| Parsing input, validating with schemas, defining tables, querying, migrations | `references/data-and-validation.md` | +| Per-browser state, login flows, route protection, identity | `references/auth-and-sessions.md` | +| Component setup, state, lifecycle, updates, `queueTask`, context | `references/component-model.md` | +| Event handlers, styles, refs, click/key behavior, simple animations | `references/mixins-styling-events.md` | +| `clientEntry`, `run`, ``, navigation, browser HMR update handling, `` | `references/hydration-frames-navigation.md` | +| Router tests, component tests, test isolation | `references/testing-patterns.md` | +| Spring physics, tweens, layout transitions | `references/animate-elements.md` | +| Authoring custom reusable mixins | `references/create-mixins.md` | + +Common bundles: + +- **Form or CRUD feature** -> routing, data and validation, testing; add auth if user-specific +- **Protected area** -> auth and sessions, routing, testing +- **Interactive widget** -> component model, mixins and styling; add hydration only if it runs in the browser +- **Browser asset pipeline** -> assets and browser modules, hydration, middleware and server +- **Development HMR** -> middleware and server, assets and browser modules, hydration +- **File upload** -> middleware and server, data and validation, testing +- **Navigation or frames** -> hydration, frames, navigation + +## Default Workflow + +1. **Classify the change.** Decide whether it changes the route contract, request lifecycle, data model, auth or session behavior, or only UI. +2. **Start from the server contract.** Add or update `app/routes.ts` before wiring handlers or UI. +3. **Put code in the narrowest owner.** Favor route-local code first, then promote only when reuse is real. +4. **Make the server path correct before adding browser behavior.** A route should return the right `Response` via `router.fetch(...)` before you add `clientEntry(...)`, animations, or DOM effects. +5. **Add middleware deliberately.** Keep fast-exit middleware early and request-enriching middleware later. Export a typed `AppContext` from the middleware stack and use it in controllers. +6. **Validate input at the boundary.** Parse and validate `Request`, `FormData`, params, cookies, and external payloads before they reach rendering or persistence logic. +7. **Hydrate only when necessary.** Prefer server-rendered UI. Use `clientEntry(...)` and `run(...)` only for real browser interactivity or browser-only APIs. +8. **Test the narrowest meaningful layer.** Prefer router tests for route behavior. Use component tests when the behavior is truly interactive or DOM-specific. +9. **Finish with verification.** Re-read the route flow, confirm auth and authorization boundaries, and run the smallest relevant test and typecheck loop. + +## Project Layout + +Use these root directories consistently: + +- `app/` for runtime application code +- `db/` for migrations and local database files +- root `public/` for static assets served as-is from the app root +- `test/` for shared helpers, fixtures, and integration coverage +- `tmp/` for uploads, caches, local session files, and other scratch data + +Inside `app/`, organize by responsibility: + +- `actions/` for controller-owned route handlers, route-local response rendering, and route-local UI/helpers that are not shared across route areas +- `data/` for schema, queries, persistence setup, migrations, and runtime data initialization +- `middleware/` for request lifecycle concerns such as auth, sessions, uploads, and database injection +- `public/` directories inside the narrowest owner for browser-reachable source code, with the browser runtime entrypoint at `app/actions/public/entry.ts` +- `ui/` for shared cross-route UI primitives +- `utils/` only for genuinely cross-layer helpers that do not clearly belong elsewhere +- `routes.ts` for the shared server-and-browser route contract and type-safe href generation +- `router.ts` for router setup and wiring + +### Placement Precedence + +When code could live in multiple places: + +1. Put it in the narrowest owner first. +2. If it belongs to one route, keep it with that route. +3. If it is shared UI across route areas, move it to `app/ui/`. +4. If it is request lifecycle setup, keep it in `app/middleware/`. +5. If it is schema, query, persistence, or startup data logic, keep it in `app/data/`. +6. Use `app/utils/` only as a last resort for truly cross-layer helpers. + +### Route Ownership + +- Put top-level leaf actions in `app/actions/controller.tsx` +- A controller's `actions` object contains only direct leaf route keys from the route map passed to `router.map(...)` +- Add `app/actions//controller.tsx` for each nested route map that needs actions or controller middleware, and map it explicitly with `router.map(routes., controller)` +- Name directories under `app/actions/` after route-map keys, not URL path segments +- Keep route-local UI and helpers next to the controller that owns them +- Move shared cross-route UI to `app/ui/` +- If a top-level leaf grows into a route map, move its handler into the nested route-key controller and update `app/router.ts` to map that route map explicitly + +### Response Rendering And Utilities + +- Install `render()` from `remix/middleware/render` in the router middleware stack for normal Remix UI applications. Pass `render({ assets })` when source-based `clientEntry()` modules need browser URLs +- Render UI responses at the action boundary with `context.render(node, init)`. Status, headers, and other response policy remain explicit in the action +- Use `renderWith(...)`, `renderToStream(...)`, and `createHtmlResponse(...)` only when an application intentionally owns a custom renderer contract or replaces the standard UI response pipeline +- Put pure support code in focused `app/utils/.ts` modules. Formatting, MIME classification, path parsing, sorting, and normalization should be testable without a router, request context, or `Response`, and should not import from `app/actions`, `remix/ui/server`, or `remix/response/*` +- Do not introduce page-data intermediary shapes only to keep route-specific renderers away from `render(...)`; keep response assembly in actions and extract only the pure helpers + +### Layout Anti-Patterns + +- Do not create `app/lib/` as a generic dumping ground +- Do not create `app/components/` as a second shared UI bucket when `app/ui/` already owns that role +- Do not create `app/controllers/`; Remix app route handlers live under `app/actions/` +- Do not put shared cross-route UI in `app/actions/` +- Do not create standalone root action files; put root route actions in `app/actions/controller.tsx` +- Do not put nested route-map keys in a controller's `actions` +- Do not register normal app leaf routes directly in `app/router.ts` when they belong in a controller +- Do not rely on controller middleware from one controller to protect another controller; add controller middleware explicitly in each controller that needs it +- Do not put middleware or persistence helpers in `app/utils/` when they have a clearer home + +## Core Remix Rules + +- Import from `remix/`, never `import { ... } from 'remix'` +- Treat `app/routes.ts` as the source of truth for URLs. Use `routes..href(...)` for redirects, links, tests, and internal URL construction +- Controllers should return explicit `Response` objects, including redirects, 404s, and validation failures. At the route boundary, prefer returning a `Response` for expected outcomes (validation errors, conflicts, not found) over throwing for control flow +- `router.map(routes, controller)` maps only the direct leaf routes in `routes`; nested route maps must be mapped with their own explicit controllers +- Model HTTP behavior explicitly. Status codes, headers, redirects, cache rules, and content types are part of the route contract +- Make the server route correct first. A POST should already return the right HTML, redirect, or error response on its own before `clientEntry(...)` layers interactivity on top +- Validate input at the boundary using `remix/data-schema` (and `remix/data-schema/form-data` for forms). `parseSafe` makes the failure path a return value instead of an exception +- Derive `AppContext` from the middleware stack so `get(databaseContext)`, `get(Session)`, `get(Auth)`, and similar keys stay typed. If the controller never reads from context, it doesn't need the harness +- Outside actions and controllers, only use `getContext()` when `asyncContext()` is in the middleware stack +- Remix Component is not React: write `function Name(handle: Handle) { return () => ... }`, read props from `handle.props`, keep state in setup-scope variables, call `handle.update()` explicitly, and do DOM-sensitive work in event handlers or `queueTask(...)`, not in render +- Prefer host-element mixins via `mix={mixin(...)}` for behavior and styling instead of inventing custom host prop conventions. Use `mix={[...]}` only when composing multiple mixins +- Keep short, one-off static styles inline with `mix={css(...)}`. Extract a module-scoped style descriptor when it forms a reused visual recipe, has substantial selectors, media queries, or keyframes, or is large enough to obscure the component. Export a style descriptor only after multiple modules need the same visual recipe; otherwise keep it with its narrowest owner +- Hydrated `clientEntry(...)` props must be serializable. Do not pass functions, class instances, or opaque runtime objects + +## Security And Session Defaults + +- Never ship demo secrets. In non-test environments, require session and provider secrets from the environment and fail fast if they are missing +- Use hardened cookies: `httpOnly` always, `sameSite` by default, and `secure` when serving over HTTPS +- Regenerate session IDs on login, logout, and privilege changes +- Use `requireAuth()` to protect authenticated route areas, but still authorize resource ownership inside handlers and data writes +- Add CSRF protection when browser forms mutate state using cookie-backed sessions +- Add CORS only for endpoints that must be called cross-origin. Prefer same-origin by default +- Prefer JSX or `remix/html-template` for HTML generation so escaping stays correct +- Validate uploads for size, type, and destination. Treat filenames and content as untrusted input + +## Testing Defaults + +- Prefer server and router tests first. Drive the app with `router.fetch(new Request(...))` and assert on the returned `Response` +- Keep controller tests shaped like controllers: root route behavior belongs in `app/actions/controller.test.ts(x)`, and nested route-map behavior belongs beside that route-key controller +- Build a fresh router per test or per suite so sessions, in-memory storage, and database state stay isolated +- Use `routes..href(...)` in tests so URLs stay coupled to the route contract +- For auth or session scenarios, use a test cookie and `createMemorySessionStorage()` instead of production storage +- Co-locate tests for pure `app/utils` helpers beside their modules. Test response behavior through router or controller tests +- Use component tests only for interactive or DOM-specific behavior. Render with `createRoot(...)`, interact with the real DOM, and call `root.flush()` between steps +- Prefer one representative behavior test over many repetitive assertion variants + +## Common Mistakes To Avoid + +- Treating Remix Component like React and reaching for hooks or implicit rerendering +- Importing from a top-level `remix` entry instead of a subpath +- Adding `clientEntry(...)` before the server-rendered route behavior is correct +- Passing non-serializable props into `clientEntry(...)` +- Calling `getContext()` without `asyncContext()` in the middleware stack +- Getting middleware order wrong; fast exits like static files belong early, request enrichment later +- Skipping boundary validation and trusting raw `FormData`, params, cookies, or external payloads +- Letting route-local domain errors leak out of the controller. Translate expected outcomes (validation, conflicts, not-found) into the HTTP `Response` the route means to return rather than throwing a custom `Error` subclass and catching it elsewhere +- Reaching for `createCookie` when a tamper-sensitive or server-managed per-browser fact really wants `remix/session`. If editing the value would be a bug, use a session +- Building a JSON-only RPC layer when a normal form POST, redirect, or resource route would be simpler. Fetch-from-the-client is a layer on top of sound route behavior, not a replacement for it +- Treating JSON state endpoints and `` reloads as mutually exclusive patterns. Pick the lightest sync mechanism that fits the UX; small widgets may reasonably poll a JSON endpoint +- Assuming authentication is enough without per-resource authorization checks +- Dropping shared code into vague buckets like `utils.ts`, `helpers.ts`, or `common.ts` when ownership is known +- Recreating the old `app/controllers` or standalone root action file layout instead of using controllers under `app/actions` +- Putting nested route-map keys inside a controller `actions` object. Map nested route maps explicitly in `app/router.ts` +- Treating direct `router.get(...)`/`router.post(...)` registrations as the default app structure instead of using controllers +- Assuming controller middleware applies to controllers registered for nested route maps +- Writing only component tests for a feature whose main behavior is really an HTTP route concern + +## Package Map + +Use this map to find the right package quickly. Each entry says what the package is for, not just what it exports. Open the linked reference file when you need full examples. + +### Routing, Server, and Responses + +- `remix/router` — the router itself. Use for `createRouter`, controllers, middleware types, and registering routes +- `remix/routes` — declarative route builders. Use for `route`, `get`, `post`, `put`, `del`, `form`, `resources` when defining `app/routes.ts` +- `remix/node-fetch-server` — default Node adapter for new apps. Use `createRequestListener` with `node:http`, `node:https`, or `node:http2` in `server.ts` when booting the template-style app +- `remix/node-hmr` — optional development Node HMR runner for rapid UI edits. Use `run` in `hmr.ts` to supervise `server.ts` behind an `hmr` script, and use `createHmrReadyFetch` when a stable public proxy should wait for child server readiness during updates +- `remix/node-hmr/runtime` — child-process runtime API for code running under `remix/node-hmr`. Use to create browser HMR channels for asset servers and to emit server readiness after the child server starts listening +- `remix/node-hmr/types` — type-only entry for `import.meta.hot` in Node modules +- `remix/assets` — browser asset server. Use for `createAssetServer` when serving compiled scripts and styles, getting public hrefs, emitting preloads, and wiring browser HMR. Configure a `basePath`; use optional directory `mounts` configuration when the default mounts that serve `app` at `app` and `node_modules` at `npm` are not enough; use `allowFiles`/`denyFiles` for path and glob rules; and use exact package names in `allowPackages` for package-level access. Shared compiler options such as `target`, `sourceMaps`, `sourceMapSourcePaths`, and `minify` live at the top level +- `remix/assets/types/hmr` — type-only entry for `import.meta.hot` in browser modules compiled by `remix/assets` +- `remix/headers` — `SuperHeaders` plus typed header parsers and builders. Use the default export when you want a `Headers` subclass with typed accessors like `headers.contentType`, `headers.cacheControl`, and `headers.setCookie`; use named classes such as `CacheControl`, `ContentDisposition`, and `Vary` when working with individual header values +- `remix/response/redirect` — `redirect(href, status?)`. Use for the canonical "POST then redirect" pattern and other location changes +- `remix/response/html` — `createHtmlResponse`. Use when you need an HTML `Response` from a string or stream without rendering through `remix/ui` +- `remix/response/compress` — `compressResponse`. Use when compressing one-off responses outside `compression()` middleware +- `remix/response/file` — file-download responses. Use for `Content-Disposition: attachment` responses +- `remix/route-pattern` — low-level URL matching and generation. Use `RoutePattern` or `createMatcher` when working with raw patterns outside the router. `href(...)` encodes pathname and search params for you, and `match(...)` returns decoded params +- `remix/route-pattern/specificity` — pattern ranking helpers. Use only when building custom matcher or reporting logic outside the normal router/matcher APIs +- `remix/fetch-proxy` — Fetch-based HTTP proxying. Use to forward a request to another origin; pass `xForwardedHeaders` when the upstream needs forwarded proto, host, and port. It also rewrites proxied `Set-Cookie` domain/path attributes by default + +### Data, Validation, and Persistence + +- `remix/data-schema` — schema builders for runtime validation. Use for `parse` and `parseSafe` to validate any input that crosses a trust boundary, and `.transform(...)` when validated output should map to a different value or type +- `remix/data-schema/checks` — common check helpers (`email`, `minLength`, `maxLength`, etc.). Use to compose into a schema +- `remix/data-schema/coerce` — coercion helpers for strings, numbers, booleans, dates, and ids. Use when input arrives as a string but should be a typed value +- `remix/data-schema/form-data` — `f.object` and `f.field` for parsing `FormData` directly. Use in actions that read browser forms +- `remix/data-schema/lazy` — recursive or mutually-referential schemas. Use when a schema needs to refer to itself or another schema that is declared later +- `remix/data-table` — typed tables and the shared `Database` API. Use `table` and `column` when modeling persisted data, then create a concrete database from the matching dialect package. Integration packages can implement `DatabaseDriver` and extend `Database` to add another SQL dialect +- `remix/data-table/sqlite`, `remix/data-table/postgres`, `remix/data-table/mysql` — concrete database integrations. Use `createSqliteDatabase`, `createPostgresDatabase`, or `createMysqlDatabase`. SQLite accepts Node, Bun, and compatible synchronous clients with the shared `prepare`/`exec` surface +- `remix/data-table/migrations` — migration authoring and registries. Use for `createMigration` and `createMigrationRegistry`; run migrations with `Database.migrate()` +- `remix/data-table/migrations/node` — `loadMigrations` from disk. Use in startup scripts that apply migrations +- `remix/data-table/operators` — query operators such as `inList(...)`. Use when `where` clauses need set or comparison logic +- `remix/data-table/sql-helpers` — SQL helper utilities for database integrations or advanced query work. Avoid this in normal app code unless you are intentionally working below the table/query API + +### Auth, Sessions, and Cookies + +- `remix/session` — the `Session` object: `get`, `set`, `flash`, `unset`, `regenerateId`. Use for any per-browser state where tampering would be a bug (login, "I submitted this form already", cart, flash messages) +- `remix/middleware/session` — `session(cookie, storage)`. Use to wire a session cookie and storage backend into the middleware stack +- `remix/session-storage/fs`, `remix/session-storage/memory`, `remix/session-storage/cookie` — storage backends. Use `fs-storage` for single-process apps, `memory-storage` for tests, `cookie-storage` for stateless deployments where data fits in a cookie +- `remix/session-storage/redis` — Redis-backed storage. Use for multi-process or multi-host deployments +- `remix/session-storage/memcache` — Memcache-backed storage. Same multi-host use case as Redis +- `remix/cookie` — `createCookie` for plain signed/unsigned cookies. Use for non-sensitive preferences where the client is allowed to control the value (theme, locale, dismissed banner). For state where tampering matters, prefer `remix/session` +- `remix/auth` — credentials, OAuth, and OIDC providers. Use to define how identity is verified, start/finish external login, and refresh stored OAuth/OIDC token bundles with `refreshExternalAuth(...)` +- `remix/middleware/auth` — `auth({ schemes })`, `requireAuth`, the `Auth` context key. Use to resolve identity into the request context and to gate routes + +### UI, Hydration, and Browser Behavior + +- `remix/ui` — the component runtime: components, core mixins, `clientEntry`, `run`, ``, navigation helpers, and `createRoot`. Use for app UI behavior +- `remix/ui/server` — low-level server rendering with `renderToStream` and `renderToString`. Normal apps should install `render()` from `remix/middleware/render`; use this subpath for custom pipelines and static string rendering +- `remix/ui-hmr` — direct Remix UI component HMR transforms. Use only when writing a custom module hook or build integration +- `remix/ui-hmr/node` — Node import hook for Remix UI component HMR. Use with `--import remix/ui-hmr/node` in development servers that run through `remix/node-hmr` +- `remix/ui-hmr/assets` — `remix/assets` loader for Remix UI component HMR. Use `uiHmr()` in `createAssetServer({ scripts: { loaders } })` during development +- `remix/ui/dev/refresh` — development refresh support used by HMR tooling, not normal application code +- `remix/ui/animation` — animation APIs: `animateEntrance`, `animateExit`, `animateLayout`, `spring`, `tween`, and `easings` +- `remix/ui/` — UI primitives, mixins, and component helpers. Current subpaths include `remix/ui/accordion`, `remix/ui/anchor`, `remix/ui/button`, `remix/ui/checkbox`, `remix/ui/combobox`, `remix/ui/input`, `remix/ui/listbox`, `remix/ui/menu`, `remix/ui/popover`, and `remix/ui/select` +- `remix/ui/test` — component test rendering helpers such as `render` +- `remix/ui/jsx-runtime` and `remix/ui/jsx-dev-runtime` — JSX transform targets. Configured in `tsconfig.json`, rarely imported directly +- `remix/html-template` — escaped HTML template literals. Use when generating HTML outside the component system (RSS feeds, email bodies, error pages) +- `remix/file-storage` — backend-agnostic `File` storage interface. Use as the type bound for upload destinations +- `remix/file-storage/fs`, `remix/file-storage/memory`, `remix/file-storage/s3` — storage backends. Use to implement an upload destination + +### Middleware + +- `remix/middleware/render` — `render({ assets?, onError? })` for the standard Remix UI renderer and `renderWith(factory)` for custom request-scoped renderers. Normal UI actions return `context.render(node, init)` +- `remix/middleware/static` — `staticFiles(dir)`. Use to serve files from `public/` exactly as they exist on disk +- `remix/middleware/form-data` — `formData()`. Use to parse `FormData` once and expose it via `get(FormData)` instead of calling `await request.formData()` in each action +- `remix/form-data-parser` — lower-level `parseFormData`, `FileUpload`. Use when implementing custom upload handlers. Upload handler errors propagate directly +- `remix/multipart-parser` and `remix/multipart-parser/node` — low-level multipart stream parsing. `MultipartPart.headers` is a plain object keyed by lower-case header name; read values with bracket notation such as `part.headers['content-type']` +- `remix/middleware/compression` — `compression()`. Use for text-like responses +- `remix/middleware/logger` — `logger()`. Use in development for request logs; pass `colors` to force terminal color output on or off +- `remix/middleware/method-override` — `methodOverride()`. Use when HTML forms need `PUT`, `PATCH`, or `DELETE` +- `remix/middleware/async-context` — `asyncContext()`, `getContext()`. Use when helpers outside actions need request context without threading it through every call +- `remix/middleware/cors` — `cors(opts?)`. Use for endpoints called cross-origin +- `remix/middleware/csrf` — `csrf(opts?)`. Use when session-backed forms mutate state and need synchronizer-token CSRF protection +- `remix/middleware/cop` — cross-origin protection. Use to reject unsafe cross-origin browser requests + +### Test + +- `remix/test` — `describe`, `it`, and lifecycle hooks. Use as the test framework +- `remix/test/cli` — programmatic test runner APIs such as `runRemixTest` +- `remix/node-fetch-server/test` — `createTestServer` for end-to-end tests that need a real local HTTP server around a Fetch handler +- `remix/cli` — programmatic Remix CLI API. Use the `remix` executable for project commands such as `remix test`, `remix routes`, `remix doctor`, and `remix version` +- `remix/assert` — assertion helpers. Use in place of `node:assert` so messages render cleanly in the runner +- `remix/terminal` — ANSI styles, color detection, style factories, and testable terminal streams. Use for CLIs and terminal output instead of hand-rolled escape sequences +- `remix/fs` — small filesystem helpers such as `openLazyFile` and `writeFile`. Use in Node-only app or tooling code when you need lazy file responses or safe file writes +- `remix/lazy-file` — `LazyFile` primitives and byte-range helpers. Use when implementing file or range responses below the higher-level response/file helpers +- `remix/mime` — content-type and MIME detection helpers. Use instead of maintaining app-local extension maps +- `remix/tar-parser` — streaming tar parsing. Use for import/export tooling that consumes tar archives + +## Canonical Patterns + +### Define routes first + +```typescript +import { form, get, post, resources, route } from 'remix/routes' + +export const routes = route({ + home: '/', + contact: form('contact'), + books: { + index: '/books', + show: '/books/:slug', + }, + auth: route('auth', { + login: form('login'), + logout: post('logout'), + }), + admin: route('admin', { + index: get('/'), + books: resources('books', { param: 'bookId' }), + }), +}) +``` + +### Type controllers against the route contract + +```typescript +import { createController } from 'remix/router' + +import { databaseContext } from '../middleware/database.ts' +import { routes } from '../routes.ts' + +export default createController(routes.books, { + actions: { + async index({ get }) { + let db = get(databaseContext) + let allBooks = await db.findMany(books, { orderBy: ['id', 'asc'] }) + return render() + }, + async show({ get, params }) { + let db = get(databaseContext) + let book = await db.findOne(books, { where: { slug: params.slug } }) + if (!book) return new Response('Not Found', { status: 404 }) + return render() + }, + }, +}) +``` + +### Register Controllers Explicitly + +```typescript +import { createRouter } from 'remix/router' + +import rootController from './actions/controller.tsx' +import adminController from './actions/admin/controller.tsx' +import adminBooksController from './actions/admin/books/controller.tsx' +import authController from './actions/auth/controller.tsx' +import authLoginController from './actions/auth/login/controller.tsx' +import booksController from './actions/books/controller.tsx' +import contactController from './actions/contact/controller.tsx' +import { routes } from './routes.ts' + +export const router = createRouter({ middleware }) + +router.map(routes, rootController) +router.map(routes.contact, contactController) +router.map(routes.books, booksController) +router.map(routes.auth, authController) +router.map(routes.auth.login, authLoginController) +router.map(routes.admin, adminController) +router.map(routes.admin.books, adminBooksController) +``` + +### Compose middleware deliberately + +```typescript +import { createRouter } from 'remix/router' + +let middleware = [] + +if (process.env.NODE_ENV === 'development') { + middleware.push(logger()) +} + +middleware.push(compression()) +middleware.push(staticFiles('./public')) +middleware.push(formData()) +middleware.push(methodOverride()) +middleware.push(session(cookie, storage)) +middleware.push(asyncContext()) +middleware.push(loadDatabase()) +middleware.push(loadAuth()) + +let router = createRouter({ middleware }) +``` + +### Validate, mutate, and respond + +```typescript +import { createController } from 'remix/router' +import { redirect } from 'remix/response/redirect' +import * as s from 'remix/data-schema' +import * as f from 'remix/data-schema/form-data' +import { Session } from 'remix/session' + +import { databaseContext } from '../middleware/database.ts' +import { routes } from '../routes.ts' + +let bookSchema = f.object({ + slug: f.field(s.string()), + title: f.field(s.string()), +}) + +export default createController(routes.books, { + actions: { + async create({ get }) { + let parsed = s.parseSafe(bookSchema, get(FormData)) + if (!parsed.success) { + return render(, { status: 400 }) + } + + let db = get(databaseContext) + let book = await db.create(books, parsed.value) + + let session = get(Session) + session.flash('message', `Added ${book.title}.`) + + return redirect(routes.books.show.href({ slug: book.slug })) + }, + }, +}) +``` + +This shape works without JavaScript, returns a `Response` for every outcome, and is ready for `clientEntry(...)` interactivity when the UI needs it. + +### Build UI from handle props plus render + +```tsx +import { on, type Handle } from 'remix/ui' + +function Counter(handle: Handle<{ initialCount?: number; label: string }>) { + let count = handle.props.initialCount ?? 0 + + return () => ( + + ) +} +``` + +Only add `clientEntry(...)` and `run(...)` when the component needs browser interactivity or browser-only APIs. diff --git a/examples/ts-remix-chat/.agents/skills/remix/references/animate-elements.md b/examples/ts-remix-chat/.agents/skills/remix/references/animate-elements.md new file mode 100644 index 0000000000..36d8d3d46d --- /dev/null +++ b/examples/ts-remix-chat/.agents/skills/remix/references/animate-elements.md @@ -0,0 +1,208 @@ +# Animating Elements + +## What This Covers + +How to animate insertion, removal, and layout changes of elements. Read this when the task involves: + +- Adding entrance, exit, or shared-layout transitions to UI +- Choosing between spring physics (`spring(...)`) and time-based easing (`tween`) +- Coordinating CSS transitions with the same easing as JS animations +- Imperative animation loops via `requestAnimationFrame` + +Import animation APIs from `remix/ui/animation`. For the smaller set of animation helpers that show up alongside other mixins, see `mixins-styling-events.md`. + +## Animation Mixins + +### `animateEntrance(config)` + +Animates an element when inserted. Config specifies the **starting** style the element animates **from**: + +```tsx +
+``` + +### `animateExit(config)` + +Animates an element when removed. Config specifies the **ending** style the element animates **to**. The element stays in the DOM until the animation completes: + +```tsx +{ + isVisible && ( +
+ ) +} +``` + +### `animateLayout(config?)` + +Animates layout changes (position/size) using FLIP-style transforms: + +```tsx +{ + items.map((item) => ( +
  • + )) +} +``` + +Options: `duration` (default 200ms), `easing` (default spring snappy), `size` (default true — include scale projection for size changes). + +### Combining mixins + +```tsx +
    +``` + +### Shared-layout swap + +```tsx +
    *': { gridArea: '1 / 1' } })}> + {stateA ? ( +
    + ) : ( +
    + )} +
    +``` + +## Spring API + +Physics-based spring animation. Returns a `SpringIterator` with `duration`, `easing`, and `toString()` for CSS. + +### Presets + +| Preset | Bounce | Duration | Character | +| -------- | ------ | -------- | --------------------------- | +| `smooth` | -0.3 | 400ms | Overdamped, no overshoot | +| `snappy` | 0 | 200ms | Critically damped, quick | +| `bouncy` | 0.3 | 400ms | Underdamped, visible bounce | + +```tsx +spring('bouncy') +spring('snappy') +spring('smooth') +spring('bouncy', { duration: 300 }) // override duration +``` + +### Custom spring + +```tsx +spring({ duration: 500, bounce: 0.3 }) +spring({ duration: 500, bounce: 0.3, velocity: 2 }) // continue momentum from gesture +``` + +### Spread into animation mixins + +Spreading a spring gives both `duration` and `easing`: + +```tsx +animateEntrance({ opacity: 0, ...spring('bouncy') }) +``` + +### CSS transitions + +The iterator stringifies to `"550ms linear(...)"`: + +```tsx +css({ transition: `width ${spring('bouncy')}` }) +``` + +Or use the `spring.transition()` helper for multiple properties: + +```tsx +css({ transition: spring.transition('width', 'bouncy') }) +css({ transition: spring.transition(['left', 'top'], 'snappy') }) +``` + +### Web Animations API + +```tsx +element.animate(keyframes, { ...spring('bouncy') }) +``` + +### JS iteration + +The iterator yields position values from 0 to 1, one per frame: + +```tsx +for (let t of spring('bouncy')) { + let x = from + (to - from) * t + updateSomething(x) + await nextFrame() +} +``` + +## Tween API + +Generator-based tween for animating values over time with cubic bezier easing. Prefer animation mixins or CSS transitions with `spring` for most UI work. Use `tween` for imperative `requestAnimationFrame` loops, canvas/WebGL, or non-CSS properties. + +```tsx +import { tween, easings } from 'remix/ui/animation' + +let animation = tween({ + from: 0, + to: 100, + duration: 300, + curve: easings.easeOut, +}) + +animation.next() // initialize +function tick(timestamp: number) { + if (handle.signal.aborted) return + let { value, done } = animation.next(timestamp) + element.style.transform = `translateX(${value}px)` + if (!done) requestAnimationFrame(tick) +} +requestAnimationFrame(tick) +``` + +Built-in easings: `easings.linear`, `easings.ease`, `easings.easeIn`, `easings.easeOut`, `easings.easeInOut`. + +## Practical Guidance + +- Always key conditional or switching elements you expect to animate. +- Use `animateLayout` only on the element whose position or size changes. +- Prefer one clear transition intent per mixin: entrance starts from a style, exit ends at a style. +- Default to `...spring()` for duration and easing in most cases. +- Keep DOM work in `handle.queueTask(...)` or `ref(...)`, not in render. diff --git a/examples/ts-remix-chat/.agents/skills/remix/references/assets-and-browser-modules.md b/examples/ts-remix-chat/.agents/skills/remix/references/assets-and-browser-modules.md new file mode 100644 index 0000000000..59f2e43451 --- /dev/null +++ b/examples/ts-remix-chat/.agents/skills/remix/references/assets-and-browser-modules.md @@ -0,0 +1,155 @@ +# Assets and Browser Modules + +## What This Covers + +How to serve browser scripts and styles from source. Read this when the task involves: + +- Configuring `createAssetServer` (`basePath`, `mounts`, `allowFiles`, `allowPackages`, `denyFiles`, fingerprinting, compiler options) +- Choosing between `staticFiles()` for already-built files and `createAssetServer()` for source assets that need import rewriting, preloads, or fingerprinted URLs +- Generating script URLs or `` tags for a client entry +- Enabling browser HMR for source-served modules +- Keeping files such as tests out of the browser via `denyFiles` rules + +For routing the URL namespace itself, see `routing-and-controllers.md`. For client entry hydration and browser update handling, see `hydration-frames-navigation.md`. For the Node HMR runner and browser HMR channel, see `middleware-and-server.md`. + +## When To Reach For It + +Use `remix/assets` when the app serves browser JavaScript, TypeScript, or CSS from source files. This is the right tool for client entrypoints, browser-only helpers, styles, and monorepo code that should be compiled and served under a public URL namespace. + +Use `staticFiles()` for files that already exist on disk exactly as they should be served. Use `createAssetServer()` for source scripts or styles that need rewriting, dependency scanning, preloads, sourcemaps, or fingerprinted URLs. + +## Default Pattern + +```typescript +import { createAssetServer } from 'remix/assets' +import { createController } from 'remix/router' +import { get, route } from 'remix/routes' + +export const routes = route({ + assets: get('/assets/*path'), +}) + +let assets = createAssetServer({ + basePath: '/assets', + rootDir: process.cwd(), + allowFiles: ['app/routes.ts', 'app/**/public/**'], + allowPackages: ['remix'], + denyFiles: ['app/**/*.test.*'], + target: { es: '2020', chrome: '109', safari: '16.4' }, + sourceMaps: process.env.NODE_ENV === 'development' ? 'external' : undefined, + minify: process.env.NODE_ENV === 'production', + scripts: { + define: { + 'process.env.NODE_ENV': JSON.stringify( + process.env.NODE_ENV ?? 'development', + ), + }, + }, +}) + +export default createController(routes, { + actions: { + async assets({ request }) { + return ( + (await assets.fetch(request)) ?? + new Response('Not Found', { status: 404 }) + ) + }, + }, +}) +``` + +## Rules + +- Treat `allowFiles`/`allowPackages` and `denyFiles` as the security boundary for browser-reachable source files. +- Put browser-reachable app source in a `public/` directory inside `app/`, beside its narrowest owner, such as `app/ui/public/` or `app/actions/cart/public/`. +- Every local dependency in a browser module graph must match `allowFiles`, so keep the whole graph inside those `public/` directories. `app/routes.ts` is allowed separately so browser modules can build type-safe links with `routes.*.href(...)`. +- Deny test modules with `denyFiles` so tests can be colocated inside a `public/` directory without becoming browser-reachable. +- Use `allowFiles` and `denyFiles` for file paths and globs. Relative values resolve from `rootDir`. +- Use `allowPackages` for exact package names, not globs or subpaths. Packages allowed by `allowPackages` also allow their installed `dependencies` and `optionalDependencies`; peer dependencies must be listed explicitly if they should be browser-reachable. +- `denyFiles` takes precedence over both file and package allow rules. +- Set `rootDir` explicitly in monorepos so relative paths resolve from the intended project root. +- `basePath` is the public URL namespace handled by the asset server. +- The default mounts serve the `app` directory at `/app` and `node_modules` at `/npm`. Use `mounts` to replace these defaults when the app needs different public or root-relative directory roots. +- Mounts preserve every path segment beneath their public and filesystem roots. Do not configure overlapping public or filesystem roots. +- CSS files are compiled and served alongside scripts. Local CSS `@import` rules are rewritten and fingerprinted with the same asset server routing rules. + +## Rendering HTML + +Use `getHref()` when you need the public URL for one module, and `getPreloads()` when you want `` tags or `Link` headers for one or more entrypoints and their dependencies. + +```typescript +let entryHref = await assets.getHref('app/actions/public/entry.ts') +let entryPreloads = await assets.getPreloads('app/actions/public/entry.ts') +``` + +Use this when rendering documents or layouts that boot browser behavior with a known client entry. + +For normal Remix applications, pass the asset server to `render({ assets })` from `remix/middleware/render`. The middleware resolves source entry IDs from `clientEntry(import.meta.url, ...)` with `getHref()` and `getPreloads()` and applies the UI renderer's explicit-hash or named-component export rules. Use a custom `resolveClientEntry` callback only when building a custom rendering pipeline. + +## Development vs Deployment + +In development: + +- Keep `watch` enabled so source changes are picked up without restarting the server +- Prefer stable URLs with normal revalidation +- Enable source maps when debugging browser code +- Use `hmr` only when the app is running under `remix/node-hmr` +- Use `scripts.loaders` for development-only browser transforms such as `uiHmr()` + +In deployment: + +- Set `watch: false` +- Use `fingerprint: { buildId }` for long-lived immutable caching +- Make sure `buildId` changes for each deploy + +Fingerprinting assumes files on disk are stable and requires `watch: false`. + +## Browser HMR + +Use browser HMR when source-served browser modules should update without a full page reload during development. Let `remix/node-hmr` own the browser HMR channel so browser updates stay coordinated with server restarts. + +```typescript +import { createAssetServer } from 'remix/assets' +import { uiHmr } from 'remix/ui-hmr/assets' + +const isDevelopment = process.env.NODE_ENV === 'development' +const isHmr = Boolean(isDevelopment && process.env.REMIX_NODE_HMR) + +const assetServer = createAssetServer({ + basePath: '/assets', + allowFiles: ['app/routes.ts', 'app/**/public/**'], + denyFiles: ['app/**/*.test.*'], + watch: isDevelopment, + hmr: isHmr + ? async () => + (await import('remix/node-hmr/runtime')).createBrowserHmrChannel() + : undefined, + scripts: { + loaders: isHmr ? [uiHmr()] : undefined, + }, +}) +``` + +Rules: + +- Guard `remix/node-hmr/runtime` imports with `process.env.REMIX_NODE_HMR`; that runtime API is only available inside the supervised child process. +- Keep browser HMR and loaders development-only. +- Add `remix/assets/types/hmr` to `compilerOptions.types` only when browser source modules use `import.meta.hot` directly. +- Write HMR accept calls directly as `import.meta.hot.accept(...)` with literal dependency specifiers. + +## Useful Compiler Options + +- `minify` for production minification of scripts and styles +- `sourceMaps` for `'external'` or `'inline'` source maps for scripts and styles +- `sourceMapSourcePaths` for `'url'` or `'absolute'` source map paths +- `target` as an object for shared browser targets and script-only ECMAScript output, such as `{ es: '2020', chrome: '109', safari: '16.4' }` +- `scripts.define` to replace globals such as `process.env.NODE_ENV` +- `scripts.external` to leave specific script imports untouched +- `scripts.loaders` to transform browser modules during compilation + +Do not nest shared compiler options under `scripts`. Use top-level `minify`, `sourceMaps`, `sourceMapSourcePaths`, and `target` so they apply to styles as well as scripts. + +## Lifecycle + +If the asset server is long-lived and watching the file system, call `await assetServer.close()` when shutting down dev servers or disposing tests. diff --git a/examples/ts-remix-chat/.agents/skills/remix/references/auth-and-sessions.md b/examples/ts-remix-chat/.agents/skills/remix/references/auth-and-sessions.md new file mode 100644 index 0000000000..b62439db7a --- /dev/null +++ b/examples/ts-remix-chat/.agents/skills/remix/references/auth-and-sessions.md @@ -0,0 +1,417 @@ +# Authentication and Sessions + +## What This Covers + +How to remember things about a browser between requests and how to identify a user. Read this when the task involves: + +- Storing per-browser state across requests (login, cart, "I have submitted this form") +- Adding a credentials login flow or an OAuth provider +- Protecting routes with `requireAuth()` or stacking authorization checks +- Reading or writing `Session`, `Auth`, or other identity-related context values +- Logging in, logging out, or rotating session IDs + +For raw cookies that are not session-backed (theme, locale, dismissed-banner), see `createCookie` in this file plus the broader `Package Map` in `SKILL.md`. + +## Sessions vs Plain Cookies + +Reach for `remix/session` when state is sensitive, must be tamper-resistant, or represents the identity of a request: who is logged in, which form a browser already submitted, what items are in a cart. Sessions sign or encrypt their backing cookie with a server-held secret and give you a typed `Session` object you can `get`, `set`, `flash`, `unset`, and `regenerateId`. + +Reach for `remix/cookie` directly when the browser is allowed to carry the value and the server does not need session semantics. This often means preferences (theme, locale, dismissed banner), but a signed cookie can also be fine for small low-risk values where you truly only need one cookie-shaped fact and do not need `Session` helpers. + +If a malicious user editing the value would be a bug, or if the value needs server-managed lifecycle, reach for a session. + +### Quick chooser + +| Need | Best fit | Why | +| ------------------------------------------------------------------- | --------------- | -------------------------------------------------- | +| Theme, locale, dismissed banner | `remix/cookie` | Browser-controlled preference | +| Small signed hint with minimal lifecycle | `remix/cookie` | One value, no `Session` helpers needed | +| "This browser already submitted", cart, flash messages, login state | `remix/session` | Tamper-sensitive, server-managed per-browser state | +| "One real person only", ownership, durable identity | account/auth | Cookies or sessions alone do not prove personhood | + +## Session Setup + +### Create a session cookie + +```typescript +import { createCookie } from 'remix/cookie' + +let sessionSecret = process.env.SESSION_SECRET +if (!sessionSecret && process.env.NODE_ENV !== 'test') { + throw new Error('SESSION_SECRET is required') +} + +export let sessionCookie = createCookie('session', { + secrets: [sessionSecret ?? 'test-only-secret'], + httpOnly: true, + sameSite: 'Lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 2592000, // 30 days + path: '/', +}) +``` + +The cookie should always be `httpOnly`, default to `sameSite: 'Lax'`, and be `secure` in production. Demo defaults like `'s3cr3t'` are fine in tests but should never reach production — fail fast when the secret is missing. + +### Create session storage + +```typescript +// Filesystem storage +import { createFsSessionStorage } from 'remix/session-storage/fs' +export let sessionStorage = createFsSessionStorage('./tmp/sessions') + +// Memory storage (for tests) +import { createMemorySessionStorage } from 'remix/session-storage/memory' +export let sessionStorage = createMemorySessionStorage() +``` + +### Add session middleware + +```typescript +import { session } from 'remix/middleware/session' + +let router = createRouter({ + middleware: [ + session(sessionCookie, sessionStorage), + // ... other middleware + ], +}) +``` + +### Using sessions in handlers + +```typescript +import { Session } from 'remix/session' + +async function handler({ get }) { + let session = get(Session) + + // Read + let userId = session.get('userId') + + // Write + session.set('userId', 42) + + // Flash (read once, then cleared) + session.flash('message', 'Settings saved!') + let message = session.get('message') // returns and clears + + // Remove a key + session.unset('userId') + + // Regenerate session ID (after login/logout) + session.regenerateId(true) +} +``` + +### Sessions for non-auth state + +Sessions are not just for login. They are the right place to store any tamper-sensitive per-browser fact: which form a browser already submitted, how many free actions are left in a trial, which feature flags a tester opted into, what items are in a cart. + +```typescript +async function submit({ get }) { + let session = get(Session) + if (session.get('hasSubmitted')) { + return render(, { status: 409 }) + } + + let parsed = s.parseSafe(submitSchema, get(FormData)) + if (!parsed.success) { + return render(, { status: 400 }) + } + + await saveSubmission(parsed.value) + session.set('hasSubmitted', true) + session.flash('message', 'Thanks for submitting!') + + return redirect(routes.thanks.href()) +} +``` + +Notice that there is no manual `Set-Cookie` plumbing in the action — the session middleware handles that, and the handler returns an ordinary `Response`. Per-browser state enforced this way is still bypassable by clearing cookies; if the guarantee needs to survive that, you also need an account (see auth providers below). + +## Auth Middleware + +### Basic setup + +```typescript +import { auth, createSessionAuthScheme } from 'remix/middleware/auth' +import { Session } from 'remix/session' +import { databaseContext } from '~/middleware/database.ts' + +export function loadAuth() { + return auth({ + schemes: [ + createSessionAuthScheme({ + read(session) { + let data = session.get('auth') + return data ?? null + }, + async verify(value, context) { + let db = context.get(databaseContext) + return (await db.find(users, value.userId)) ?? null + }, + invalidate(session) { + session.unset('auth') + }, + }), + ], + }) +} +``` + +### Reading auth state + +```typescript +import { Auth } from 'remix/middleware/auth' + +function handler({ get }) { + let auth = get(Auth) + + if (auth.ok) { + // User is authenticated + let user = auth.identity + } +} +``` + +## Credentials Auth + +### Define a credentials provider + +```typescript +import { + createCredentialsAuthProvider, + verifyCredentials, + completeAuth, +} from 'remix/auth' +import * as s from 'remix/data-schema' +import * as f from 'remix/data-schema/form-data' + +let loginSchema = f.object({ + email: f.field(s.defaulted(s.string(), '')), + password: f.field(s.defaulted(s.string(), '')), +}) + +export let passwordProvider = createCredentialsAuthProvider({ + parse(context) { + let formData = context.get(FormData) + return s.parse(loginSchema, formData) + }, + async verify({ email, password }, context) { + let db = context.get(databaseContext) + let user = await db.findOne(users, { where: { email } }) + if (!user || !(await verifyPassword(password, user.password_hash))) { + return null + } + return user + }, +}) +``` + +### Login action + +```typescript +import { verifyCredentials, completeAuth } from 'remix/auth' +import { redirect } from 'remix/response/redirect' + +async action(context) { + let user = await verifyCredentials(passwordProvider, context) + + if (user == null) { + let session = context.get(Session) + session.flash('error', 'Invalid email or password.') + return redirect(routes.auth.login.href()) + } + + let session = completeAuth(context) + session.set('auth', { userId: user.id }) + + return redirect(routes.home.href()) +}, +``` + +### Logout action + +```typescript +import { Session } from 'remix/session' +import { redirect } from 'remix/response/redirect' + +function logout(context) { + let session = context.get(Session) + session.unset('auth') + session.regenerateId(true) + return redirect(routes.home.href()) +} +``` + +## OAuth / External Auth + +### Create providers + +```typescript +import { + createGoogleAuthProvider, + createGitHubAuthProvider, + startExternalAuth, + finishExternalAuth, + completeAuth, + refreshExternalAuth, +} from 'remix/auth' + +let googleProvider = createGoogleAuthProvider({ + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + redirectUri: new URL(routes.auth.google.callback.href(), origin), +}) + +let githubProvider = createGitHubAuthProvider({ + clientId: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + redirectUri: new URL(routes.auth.github.callback.href(), origin), +}) +``` + +### OAuth controller + +```typescript +import { createController } from 'remix/router' + +export default createController(routes.auth.google, { + actions: { + // GET /auth/google — redirect to Google + async index(context) { + return await startExternalAuth(googleProvider, context, { + returnTo: context.url.searchParams.get('returnTo'), + }) + }, + + // GET /auth/google/callback — handle redirect back + async callback(context) { + let { result, returnTo } = await finishExternalAuth( + googleProvider, + context, + ) + + let db = context.get(databaseContext) + let { user, authAccount } = await resolveExternalAuth(db, result) + + let session = completeAuth(context) + session.set('auth', { + userId: user.id, + loginMethod: result.provider, + authAccountId: authAccount.id, + }) + + return redirect(returnTo ?? routes.account.index.href()) + }, + }, +}) +``` + +### Refresh stored provider tokens + +Use `refreshExternalAuth(provider, tokens)` when an app has stored OAuth/OIDC tokens and needs a fresh access token from a refresh token. Built-in OIDC providers and X support refresh-token exchange. If the provider does not rotate the refresh token, the refreshed bundle preserves the current one. + +```typescript +async function refreshGoogleTokens({ get }) { + let db = get(databaseContext) + let account = await db.findOne(authAccounts, { + where: { provider: 'google' }, + }) + if (!account) return null + + let refreshed = await refreshExternalAuth(googleProvider, account.tokens) + await db.update(authAccounts, account.id, { tokens: refreshed.tokens }) + + return refreshed.tokens +} +``` + +## Protecting Routes + +### Controller middleware protection + +Apply `requireAuth()` as controller middleware to every action in one controller: + +```typescript +import { createController } from 'remix/router' +import { requireAuth } from 'remix/middleware/auth' + +export default createController(routes.account, { + middleware: [requireAuth()], + actions: { + index() { + /* guaranteed authenticated */ + }, + }, +}) +``` + +Nested route maps need their own explicit protection: + +```typescript +// app/router.ts +router.map(routes.account, accountController) +router.map(routes.account.settings, accountSettingsController) + +// app/actions/account/settings/controller.tsx +export default createController(routes.account.settings, { + middleware: [requireAuth()], + actions: { + index() { + /* guaranteed authenticated */ + }, + update() { + /* guaranteed authenticated */ + }, + }, +}) +``` + +### Stacking middleware + +Combine auth checks with role checks: + +```typescript +export default createController(routes.admin, { + middleware: [requireAuth(), requireAdmin()], + actions: { + index() { + /* requires auth + admin */ + }, + }, +}) +``` + +### Action middleware protection + +Apply middleware to a single route: + +```typescript +import { Auth, requireAuth } from 'remix/middleware/auth' + +router.get(routes.account.index, { + middleware: [requireAuth()], + handler(context) { + let auth = context.get(Auth) + return render() + }, +}) +``` + +### Redirect on auth failure + +```typescript +import { requireAuth } from 'remix/middleware/auth' +import { redirect } from 'remix/response/redirect' + +export function requireAuthRedirect() { + return requireAuth({ + onFailure(context) { + let returnTo = encodeURIComponent(context.url.pathname) + return redirect(routes.auth.login.href() + `?returnTo=${returnTo}`, 303) + }, + }) +} +``` diff --git a/examples/ts-remix-chat/.agents/skills/remix/references/component-model.md b/examples/ts-remix-chat/.agents/skills/remix/references/component-model.md new file mode 100644 index 0000000000..07cea99bcd --- /dev/null +++ b/examples/ts-remix-chat/.agents/skills/remix/references/component-model.md @@ -0,0 +1,282 @@ +# Component Model + +## What This Covers + +How a Remix Component is shaped and how its state, lifecycle, and updates behave. Read this when the task involves: + +- Writing a component (`handle` plus render function) +- Managing component-local state, derived values, or post-render DOM work +- Using `handle.props`, `handle.update()`, `handle.queueTask()`, `handle.signal`, `handle.id`, or `handle.context` +- Listening to global events with cleanup tied to the component lifecycle + +For host-element behavior (event handlers, styles, refs, animations), see `mixins-styling-events.md`. For browser hydration, frames, and navigation, see `hydration-frames-navigation.md`. + +## Phases + +A component has two phases: + +1. **Setup phase** — runs once when the component is created +2. **Render phase** — returned zero-argument function runs on initial render and every update + +The component shape is `function Component(handle: Handle) { return () => ... }`. Props are available as `handle.props` in setup scope and are updated before every render. + +```tsx +import { on, type Handle } from 'remix/ui' + +function Counter(handle: Handle<{ initialCount?: number; label: string }>) { + let count = handle.props.initialCount ?? 0 + + return () => ( + + ) +} +``` + +## Props + +Components receive all JSX props through `handle.props`. The object identity is stable for the component lifetime, and its values are updated before each render. Put initialization inputs on normal JSX props and read them from `handle.props`: + +```tsx +function Timer(handle: Handle<{ initialSeconds: number; paused?: boolean }>) { + let seconds = handle.props.initialSeconds + + return () =>
    Time remaining: {seconds}s
    +} + +// Usage: +``` + +Because `handle.props` is stable, destructuring `let { props } = handle` is safe when helpers need to read current values later. Destructuring individual prop values is only a snapshot; prefer `handle.props.name` inside callbacks and render output when values can change. + +## State Rules + +- Keep state in setup scope as plain JavaScript variables. +- Store only what affects rendering. Derive computed values in render. +- Do not mirror input state unless you truly need controlled behavior. +- Do work in event handlers, not in render. Use the handler scope for transient state. + +```tsx +// Derive computed values in render +function TodoList(handle: Handle) { + let todos: Array<{ text: string; completed: boolean }> = [] + + return () => { + let completedCount = todos.filter((t) => t.completed).length + return
    Completed: {completedCount}
    + } +} +``` + +## Handle API + +### `handle.update()` + +Schedules a rerender. Returns a promise that resolves with an `AbortSignal` after the update completes. Await it when you need the updated DOM before follow-up work: + +```tsx +on('click', async () => { + isPlaying = true + let signal = await handle.update() + // DOM is now updated, safe to focus or measure + stopButton.focus() +}) +``` + +### `handle.queueTask(task)` + +Schedules a task to run after the next update. The task receives an `AbortSignal` that aborts when the component re-renders or is removed. Use for post-render DOM work, reactive data loading, or hydration-sensitive setup: + +```tsx +let data = null +let requestedUrl: string | null = null + +// Post-render DOM work in an event handler +on('click', () => { + showDetails = true + handle.update() + handle.queueTask(() => { + detailsSection.scrollIntoView({ behavior: 'smooth' }) + }) +}) + +// Reactive data loading keyed by props.url +return () => { + if (requestedUrl !== handle.props.url) { + let nextUrl = handle.props.url + requestedUrl = nextUrl + data = null + + handle.queueTask(async (signal) => { + let response = await fetch(nextUrl, { signal }) + let json = await response.json() + if (signal.aborted || requestedUrl !== nextUrl) return + data = json + handle.update() + }) + } + + return
    {data ?? 'Loading...'}
    +} +``` + +Avoid creating intermediate state just to trigger `queueTask`. Do the work directly in the handler or the queued task. + +### `handle.signal` + +An `AbortSignal` aborted when the component disconnects. Use for cleanup: + +```tsx +function Clock(handle: Handle) { + let interval = setInterval(handle.update, 1000) + handle.signal.addEventListener('abort', () => clearInterval(interval)) + + return () => {new Date().toString()} +} +``` + +### `handle.id` + +Stable identifier per component instance. Useful for `htmlFor`, `aria-owns`, etc.: + +```tsx +function LabeledInput(handle: Handle) { + return () => ( +
    + + +
    + ) +} +``` + +### `handle.frame` and `handle.frames` + +Frame-aware behavior for client entries rendered inside frames: + +- `handle.frame.reload()` — reload the containing frame +- `handle.frame.src` — the URL of the containing frame +- `handle.frames.top` — the root frame (the whole page) +- `handle.frames.top.reload()` — reload the entire page/frame tree +- `handle.frames.get(name)` — look up a named frame; returns `FrameHandle | undefined` + +```tsx +function RefreshButton(handle: Handle) { + return () => ( + + ) +} +``` + +### `handle.context` + +Context for ancestor/descendant communication. See the context section below. + +## Context + +Use `handle.context.set()` to provide values and `handle.context.get(Provider)` to consume them. `set()` does **not** trigger updates — call `handle.update()` if the tree needs to rerender. + +```tsx +function ThemeProvider( + handle: Handle<{ children?: RemixNode }, { theme: 'light' | 'dark' }>, +) { + let theme: 'light' | 'dark' = 'light' + handle.context.set({ theme }) + + return () => ( +
    + + {handle.props.children} +
    + ) +} + +function ThemedContent(handle: Handle) { + let { theme } = handle.context.get(ThemeProvider) + return () =>
    Current theme: {theme}
    +} +``` + +For granular updates without re-rendering the full subtree, use `TypedEventTarget`: + +```tsx +import { TypedEventTarget } from 'remix/ui' + +class Theme extends TypedEventTarget<{ change: Event }> { + #value: 'light' | 'dark' = 'light' + get value() { + return this.#value + } + setValue(value: 'light' | 'dark') { + this.#value = value + this.dispatchEvent(new Event('change')) + } +} + +function ThemeProvider(handle: Handle<{ children?: RemixNode }, Theme>) { + let theme = new Theme() + handle.context.set(theme) + + return () => ( +
    + + {handle.props.children} +
    + ) +} + +function ThemedContent(handle: Handle) { + let theme = handle.context.get(ThemeProvider) + theme.addEventListener('change', () => handle.update(), { + signal: handle.signal, + }) + return () =>
    Theme: {theme.value}
    +} +``` + +## Global Events + +Use `on(...)` for element events. For browser globals such as `window` or `document`, schedule setup with `handle.queueTask()` and pass `handle.signal` to `addEventListener()` so the listener is removed when the component disconnects: + +```tsx +import type { Handle } from 'remix/ui' + +function ViewportWidth(handle: Handle) { + let width: number | undefined + + handle.queueTask(() => { + width = window.innerWidth + window.addEventListener( + 'resize', + () => { + width = window.innerWidth + handle.update() + }, + { signal: handle.signal }, + ) + handle.update() + }) + + return () =>
    {width === undefined ? 'Measuring…' : `${width}px`}
    +} +``` diff --git a/examples/ts-remix-chat/.agents/skills/remix/references/create-mixins.md b/examples/ts-remix-chat/.agents/skills/remix/references/create-mixins.md new file mode 100644 index 0000000000..0869da4b65 --- /dev/null +++ b/examples/ts-remix-chat/.agents/skills/remix/references/create-mixins.md @@ -0,0 +1,155 @@ +# Creating Mixins + +## What This Covers + +How to author your own reusable host-element behavior with `createMixin`. Read this when the task involves: + +- Combining multiple low-level events or DOM hooks into one semantic mixin +- Dispatching custom DOM events from a host node +- Encapsulating imperative DOM setup that several components share +- Typing custom events on `HTMLElementEventMap` for use with `on(...)` + +For the built-in mixins most code should use, see `mixins-styling-events.md`. + +Use `createMixin` from `remix/ui` to author reusable host-element behavior. + +Most app code should use built-in core mixins (`on`, `css`, `ref`, `link`, `attrs`) and animation mixins from `remix/ui/animation`. Create custom mixins when combining multiple low-level events into one semantic event, or when the pattern is reused across components. + +## Core Semantics + +1. A mixin handle is tied to one mounted host node lifecycle. +2. `insert` is the host-node availability point for imperative setup. +3. `remove` is teardown for that same lifecycle. +4. `queueTask` runs post-commit and receives `(node, signal)` for mixins. +5. Mixin render functions should stay pure; side effects belong in `insert`, `remove`, or queued work. + +```tsx +import { createMixin } from 'remix/ui' + +let myMixin = createMixin((handle) => { + handle.addEventListener('insert', (event) => { + // event.node is the mounted host node + }) + + handle.addEventListener('remove', () => { + // Clean up listeners, timers, observers + }) + + return (props) => { + handle.queueTask((node) => { + // Post-commit work that needs the concrete host node + }) + return + } +}) +``` + +## Patterns + +### Pure prop transform + +```tsx +let withTitle = createMixin( + (handle) => (title: string, props: { title?: string }) => ( + + ), +) +``` + +### Lifecycle-managed imperative setup + +```tsx +let withFocus = createMixin((handle) => { + handle.addEventListener('insert', (event) => { + event.node.focus() + }) + return (props) => +}) +``` + +## Custom Event Mixins + +Create event mixins when you combine multiple low-level events into one semantic custom event that is reused across components. + +1. Namespace custom event names (`myapp:*`) to avoid collisions. +2. Extend `Event` with the data consumers need. +3. Declare the event on `HTMLElementEventMap` for type safety with `on(...)`. +4. Dispatch from the host node inside the mixin. + +```tsx +import { createMixin, on } from 'remix/ui' + +export let dragReleaseType = 'myapp:drag-release' as const + +declare global { + interface HTMLElementEventMap { + [dragReleaseType]: DragReleaseEvent + } +} + +export class DragReleaseEvent extends Event { + velocityX: number + velocityY: number + constructor(init: { velocityX: number; velocityY: number }) { + super(dragReleaseType, { bubbles: true, cancelable: true }) + this.velocityX = init.velocityX + this.velocityY = init.velocityY + } +} + +export let dragRelease = createMixin((handle) => { + let node: HTMLElement | undefined + let tracking = false + let velocityX = 0 + let velocityY = 0 + let lastX = 0 + let lastY = 0 + let lastT = 0 + + handle.addEventListener('insert', (event) => { + node = event.node + }) + + return () => ( + { + if (!event.isPrimary) return + tracking = true + lastX = event.clientX + lastY = event.clientY + lastT = event.timeStamp + node?.setPointerCapture(event.pointerId) + }), + on('pointermove', (event) => { + if (!tracking) return + let dt = Math.max(1, event.timeStamp - lastT) + velocityX = (event.clientX - lastX) / dt + velocityY = (event.clientY - lastY) / dt + lastX = event.clientX + lastY = event.clientY + lastT = event.timeStamp + }), + on('pointerup', () => { + if (!tracking) return + tracking = false + node?.dispatchEvent(new DragReleaseEvent({ velocityX, velocityY })) + }), + ]} + /> + ) +}) +``` + +Consume it: + +```tsx +
    { + console.log('velocity:', event.velocityX, event.velocityY) + }), + ]} +/> +``` diff --git a/examples/ts-remix-chat/.agents/skills/remix/references/data-and-validation.md b/examples/ts-remix-chat/.agents/skills/remix/references/data-and-validation.md new file mode 100644 index 0000000000..5391ae36f3 --- /dev/null +++ b/examples/ts-remix-chat/.agents/skills/remix/references/data-and-validation.md @@ -0,0 +1,366 @@ +# Data Access and Validation + +## What This Covers + +How input becomes a value the app trusts, and how that value reaches storage. Read this when the task involves: + +- Defining database tables, columns, relations, and migrations +- Querying or mutating persisted data with `Database` +- Parsing and validating user input from forms, query strings, or external payloads +- Choosing between schema-level checks, table validation hooks, and migration-level constraints + +For where validation runs in the request lifecycle, see `routing-and-controllers.md`. For session or identity-bound writes, see `auth-and-sessions.md`. + +## Table Definitions (`remix/data-table`) + +Define tables with typed columns, relations, and optional validation hooks: + +```typescript +import { belongsTo, column as c, hasMany, table } from 'remix/data-table' +import type { TableRow, TableRowWith } from 'remix/data-table' + +export const books = table({ + name: 'books', + columns: { + id: c.integer().primaryKey().autoIncrement(), + slug: c.text().notNull().unique(), + title: c.text().notNull(), + author: c.text().notNull(), + price: c.decimal(10, 2).notNull(), + genre: c.text().notNull(), + in_stock: c.boolean(), + }, +}) + +export const orders = table({ + name: 'orders', + columns: { + id: c.integer().primaryKey().autoIncrement(), + user_id: c.integer().notNull().references('users', 'id'), + total: c.decimal(10, 2).notNull(), + created_at: c.integer().notNull(), + }, + relations: { + user: belongsTo('users', 'user_id'), + items: hasMany('order_items', 'order_id'), + }, +}) + +export type Book = TableRow +export type Order = TableRow +export type OrderWithItems = TableRowWith +``` + +### Column types + +| Method | SQL type | +| ----------------------------- | ------------------ | +| `c.integer()` | INTEGER | +| `c.text()` | TEXT | +| `c.boolean()` | BOOLEAN | +| `c.decimal(precision, scale)` | DECIMAL | +| `c.enum([...])` | TEXT (string enum) | +| `c.uuid()` | UUID / TEXT | +| `c.varchar(length)` | VARCHAR | + +Column modifiers: `.primaryKey()`, `.autoIncrement()`, `.notNull()`, `.unique()`, `.references(table, column, fkName?)`, `.onDelete(action)`, `.default(value)`. + +Composite primary keys go on the table option, not the column: `primaryKey: ['order_id', 'book_id']`. + +### Schema vs migrations + +Column modifiers on runtime `table(...)` definitions in `app/data/schema.ts` describe app-facing column metadata. They do not create or update database tables by themselves. The source of truth for actual DDL and constraints is your hand-written SQL migration files. Two valid patterns: + +- **Mirror constraints in schema and SQL** — table definitions stay useful as schema-level docs, and migrations still own the actual DDL. +- **Bare columns in schema, constraints in SQL** — schema describes what the app reads and writes; migrations own the DDL and constraints. + +Pick one and apply it consistently across the app. + +### Table lifecycle hooks + +Tables can define validation and lifecycle hooks: + +- `validate` runs before `create` and `update` writes and should return either `{ value }` or `{ issues }` +- `beforeWrite` can normalize or veto `create`/`update` values +- `afterWrite` observes completed `create`/`update` operations +- `beforeDelete` and `afterDelete` observe or veto deletes +- `afterRead` can normalize or reject row values after reads + +```typescript +export const books = table({ + name: 'books', + columns: {/* ... */}, + beforeWrite({ value }) { + if (typeof value.slug === 'string') { + return { value: { ...value, slug: value.slug.trim().toLowerCase() } } + } + return { value } + }, + validate({ operation, value }) { + let issues = [] + if (operation === 'create' && !value.slug) { + issues.push({ message: 'Slug is required.', path: ['slug'] }) + } + return issues.length > 0 ? { issues } : { value } + }, + afterRead({ value }) { + return { value } + }, +}) +``` + +## Database Setup + +Create a database and expose it via middleware: + +```typescript +import BetterSqlite3 from 'better-sqlite3' +import { createSqliteDatabase } from 'remix/data-table/sqlite' + +let sqlite = new BetterSqlite3('./db/app.db') +sqlite.pragma('foreign_keys = ON') +export let db = createSqliteDatabase(sqlite) +``` + +`createSqliteDatabase` accepts synchronous SQLite clients with a shared `prepare`/`exec` surface, including Node's `node:sqlite`, Bun's `bun:sqlite`, and compatible clients. Use whichever client fits the runtime instead of assuming `better-sqlite3` is required. + +### Database middleware + +```typescript +import type { Database } from 'remix/data-table' +import { createContextKey, type Middleware } from 'remix/router' + +export const databaseContext = createContextKey() + +export function loadDatabase(): Middleware { + return async (context, next) => { + context.set(databaseContext, db) + return next() + } +} +``` + +### Querying + +```typescript +let db = get(databaseContext) + +// Find by primary key +let book = await db.find(books, id) + +// Find one by condition +let user = await db.findOne(users, { where: { email } }) + +// Find many with ordering +let allBooks = await db.findMany(books, { orderBy: ['id', 'asc'] }) + +// Count +let total = await db.count(orders, { where: { user_id: userId } }) + +// Query builder +let genres = await db + .query(books) + .select('genre') + .distinct() + .orderBy('genre', 'asc') + .all() + +// Create +let newBook = await db.create(books, { + slug: 'new-book', + title: 'New Book' /* ... */, +}) + +// Update +await db.update(books, bookId, { title: 'Updated Title' }) + +// Delete +await db.delete(books, bookId) +``` + +### Operators + +```typescript +import { inList } from 'remix/data-table/operators' + +let featured = await db.findMany(books, { + where: inList('slug', ['book-a', 'book-b', 'book-c']), +}) +``` + +## Migrations + +Migrations are plain SQL files. Each migration is a directory named `YYYYMMDDHHmmss_/` containing a hand-written `up.sql` (required) and an optional `down.sql` (omit for irreversible migrations). + +```txt +db/ + migrations/ + 20260228090000_create_users/ + up.sql + down.sql + 20260301083000_add_books_search_index/ + up.sql +``` + +### Writing migrations + +Write standard SQL in `up.sql` and `down.sql`: + +```sql +-- up.sql +create table users ( + id integer primary key autoincrement, + email text not null unique, + name text not null +); + +create index users_email_idx on users (email); +``` + +```sql +-- down.sql +drop table if exists users; +``` + +Do **not** import app code (e.g. `app/data/schema.ts`) into migration files. Migrations must be stable, immutable artifacts — importing live schema definitions creates drift between what the migration meant when it was written and what it does when replayed later. SQL files guarantee stability because they cannot import anything. + +### Transaction modes + +Migrations run inside a transaction by default (when the database supports transactional DDL). Override per migration with a directive comment in `up.sql`: + +```sql +-- data-table/transaction: none +create index concurrently users_email_idx on users (email); +``` + +Modes: `auto` (default — wrap when supported), `required` (wrap; throw if unsupported), `none` (never wrap). + +### Running migrations + +```typescript +import { loadMigrations } from 'remix/data-table/migrations/node' + +let migrations = await loadMigrations('./db/migrations') +await db.migrate(migrations) +``` + +The database checksums each `up.sql` and detects drift if a previously applied migration changes. +Use `db.migrationStatus(migrations)` to inspect applied/pending/drifted state, and +`db.migrate(migrations, { direction: 'down' })` to revert. + +## Input Validation (`remix/data-schema`) + +Use `data-schema` to validate user input (forms, query params, API payloads). This is separate from table-level `validate` hooks which run at persistence. + +### Schema builders + +```typescript +import * as s from 'remix/data-schema' +import { email, minLength, maxLength } from 'remix/data-schema/checks' + +let userSchema = s.object({ + name: s.string().pipe(minLength(1)), + email: s.string().pipe(email()), + age: s.optional(s.number()), +}) + +let result = s.parse(userSchema, data) +``` + +### FormData validation + +Use `remix/data-schema/form-data` to validate `FormData` directly: + +```typescript +import * as s from 'remix/data-schema' +import * as f from 'remix/data-schema/form-data' +import { email, minLength } from 'remix/data-schema/checks' + +let signupSchema = f.object({ + name: f.field(s.string().pipe(minLength(1))), + email: f.field(s.string().pipe(email())), + password: f.field(s.string().pipe(minLength(8))), +}) + +// In a controller action: +let formData = get(FormData) +let { name, email, password } = s.parse(signupSchema, formData) +``` + +### Reading FormData: middleware vs `request.formData()` + +There are two ways to get a `FormData` value inside an action. + +The recommended way: register `formData()` middleware in the root stack and read with `get(FormData)`. The body is parsed once per request, and the typed `FormData` value flows through the context system. This also lets `methodOverride()` and CSRF middleware work uniformly. + +```typescript +import { formData } from 'remix/middleware/form-data' + +let router = createRouter({ + middleware: [, /* ... */ formData() /* ... */], +}) + +// In an action: +let parsed = s.parseSafe(signupSchema, get(FormData)) +``` + +The fallback: `await request.formData()` directly. This works without middleware and is fine for small one-off cases, but it bypasses the context system, runs once per call site, and doesn't compose with middleware that depends on parsed form fields. + +### Safe parsing + +`s.parse` throws on invalid input. `s.parseSafe` returns a tagged result and is usually what an action wants, since validation failure is an expected outcome (re-render the form with errors) rather than an exception: + +```typescript +let result = s.parseSafe(signupSchema, get(FormData)) +if (!result.success) { + return render(, { status: 400 }) +} +let { name, email, password } = result.value +``` + +Returning a `Response` for validation failures keeps the route contract honest: the same action returns 200 on success, 400 with errors on bad input, no out-of-band exception flow. + +### Transforming validated output + +Use `.transform(...)` when a schema should validate one shape but return another value or output type. Transforms run after validation and compose with `.pipe(...)` and `.refine(...)`: + +```typescript +import * as coerce from 'remix/data-schema/coerce' + +let slugSchema = s + .string() + .pipe(minLength(1)) + .transform((value) => value.trim().toLowerCase().replace(/\s+/g, '-')) + +let pageSchema = f.object({ + page: f.field(s.defaulted(coerce.coerceNumber(), 1).refine(Number.isInteger)), + q: f.field(s.defaulted(s.string(), '').transform((value) => value.trim())), +}) + +let { page, q } = s.parse(pageSchema, formData) +``` + +### Anti-patterns + +Avoid these shapes when reading and validating input: + +- **Raw `formData.get('name')` plus an `if (typeof name !== 'string')` guard**, then a thrown custom error. This reinvents what `data-schema` already does, loses the typed result, and pushes error translation into a `try/catch` instead of a return value. +- **Letting route-local domain errors leak out of the action.** Translate expected outcomes (bad input, missing record, duplicate entry) into the `Response` the route means to return instead of throwing a custom `Error` subclass with a `status` field and catching it later. +- **Trusting `params`, query strings, or external payloads without a schema.** Anything that crosses a trust boundary should be parsed before it reaches business logic. + +### Common patterns + +```typescript +// Optional with default +let limitSchema = f.field(s.defaulted(s.string(), '10')) + +// Union types +let methodSchema = s.union([ + s.literal('credentials'), + s.literal('google'), + s.literal('github'), +]) + +// Refinements +let idSchema = s.number().refine(Number.isInteger, 'Expected an integer') +``` diff --git a/examples/ts-remix-chat/.agents/skills/remix/references/hydration-frames-navigation.md b/examples/ts-remix-chat/.agents/skills/remix/references/hydration-frames-navigation.md new file mode 100644 index 0000000000..8f68fa0c20 --- /dev/null +++ b/examples/ts-remix-chat/.agents/skills/remix/references/hydration-frames-navigation.md @@ -0,0 +1,305 @@ +# Hydration, Frames, and Navigation + +## What This Covers + +How server-rendered UI becomes interactive in the browser, and how the page updates without a full navigation. Read this when the task involves: + +- Marking a component for client-side hydration with `clientEntry` +- Booting the client runtime with `run` +- Streaming server content into a region of the page with `` and reloading those regions +- Handling browser HMR updates for hydrated entries +- Triggering Navigation API transitions with `navigate(...)` or `link(...)` +- Server rendering with `renderToStream` or `renderToString` +- Managing the document `` + +For component-local state and updates, see `component-model.md`. For host-element behavior and events, see `mixins-styling-events.md`. For browser asset HMR setup, see `assets-and-browser-modules.md`. + +## Server First, Then Hydrate + +Make the server route correct before adding `clientEntry(...)`. A POST should already do the right thing on its own — return HTML, a redirect, or an error response — and a GET should already render the page the user expects. `clientEntry` exists to layer interactivity on top of UI that already works without it. + +When server state changes after a mutation, prefer reloading a `` when the UI region already maps cleanly to a server-rendered route. Frames re-fetch the same route, so the rendering logic stays in one place and the client does not need a parallel "state" API. + +```tsx +on('submit', async (event, signal) => { + event.preventDefault() + await fetch(routes.cart.add.href(), { + method: 'POST', + body: new FormData(event.currentTarget), + signal, + }) + if (signal.aborted) return + await handle.frames.get('cart-summary')?.reload() +}) +``` + +Use polling or a small JSON state endpoint when the data changes outside this page, or when a tiny shared widget would be heavier to model as a frame. Pick the lightest sync mechanism that preserves clear ownership of rendering logic. + +## Client Entries + +Use `clientEntry` to mark a component for client-side hydration. In source-served apps, prefer the source module's `import.meta.url` as the entry ID and let server rendering map it to the public asset URL: + +```tsx +import { clientEntry, on, type Handle } from 'remix/ui' + +export const Counter = clientEntry( + import.meta.url, + function Counter(handle: Handle<{ initialCount: number; label: string }>) { + let count = handle.props.initialCount + + return () => ( +
    + + {handle.props.label}: {count} + + +
    + ) + }, +) +``` + +On the server, pass the asset server to the standard render middleware so source file URLs become browser-loadable asset URLs without hard-coding deployment paths in component modules: + +```tsx +import { render } from 'remix/middleware/render' + +let router = createRouter({ + middleware: [render({ assets: assetServer })], +}) +``` + +If the module export name differs from the component function name, include `#ExportName` in the entry ID. Custom rendering pipelines may instead provide the exact export name through `renderToStream({ resolveClientEntry })`. + +On the server, `clientEntry` components render like any other component. The server wraps their output in comment markers and serializes props into a ` + + + {children} + + ) + } +} + +const PAGE_STYLES = ` +:root { + color-scheme: light dark; + --bg: #eeebd4; + --ink: #111111; + --muted: #3e3529; + --accent: #d3481b; + --surface: #fffdf6; + --border: color-mix(in srgb, #111111 14%, transparent); + --shadow: 0 1px 2px color-mix(in srgb, #111111 8%, transparent), + 0 8px 24px color-mix(in srgb, #111111 6%, transparent); + --font-display: 'Bricolage Grotesque', ui-sans-serif, system-ui, sans-serif; + --font-body: 'Inter', ui-sans-serif, system-ui, sans-serif; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #111111; + --ink: #ffffff; + --muted: #aea691; + --accent: #e06e49; + --surface: #1c1c1c; + --border: color-mix(in srgb, #ffffff 16%, transparent); + --shadow: 0 1px 2px color-mix(in srgb, #000000 40%, transparent), + 0 8px 24px color-mix(in srgb, #000000 35%, transparent); + } +} +@media (forced-colors: active) { + :root { + --bg: Canvas; + --ink: CanvasText; + --muted: CanvasText; + --accent: LinkText; + --surface: Canvas; + --border: CanvasText; + --shadow: none; + } +} +html { + height: 100%; + font-size: 100%; +} +body { + height: 100%; + min-height: 100%; +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} +@media (prefers-color-scheme: dark) { + img[alt="TanStack"] { + filter: invert(1); + } +} +` + +const bodyStyle = css({ + margin: 0, + minHeight: '100%', + background: 'var(--bg)', + color: 'var(--ink)', + fontFamily: 'var(--font-body)', + fontWeight: 300, + lineHeight: 1.5, +}) + +function readAppDisplayName(value: string): string { + return value.startsWith('%%') ? 'Remix App' : decodeURIComponent(value) +} diff --git a/examples/ts-remix-chat/app/actions/home-page.tsx b/examples/ts-remix-chat/app/actions/home-page.tsx new file mode 100644 index 0000000000..2ed15a3885 --- /dev/null +++ b/examples/ts-remix-chat/app/actions/home-page.tsx @@ -0,0 +1,10 @@ +import { Chat } from '../ui/chat.tsx' +import { Document } from './document.tsx' + +export function HomePage() { + return () => ( + + + + ) +} diff --git a/examples/ts-remix-chat/app/actions/public/entry.ts b/examples/ts-remix-chat/app/actions/public/entry.ts new file mode 100644 index 0000000000..a4a872bf44 --- /dev/null +++ b/examples/ts-remix-chat/app/actions/public/entry.ts @@ -0,0 +1,52 @@ +import { run } from 'remix/ui' + +const app = run({ + async loadModule(moduleUrl, exportName) { + let mod = await import(moduleUrl) + return mod[exportName] + }, + async resolveFrame(src, options) { + let response = await fetch(src, { + headers: { Accept: 'text/html' }, + method: options?.method, + body: getRequestBody( + options?.formData, + options?.method, + options?.encType, + ), + signal: options?.signal, + }) + if (!response.ok) { + return `
    Frame error: ${response.status} ${response.statusText}
    ` + } + + if (response.body) return response.body + return await response.text() + }, +}) + +if (import.meta.hot) { + import.meta.hot.on('server:update', async () => { + try { + await app.ready() + await app.frames.top.reload() + } catch (error) { + console.error('Error reloading top frame on server update', error) + } + }) +} + +function getRequestBody( + formData?: FormData, + method?: string, + encType?: string, +): BodyInit | undefined { + if (!formData || method?.toLowerCase() === 'get') return + if (encType !== 'application/x-www-form-urlencoded') return formData + + let body = new URLSearchParams() + for (let [name, value] of formData) { + body.append(name, typeof value === 'string' ? value : value.name) + } + return body +} diff --git a/examples/ts-remix-chat/app/assets.ts b/examples/ts-remix-chat/app/assets.ts new file mode 100644 index 0000000000..26bb20bf30 --- /dev/null +++ b/examples/ts-remix-chat/app/assets.ts @@ -0,0 +1,55 @@ +import { createAssetServer } from 'remix/assets' +import { uiHmr } from 'remix/ui-hmr/assets' + +const rootDir = process.cwd() +const nodeEnv = process.env.NODE_ENV ?? 'development' +const isDevelopment = nodeEnv === 'development' +const isHmr = Boolean(isDevelopment && process.env.REMIX_NODE_HMR) + +export const assets = createAssetServer({ + basePath: '/assets', + rootDir, + // pnpm stores workspace packages under the repo root. Mount that store so + // `remix/ui` and `@tanstack/*` resolve inside a configured mount. + mounts: { + app: 'app', + npm: 'node_modules', + workspace: '../../node_modules', + packages: '../../packages', + }, + + allowFiles: [ + 'app/routes.ts', + 'app/**/public/**', + 'app/ui/**', + 'app/lib/**', + 'app/data/**', + 'app/shims/**', + ], + allowPackages: [ + 'remix', + '@tanstack/ai-remix', + '@tanstack/ai-client', + '@tanstack/ai', + 'zod', + ], + denyFiles: ['app/**/*.test.*'], + sourceMaps: isDevelopment ? 'external' : undefined, + minify: !isDevelopment, + watch: isDevelopment, + hmr: isHmr + ? async () => + (await import('remix/node-hmr/runtime')).createBrowserHmrChannel() + : undefined, + scripts: { + // Remix's unbundled asset server cannot compile this CJS package. + external: ['partial-json'], + loaders: isHmr ? [uiHmr()] : undefined, + }, +}) + +const entry = 'app/actions/public/entry.ts' + +export const entryHref = await assets.getHref(entry) +export const entryPreloads = await assets.getPreloads(entry) +export const partialJsonHref = await assets.getHref('app/shims/partial-json.ts') diff --git a/examples/ts-remix-chat/app/chat.test.e2e.ts b/examples/ts-remix-chat/app/chat.test.e2e.ts new file mode 100644 index 0000000000..89269155c1 --- /dev/null +++ b/examples/ts-remix-chat/app/chat.test.e2e.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { createRouter } from 'remix/router' + +import chatController from './actions/chat/controller.ts' +import { routes } from './routes.ts' + +// ponytail: map only the chat controller. The app router loads Chat, and +// @tanstack/ai-remix source has extensionless imports Node cannot resolve. +const router = createRouter() +router.map(routes.chat, chatController) + +const chatUrl = 'http://localhost' + routes.chat.stream.href() + +function postChat(body: string) { + return router.fetch( + new Request(chatUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + }), + ) +} + +describe('chat', () => { + it('returns 400 when the POST body is not JSON', async () => { + const response = await postChat('not-json') + assert.equal(response.status, 400) + assert.equal(await response.text(), 'Bad request') + }) + + it('returns 400 when JSON is not a chat request', async () => { + const response = await postChat( + JSON.stringify({ messages: [{ role: 'user', content: 'hi' }] }), + ) + assert.equal(response.status, 400) + assert.match(await response.text(), /RunAgentInput/) + }) +}) diff --git a/examples/ts-remix-chat/app/data/guitars.ts b/examples/ts-remix-chat/app/data/guitars.ts new file mode 100644 index 0000000000..55d2f878bb --- /dev/null +++ b/examples/ts-remix-chat/app/data/guitars.ts @@ -0,0 +1,93 @@ +export interface Guitar { + id: number + name: string + image: string + description: string + shortDescription: string + price: number +} + +const guitars: Array = [ + { + id: 1, + name: 'TanStack Ukelele', + image: '/example-ukulele-tanstack.jpg', + description: + "Introducing the TanStack Signature Ukulele—a beautifully handcrafted concert ukulele that combines exceptional sound quality with distinctive style. Featuring a warm, resonant koa-wood body with natural grain patterns, this instrument delivers the rich, mellow tones Hawaii is famous for. The exclusive TanStack palm tree inlay on the soundhole adds a unique touch of island flair, while the matching branded headstock makes this a true collector's piece for developers and musicians alike. Whether you're a beginner looking for a quality starter instrument or an experienced player wanting something special, the TanStack Ukulele brings together craftsmanship, character, and that unmistakable tropical spirit.", + shortDescription: + 'Premium koa-wood ukulele featuring exclusive TanStack branding, perfect for beach vibes and island-inspired melodies.', + price: 299, + }, + { + id: 2, + name: 'Video Game Guitar', + image: '/example-guitar-video-games.jpg', + description: + "The Video Game Guitar is a unique acoustic guitar that features a design inspired by video games. It has a sleek, high-gloss finish and a comfortable playability. The guitar's ergonomic body and fast neck profile ensure comfortable playability for hours on end.", + shortDescription: + 'A unique electric guitar with a video game design, high-gloss finish, and comfortable playability.', + price: 699, + }, + { + id: 3, + name: 'Superhero Guitar', + image: '/example-guitar-superhero.jpg', + description: + "The Superhero Guitar is a bold black electric guitar that stands out with its unique superhero logo design. Its sleek, high-gloss finish and powerful pickups make it perfect for high-energy performances. The guitar's ergonomic body and fast neck profile ensure comfortable playability for hours on end.", + shortDescription: + 'A bold black electric guitar with a unique superhero logo, high-gloss finish, and powerful pickups.', + price: 699, + }, + { + id: 4, + name: 'Motherboard Guitar', + image: '/example-guitar-motherboard.jpg', + description: + "This guitar is a tribute to the motherboard of a computer. It's a unique and stylish instrument that will make you feel like a hacker. The intricate circuit-inspired design features actual LED lights that pulse with your playing intensity, while the neck is inlaid with binary code patterns that glow under stage lights. Each pickup has been custom-wound to produce tones ranging from clean digital precision to glitched-out distortion, perfect for electronic music fusion. The Motherboard Guitar seamlessly bridges the gap between traditional craftsmanship and cutting-edge technology, making it the ultimate instrument for the digital age musician.", + shortDescription: + 'A tech-inspired electric guitar featuring LED lights and binary code inlays that glow under stage lights.', + price: 649, + }, + { + id: 5, + name: 'Racing Guitar', + image: '/example-guitar-racing.jpg', + description: + "Engineered for speed and precision, the Racing Guitar embodies the spirit of motorsport in every curve and contour. Its aerodynamic body, painted in classic racing stripes and high-gloss finish, is crafted from lightweight materials that allow for effortless play during extended performances. The custom low-action setup and streamlined neck profile enable lightning-fast fretwork, while specially designed pickups deliver a high-octane tone that cuts through any mix. Built with performance-grade hardware including racing-inspired control knobs and checkered flag inlays, this guitar isn't just played—it's driven to the limits of musical possibility.", + shortDescription: + 'A lightweight, aerodynamic guitar with racing stripes and a low-action setup designed for speed and precision.', + price: 679, + }, + { + id: 6, + name: 'Steamer Trunk Guitar', + image: '/example-guitar-steamer-trunk.jpg', + description: + 'The Steamer Trunk Guitar is a semi-hollow body instrument that exudes vintage charm and character. Crafted from reclaimed antique luggage wood, it features brass hardware that adds a touch of elegance and durability. The fretboard is adorned with a world map inlay, making it a unique piece that tells a story of travel and adventure.', + shortDescription: + 'A semi-hollow body guitar with brass hardware and a world map inlay, crafted from reclaimed antique luggage wood.', + price: 629, + }, + { + id: 7, + name: "Travelin' Man Guitar", + image: '/example-guitar-traveling.jpg', + description: + "The Travelin' Man Guitar is an acoustic masterpiece adorned with vintage postcards from around the world. Each postcard tells a story of adventure and wanderlust, making this guitar a unique piece of art. Its rich, resonant tones and comfortable playability make it perfect for musicians who love to travel and perform.", + shortDescription: + 'An acoustic guitar with vintage postcards, rich tones, and comfortable playability.', + price: 499, + }, + { + id: 8, + name: 'Flowerly Love Guitar', + image: '/example-guitar-flowers.jpg', + description: + "The Flowerly Love Guitar is an acoustic masterpiece adorned with intricate floral designs on its body. Each flower is hand-painted, adding a touch of nature's beauty to the instrument. Its warm, resonant tones make it perfect for both intimate performances and larger gatherings.", + shortDescription: + 'An acoustic guitar with hand-painted floral designs and warm, resonant tones.', + price: 599, + }, +] + +export default guitars diff --git a/examples/ts-remix-chat/app/lib/guitar-tools.ts b/examples/ts-remix-chat/app/lib/guitar-tools.ts new file mode 100644 index 0000000000..1d9d4233c5 --- /dev/null +++ b/examples/ts-remix-chat/app/lib/guitar-tools.ts @@ -0,0 +1,140 @@ +import { toolDefinition } from '@tanstack/ai' +import { clientTools as createClientTools } from '@tanstack/ai-client' +import { z } from 'zod' +import guitars from '../data/guitars.ts' + +export const getGuitarsToolDef = toolDefinition({ + name: 'getGuitars', + description: 'Get all products from the database', + inputSchema: z.object({}), + outputSchema: z.array( + z.object({ + id: z.number(), + name: z.string(), + image: z.string(), + description: z.string(), + shortDescription: z.string(), + price: z.number(), + }), + ), +}) + +export const getGuitars = getGuitarsToolDef.server(() => guitars) + +export const recommendGuitarTool = toolDefinition({ + name: 'recommendGuitar', + description: + 'REQUIRED tool to display a guitar recommendation to the user. This tool MUST be used whenever recommending a guitar - do NOT write recommendations yourself. This displays the guitar in a special appealing format with a buy button.', + inputSchema: z.object({ + id: z + .string() + .describe( + 'The ID of the guitar to recommend (from the getGuitars results)', + ), + }), + outputSchema: z.object({ + id: z.string(), + }), +}) + +export const recommendGuitarToolClient = recommendGuitarTool.client( + async (args) => { + return { id: args.id } + }, +) + +export const getPersonalGuitarPreferenceTool = toolDefinition({ + name: 'getPersonalGuitarPreference', + description: + "Get the user's guitar preference from their local browser storage", + inputSchema: z.object({}), + outputSchema: z.object({ + preference: z.string(), + }), +}) + +export const getPersonalGuitarPreferenceToolClient = + getPersonalGuitarPreferenceTool.client(async () => { + return { preference: 'acoustic' } + }) + +export const addToWishListTool = toolDefinition({ + name: 'addToWishList', + description: "Add a guitar to the user's wish list (requires approval)", + inputSchema: z.object({ + guitarId: z.string(), + }), + outputSchema: z.object({ + success: z.boolean(), + guitarId: z.string(), + totalItems: z.number(), + }), + needsApproval: true, +}) + +export const addToWishListToolClient = addToWishListTool.client((args) => { + const stored = localStorage.getItem('wishList') + const parsed: unknown = stored ? JSON.parse(stored) : [] + const wishList = Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === 'string') + : [] + wishList.push(args.guitarId) + localStorage.setItem('wishList', JSON.stringify(wishList)) + return { + success: true, + guitarId: args.guitarId, + totalItems: wishList.length, + } +}) + +export const addToCartTool = toolDefinition({ + name: 'addToCart', + description: 'Add a guitar to the shopping cart (requires approval)', + inputSchema: z.object({ + guitarId: z.string(), + quantity: z.number(), + }), + outputSchema: z.object({ + success: z.boolean(), + cartId: z.string(), + guitarId: z.string(), + quantity: z.number(), + totalItems: z.number(), + }), + needsApproval: true, +}) + +export const addToCartToolServer = addToCartTool.server(async (args) => { + return { + success: true, + cartId: 'CART_' + Date.now(), + guitarId: args.guitarId, + quantity: args.quantity, + totalItems: args.quantity, + } +}) + +export const addToCartToolClient = addToCartTool.client(async (args) => { + return { + success: true, + cartId: 'CART_CLIENT_' + Date.now(), + guitarId: args.guitarId, + quantity: args.quantity, + totalItems: args.quantity, + } +}) + +export const serverTools = [ + getGuitars, + recommendGuitarTool, + getPersonalGuitarPreferenceTool, + addToWishListTool, + addToCartToolServer, +] + +export const clientTools = createClientTools( + recommendGuitarToolClient, + getPersonalGuitarPreferenceToolClient, + addToWishListToolClient, + addToCartToolClient, +) diff --git a/examples/ts-remix-chat/app/router.ts b/examples/ts-remix-chat/app/router.ts new file mode 100644 index 0000000000..755310ef26 --- /dev/null +++ b/examples/ts-remix-chat/app/router.ts @@ -0,0 +1,24 @@ +import { createRouter, type MiddlewareContext } from 'remix/router' +import { render } from 'remix/middleware/render' +import { staticFiles } from 'remix/middleware/static' + +import chatController from './actions/chat/controller.ts' +import controller from './actions/controller.tsx' +import { assets } from './assets.ts' +import { routes } from './routes.ts' + +const renderMiddleware = render({ assets }) +type AppContext = MiddlewareContext<[typeof renderMiddleware]> + +declare module 'remix/router' { + interface RouterTypes { + context: AppContext + } +} + +export const router = createRouter({ + middleware: [staticFiles('./public', { index: false }), renderMiddleware], +}) + +router.map(routes, controller) +router.map(routes.chat, chatController) diff --git a/examples/ts-remix-chat/app/routes.ts b/examples/ts-remix-chat/app/routes.ts new file mode 100644 index 0000000000..601045854c --- /dev/null +++ b/examples/ts-remix-chat/app/routes.ts @@ -0,0 +1,9 @@ +import { get, post, route } from 'remix/routes' + +export const routes = route({ + assets: get('/assets/*path'), + home: '/', + chat: { + stream: post('/chat'), + }, +}) diff --git a/examples/ts-remix-chat/app/shims/partial-json.ts b/examples/ts-remix-chat/app/shims/partial-json.ts new file mode 100644 index 0000000000..d440d473b3 --- /dev/null +++ b/examples/ts-remix-chat/app/shims/partial-json.ts @@ -0,0 +1,3 @@ +export function parse(text: string) { + return JSON.parse(text) +} diff --git a/examples/ts-remix-chat/app/ui/chat.tsx b/examples/ts-remix-chat/app/ui/chat.tsx new file mode 100644 index 0000000000..2f6570e2f8 --- /dev/null +++ b/examples/ts-remix-chat/app/ui/chat.tsx @@ -0,0 +1,496 @@ +import { createChat, fetchServerSentEvents } from '@tanstack/ai-remix' +import { clientEntry, css, on, type Handle } from 'remix/ui' +import guitars from '../data/guitars.ts' +import { clientTools } from '../lib/guitar-tools.ts' +import { routes } from '../routes.ts' + +export const Chat = clientEntry(import.meta.url, function Chat(handle: Handle) { + const chat = createChat(handle, { + connection: fetchServerSentEvents(routes.chat.stream.href()), + tools: clientTools, + }) + + return () => ( +
    + + Skip to chat + +
    + TanStack +

    Remix Chat

    +
    +
    +

    Guitar shop

    +

    + Ask for a recommendation. The assistant looks at inventory, then shows + a card you can buy. +

    +
    + {chat.messages.length === 0 ? ( +

    No messages yet. Try “Recommend a guitar”.

    + ) : ( +
      + {chat.messages.map((message) => { + const visibleParts = message.parts + .map((part, index) => renderMessagePart(part, index)) + .filter((node) => node !== null) + if (visibleParts.length === 0) return null + return ( +
    • +

      + {message.role === 'user' ? 'You' : 'Assistant'} +

      + {visibleParts} +
    • + ) + })} +
    + )} +
    + {chat.isLoading ?

    Thinking…

    : null} + {chat.error ? ( +

    + {chat.error.message} +

    + ) : null} +
    +
    +
    { + event.preventDefault() + const form = event.currentTarget + const formData = new FormData(form) + const text = String(formData.get('message') ?? '').trim() + if (!text) return + form.reset() + void chat.sendMessage(text) + }), + ]} + > + + {chat.isLoading ? ( + + ) : ( + + )} +
    +
    +
    + ) +}) + +function renderMessagePart(part: MessagePartViewModel, index: number) { + const key = partKey(part, index) + if ( + part.type === 'text' && + typeof part.content === 'string' && + part.content + ) { + return ( +

    + {part.content} +

    + ) + } + const guitarId = guitarIdFromPart(part) + if (guitarId !== undefined) { + return renderGuitarCard(key, guitarId) + } + return null +} + +function renderGuitarCard(key: string, id: number) { + const guitar = guitars.find((item) => item.id === id) + if (!guitar) { + return ( +

    + Guitar {id} is not in inventory. +

    + ) + } + return ( +
    + {guitar.name} +
    +

    {guitar.name}

    +

    {guitar.shortDescription}

    +

    ${guitar.price}

    +
    +
    + ) +} + +function partKey(part: MessagePartViewModel, index: number) { + if (typeof part.id === 'string' && part.id) return part.id + return String(index) +} + +function guitarIdFromPart(part: MessagePartViewModel): number | undefined { + if (part.type !== 'tool-call' || part.name !== 'recommendGuitar') { + return undefined + } + const fromOutput = idFromUnknown(part.output) + if (fromOutput !== undefined) return fromOutput + const fromInput = idFromUnknown(part.input) + if (fromInput !== undefined) return fromInput + if (typeof part.arguments === 'string' && part.arguments.length > 0) { + try { + const parsed: unknown = JSON.parse(part.arguments) + return idFromUnknown(parsed) + } catch { + return undefined + } + } + return undefined +} + +function idFromUnknown(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + if (isRecord(value) && 'id' in value) { + return idFromUnknown(value.id) + } + return undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +type MessagePartViewModel = { + type: string + id?: string + name?: string + content?: unknown + output?: unknown + input?: unknown + arguments?: string +} + +const shellStyle = css({ + height: '100%', + minHeight: '100%', + display: 'flex', + flexDirection: 'column', +}) + +const skipStyle = css({ + position: 'absolute', + left: '-999px', + top: '0.75rem', + zIndex: 10, + padding: '0.5rem 0.75rem', + background: 'var(--surface)', + color: 'var(--ink)', + borderRadius: '8px', + outline: '2px solid var(--accent)', + outlineOffset: '2px', + '&:focus': { + left: '0.75rem', + }, +}) + +const headerStyle = css({ + display: 'flex', + alignItems: 'center', + gap: '0.75rem', + flexShrink: 0, + padding: '1rem 1.25rem', + borderBottom: '1px solid var(--border)', +}) + +const logoStyle = css({ + height: '1.5rem', + width: 'auto', + display: 'block', +}) + +const kickerStyle = css({ + margin: 0, + fontSize: '0.875rem', + lineHeight: 1.25, + fontWeight: 400, + color: 'var(--muted)', +}) + +const mainStyle = css({ + flex: 1, + minHeight: 0, + display: 'flex', + flexDirection: 'column', + gap: '1rem', + width: 'min(42rem, 100%)', + marginInline: 'auto', + padding: '1.5rem 1rem 1.25rem', +}) + +const titleStyle = css({ + margin: 0, + fontFamily: 'var(--font-display)', + fontSize: 'clamp(1.75rem, 4vw, 2.25rem)', + lineHeight: 1.15, + fontWeight: 700, + letterSpacing: '-0.02em', + color: 'var(--accent)', +}) + +const ledeStyle = css({ + margin: 0, + maxWidth: '42ch', + color: 'var(--muted)', + fontSize: '1rem', +}) + +const transcriptStyle = css({ + flex: 1, + minHeight: 0, + overflowY: 'auto', + display: 'flex', + flexDirection: 'column', + gap: '0.75rem', + paddingBottom: '0.5rem', +}) + +const emptyStyle = css({ + margin: 0, + padding: '1.25rem', + background: 'var(--surface)', + borderRadius: '20px', + boxShadow: 'var(--shadow)', + color: 'var(--muted)', +}) + +const listStyle = css({ + listStyle: 'none', + margin: 0, + padding: 0, + display: 'flex', + flexDirection: 'column', + gap: '0.75rem', +}) + +const bubbleStyle = css({ + maxWidth: '100%', + padding: '0.9rem 1rem', + borderRadius: '18px', + background: 'var(--surface)', + boxShadow: 'var(--shadow)', +}) + +const userBubbleStyle = css({ + marginInlineStart: '12%', + outline: '1px solid var(--border)', +}) + +const assistantBubbleStyle = css({ + marginInlineEnd: '8%', +}) + +const roleStyle = css({ + margin: '0 0 0.35rem', + fontSize: '0.75rem', + lineHeight: 1.2, + fontWeight: 400, + letterSpacing: '0.04em', + textTransform: 'uppercase', + color: 'var(--muted)', +}) + +const textStyle = css({ + margin: 0, + whiteSpace: 'pre-wrap', +}) + +const statusStyle = css({ + minHeight: '1.5rem', + color: 'var(--muted)', +}) + +const errorStyle = css({ + margin: 0, + color: 'var(--accent)', + fontWeight: 400, +}) + +const formStyle = css({ + display: 'flex', + flexShrink: 0, + gap: '0.5rem', + padding: '0.5rem', + background: 'var(--surface)', + borderRadius: '20px', + boxShadow: 'var(--shadow)', +}) + +const fieldStyle = css({ + flex: 1, + minWidth: 0, + display: 'flex', +}) + +const visuallyHiddenStyle = css({ + position: 'absolute', + width: '1px', + height: '1px', + padding: 0, + margin: '-1px', + overflow: 'hidden', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', + border: 0, +}) + +const inputStyle = css({ + flex: 1, + minHeight: '2.75rem', + width: '100%', + border: 0, + borderRadius: '12px', + padding: '0.65rem 0.85rem', + background: 'transparent', + color: 'var(--ink)', + fontFamily: 'inherit', + fontSize: '1rem', + fontWeight: 400, + outline: '2px solid transparent', + outlineOffset: '2px', + '&:focus-visible': { + outlineColor: 'var(--accent)', + }, + '&::placeholder': { + color: 'var(--muted)', + opacity: 0.8, + }, + '&:disabled': { + opacity: 0.6, + }, +}) + +const buttonStyle = css({ + minHeight: '2.75rem', + minWidth: '4.5rem', + padding: '0.5rem 1rem', + border: 0, + borderRadius: '12px', + background: 'var(--accent)', + color: '#fffdf6', + fontFamily: 'inherit', + fontSize: '0.95rem', + fontWeight: 600, + cursor: 'pointer', + outline: '2px solid transparent', + outlineOffset: '2px', + transitionProperty: 'transform, opacity', + transitionDuration: '120ms', + transitionTimingFunction: 'ease-out', + '&:hover': { + opacity: 0.92, + }, + '&:focus-visible': { + outlineColor: 'var(--ink)', + }, + '&:active': { + transform: 'scale(0.96)', + }, + '&:disabled': { + opacity: 0.5, + cursor: 'not-allowed', + }, +}) + +const secondaryButtonStyle = css({ + background: 'transparent', + color: 'var(--ink)', + outline: '1px solid var(--border)', +}) + +const cardStyle = css({ + marginBlockStart: '0.75rem', + overflow: 'hidden', + borderRadius: '16px', + background: 'var(--bg)', + outline: '1px solid var(--border)', +}) + +const cardImageStyle = css({ + display: 'block', + width: '100%', + height: 'auto', + aspectRatio: '4 / 3', + objectFit: 'cover', + outline: '1px solid oklch(0 0 0 / 0.1)', + outlineOffset: '-1px', +}) + +const cardBodyStyle = css({ + padding: '0.9rem 1rem 1rem', + display: 'flex', + flexDirection: 'column', + gap: '0.35rem', +}) + +const cardTitleStyle = css({ + margin: 0, + fontFamily: 'var(--font-display)', + fontSize: '1.15rem', + lineHeight: 1.25, + fontWeight: 700, +}) + +const cardCopyStyle = css({ + margin: 0, + color: 'var(--muted)', + fontSize: '0.95rem', +}) + +const priceStyle = css({ + margin: '0.25rem 0 0', + fontWeight: 600, + color: 'var(--accent)', +}) diff --git a/examples/ts-remix-chat/hmr.ts b/examples/ts-remix-chat/hmr.ts new file mode 100644 index 0000000000..6757bdbe6d --- /dev/null +++ b/examples/ts-remix-chat/hmr.ts @@ -0,0 +1,54 @@ +import * as http from 'node:http' + +import { createFetchProxy } from 'remix/fetch-proxy' +import { createHmrReadyFetch, run } from 'remix/node-hmr' +import { createRequestListener } from 'remix/node-fetch-server' + +function parsePort(value: string | undefined, fallback: number): number { + const port = value === undefined ? fallback : Number.parseInt(value, 10) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error( + `Port must be an integer from 1 to 65535 (got ${value ?? fallback})`, + ) + } + return port +} + +const hmrProxyPort = parsePort(process.env.PORT, 44100) +const hmrEventPort = parsePort(process.env.HMR_PORT, hmrProxyPort + 1) +const appPort = parsePort(process.env.APP_PORT, hmrEventPort + 1) + +const hmrRunner = run('server.ts', { + env: { + ...process.env, + PORT: String(appPort), + HMR_PROXY_PORT: String(hmrProxyPort), + }, + nodeArgs: ['--import', 'remix/node-tsx', '--import', 'remix/ui-hmr/node'], + browserHmrChannel: { port: hmrEventPort }, +}) + +const server = http.createServer( + createRequestListener( + createHmrReadyFetch( + hmrRunner, + createFetchProxy(`http://127.0.0.1:${appPort}`, { + xForwardedHeaders: true, + }), + ), + ), +) + +server.listen(hmrProxyPort, '127.0.0.1') + +let shuttingDown = false + +function shutdown() { + if (shuttingDown) return + shuttingDown = true + server.close(() => hmrRunner.close().finally(() => process.exit(0))) + server.closeAllConnections() +} + +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown) diff --git a/examples/ts-remix-chat/package.json b/examples/ts-remix-chat/package.json new file mode 100644 index 0000000000..0d21b46f0e --- /dev/null +++ b/examples/ts-remix-chat/package.json @@ -0,0 +1,28 @@ +{ + "name": "ts-remix-chat", + "private": true, + "type": "module", + "scripts": { + "dev": "NODE_ENV=development node --watch --import remix/node-tsx server.ts", + "hmr": "NODE_ENV=development node hmr.ts", + "start": "NODE_ENV=production node --import remix/node-tsx server.ts", + "test": "node --import remix/node-tsx --test", + "typecheck": "tsc --noEmit", + "test:types": "tsc --noEmit" + }, + "engines": { + "node": ">=24.3.0" + }, + "dependencies": { + "@tanstack/ai": "workspace:*", + "@tanstack/ai-client": "workspace:*", + "@tanstack/ai-openai": "workspace:*", + "@tanstack/ai-remix": "workspace:*", + "remix": "^3.0.0-rc.1", + "zod": "^4.2.0" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "typescript": "5.9.3" + } +} diff --git a/examples/ts-remix-chat/public/example-guitar-flowers.jpg b/examples/ts-remix-chat/public/example-guitar-flowers.jpg new file mode 100644 index 0000000000..debe785efe Binary files /dev/null and b/examples/ts-remix-chat/public/example-guitar-flowers.jpg differ diff --git a/examples/ts-remix-chat/public/example-guitar-motherboard.jpg b/examples/ts-remix-chat/public/example-guitar-motherboard.jpg new file mode 100644 index 0000000000..8f1a8d72f8 Binary files /dev/null and b/examples/ts-remix-chat/public/example-guitar-motherboard.jpg differ diff --git a/examples/ts-remix-chat/public/example-guitar-racing.jpg b/examples/ts-remix-chat/public/example-guitar-racing.jpg new file mode 100644 index 0000000000..44555574c4 Binary files /dev/null and b/examples/ts-remix-chat/public/example-guitar-racing.jpg differ diff --git a/examples/ts-remix-chat/public/example-guitar-steamer-trunk.jpg b/examples/ts-remix-chat/public/example-guitar-steamer-trunk.jpg new file mode 100644 index 0000000000..7b93189397 Binary files /dev/null and b/examples/ts-remix-chat/public/example-guitar-steamer-trunk.jpg differ diff --git a/examples/ts-remix-chat/public/example-guitar-superhero.jpg b/examples/ts-remix-chat/public/example-guitar-superhero.jpg new file mode 100644 index 0000000000..3bbea2743d Binary files /dev/null and b/examples/ts-remix-chat/public/example-guitar-superhero.jpg differ diff --git a/examples/ts-remix-chat/public/example-guitar-traveling.jpg b/examples/ts-remix-chat/public/example-guitar-traveling.jpg new file mode 100644 index 0000000000..285647be55 Binary files /dev/null and b/examples/ts-remix-chat/public/example-guitar-traveling.jpg differ diff --git a/examples/ts-remix-chat/public/example-guitar-video-games.jpg b/examples/ts-remix-chat/public/example-guitar-video-games.jpg new file mode 100644 index 0000000000..4987f77e7a Binary files /dev/null and b/examples/ts-remix-chat/public/example-guitar-video-games.jpg differ diff --git a/examples/ts-remix-chat/public/example-ukelele-tanstack.jpg b/examples/ts-remix-chat/public/example-ukelele-tanstack.jpg new file mode 100644 index 0000000000..1e8a556696 Binary files /dev/null and b/examples/ts-remix-chat/public/example-ukelele-tanstack.jpg differ diff --git a/examples/ts-remix-chat/public/example-ukulele-tanstack.jpg b/examples/ts-remix-chat/public/example-ukulele-tanstack.jpg new file mode 100644 index 0000000000..1e8a556696 Binary files /dev/null and b/examples/ts-remix-chat/public/example-ukulele-tanstack.jpg differ diff --git a/examples/ts-remix-chat/public/favicon.svg b/examples/ts-remix-chat/public/favicon.svg new file mode 100644 index 0000000000..bd495a391d --- /dev/null +++ b/examples/ts-remix-chat/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/examples/ts-remix-chat/public/tanstack-landscape-black.svg b/examples/ts-remix-chat/public/tanstack-landscape-black.svg new file mode 100644 index 0000000000..a048f79eea --- /dev/null +++ b/examples/ts-remix-chat/public/tanstack-landscape-black.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/examples/ts-remix-chat/server.ts b/examples/ts-remix-chat/server.ts new file mode 100644 index 0000000000..15fbd2e2aa --- /dev/null +++ b/examples/ts-remix-chat/server.ts @@ -0,0 +1,47 @@ +import * as http from 'node:http' +import { createRequestListener } from 'remix/node-fetch-server' + +import { router } from './app/router.ts' + +const port = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 44100 +const hmrProxyPort = process.env.HMR_PROXY_PORT + ? Number.parseInt(process.env.HMR_PROXY_PORT, 10) + : null + +const server = http.createServer( + createRequestListener(async (request) => { + try { + return await router.fetch(request) + } catch (error) { + if (!(request.signal.aborted && error === request.signal.reason)) { + console.error(error) + } + return new Response('Internal Server Error', { status: 500 }) + } + }), +) + +server.listen(port, '127.0.0.1', () => { + if (process.env.REMIX_NODE_HMR) { + import('remix/node-hmr/runtime').then((nodeHmr) => + nodeHmr.emitServerReady(), + ) + } + + console.log(`Server listening on http://localhost:${hmrProxyPort ?? port}`) +}) + +let shuttingDown = false + +function shutdown() { + if (shuttingDown) { + return + } + + shuttingDown = true + server.close(() => process.exit(0)) + server.closeAllConnections() +} + +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown) diff --git a/examples/ts-remix-chat/tsconfig.json b/examples/ts-remix-chat/tsconfig.json new file mode 100644 index 0000000000..28f1f7b978 --- /dev/null +++ b/examples/ts-remix-chat/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": ["node", "remix/assets/types/hmr"], + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ESNext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "jsxImportSource": "remix/ui", + "noEmit": true + }, + "exclude": ["dist", "**/*.test.ts", "**/*.e2e.ts"] +} diff --git a/kiira.config.ts b/kiira.config.ts index d83dde48f8..20cb490e29 100644 --- a/kiira.config.ts +++ b/kiira.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'kiira-core' export default defineConfig({ include: ['docs/**/*.md'], + tsconfig: 'tsconfig.docs.json', exclude: [ // docs/reference/** is auto-generated by TypeDoc (`pnpm generate-docs`); // hand-fixing those snippets would be overwritten on regeneration. @@ -58,6 +59,11 @@ export default defineConfig({ '@soniox/tanstack-ai-adapter': '^0.1.2', // Octane is a peer of @tanstack/ai-octane, not a root workspace dep. octane: '^0.1.17', + // Remix 3 is a peer of @tanstack/ai-remix, not a root workspace dep. + remix: '^3.0.0-rc.1', + // remix/ui re-exports this. Kiira's paths["*"] does not read the + // package.json exports field, so the jsx-runtime file must exist on disk. + '@remix-run/ui': '^0.8.0', }, overrides: [ // React (and anything not overridden below) uses the automatic JSX @@ -94,5 +100,15 @@ export default defineConfig({ jsx: 'react-jsx', jsxImportSource: 'octane/dist', }, + // Remix snippets compile JSX through remix/ui. `remix/ui/jsx-runtime` + // is types-only via package.json exports; kiira's paths["*"] does not + // read exports, so `remix/dist/ui` points at dist/ui/jsx-runtime.d.ts, + // which exists on disk. Subpath imports (`remix/ui`, `remix/router`) + // are mapped in tsconfig.docs.json for the same reason. + { + include: ['docs/api/ai-remix.md', 'docs/ui/remix.md'], + jsx: 'react-jsx', + jsxImportSource: 'remix/dist/ui', + }, ], }) diff --git a/knip.json b/knip.json index f551f6dfaa..9a0cae481c 100644 --- a/knip.json +++ b/knip.json @@ -66,6 +66,9 @@ "packages/ai-angular": { "entry": ["src/index.ts", "ui/src/index.ts"] }, + "packages/ai-remix": { + "entry": ["src/index.ts", "src/ui.ts"] + }, "packages/ai-bedrock": {}, "packages/ai-grok-build": { "ignoreDependencies": ["@tanstack/ai-acp"] diff --git a/packages/ai-remix/README.md b/packages/ai-remix/README.md new file mode 100644 index 0000000000..4c29084fbb --- /dev/null +++ b/packages/ai-remix/README.md @@ -0,0 +1,11 @@ +# @tanstack/ai-remix + +Remix 3 helpers for TanStack AI streaming chat. + +## Install + +```bash +pnpm add @tanstack/ai-remix @tanstack/ai @tanstack/ai-client remix +``` + +This package publishes uncompiled source. Remix compiles JSX through `jsxImportSource` `remix/ui`. diff --git a/packages/ai-remix/package.json b/packages/ai-remix/package.json new file mode 100644 index 0000000000..c1bc31e9b8 --- /dev/null +++ b/packages/ai-remix/package.json @@ -0,0 +1,71 @@ +{ + "name": "@tanstack/ai-remix", + "version": "0.0.0", + "description": "Remix 3 bindings for TanStack AI streaming chat, structured outputs, and media generation.", + "author": "Tanner Linsley", + "license": "MIT", + "homepage": "https://tanstack.com/ai", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-remix" + }, + "bugs": { + "url": "https://github.com/TanStack/ai/issues" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "type": "module", + "engines": { + "node": ">=22" + }, + "keywords": [ + "ai", + "ai-sdk", + "typescript", + "tanstack", + "remix", + "chat", + "streaming", + "tool-calling", + "structured-outputs", + "media-generation" + ], + "//": "This package publishes uncompiled source (.ts / .tsx). Remix compiles JSX via jsxImportSource remix/ui. There is therefore no build/dist and no publint test:build target.", + "main": "./src/index.ts", + "module": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./ui": "./src/ui.ts" + }, + "files": [ + "src", + "README.md" + ], + "scripts": { + "lint:fix": "oxlint src --type-aware --fix", + "test:oxlint": "oxlint src --type-aware", + "test:lib": "vitest run", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "dependencies": { + "@tanstack/ai-client": "workspace:^" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^", + "remix": "^3.0.0-rc.1" + }, + "devDependencies": { + "@tanstack/ai": "workspace:*", + "@types/node": "^24.10.1", + "@vitest/coverage-v8": "4.1.10", + "happy-dom": "^20.11.2", + "remix": "^3.0.0-rc.1", + "vite": "^8.2.1", + "vitest": "^4.1.10" + } +} diff --git a/packages/ai-remix/src/chat-ui/chat-input.tsx b/packages/ai-remix/src/chat-ui/chat-input.tsx new file mode 100644 index 0000000000..7724ac5c12 --- /dev/null +++ b/packages/ai-remix/src/chat-ui/chat-input.tsx @@ -0,0 +1,125 @@ +import { on } from 'remix/ui' +import type { Handle, RemixNode } from 'remix/ui' +import { useChatContext } from './chat.tsx' + +export interface ChatInputRenderProps { + value: string + onChange: (value: string) => void + onSubmit: () => void + isLoading: boolean + disabled: boolean +} + +/** @deprecated Use `createChatUI()` and an application-owned input component. Removed in 1.0.0. */ +export interface ChatInputProps { + children?: (props: ChatInputRenderProps) => RemixNode + class?: string + placeholder?: string + disabled?: boolean + submitOnEnter?: boolean +} + +/** + * @deprecated Use `createChatUI()` and an application-owned input component. + * Removed in 1.0.0. + */ +export function ChatInput(handle: Handle) { + let value = '' + + return () => { + const { sendMessage, isLoading } = useChatContext(handle) + const disabled = Boolean(handle.props.disabled || isLoading) + const submitOnEnter = handle.props.submitOnEnter !== false + + function onChange(next: string) { + value = next + void handle.update() + } + + function onSubmit() { + if (!value.trim() || disabled) return + void sendMessage(value) + value = '' + void handle.update() + } + + const renderProps: ChatInputRenderProps = { + value, + onChange, + onSubmit, + isLoading, + disabled, + } + + if (typeof handle.props.children === 'function') { + return handle.props.children(renderProps) + } + + return ( +
    + { + onChange((event.currentTarget as HTMLInputElement).value) + }), + on('keydown', (event) => { + if ( + submitOnEnter && + event.key === 'Enter' && + !event.isComposing + ) { + event.preventDefault() + onSubmit() + } + }), + ]} + style={{ + flex: '1', + padding: '0.75rem 1rem', + fontSize: '0.875rem', + border: '1px solid rgba(255, 255, 255, 0.1)', + borderRadius: '0.75rem', + backgroundColor: 'rgba(31, 41, 55, 0.5)', + color: 'white', + outline: 'none', + }} + /> + +
    + ) + } +} diff --git a/packages/ai-remix/src/chat-ui/chat-message.tsx b/packages/ai-remix/src/chat-ui/chat-message.tsx new file mode 100644 index 0000000000..b4135ff527 --- /dev/null +++ b/packages/ai-remix/src/chat-ui/chat-message.tsx @@ -0,0 +1,197 @@ +import type { Handle, RemixNode } from 'remix/ui' +import { ThinkingPart } from './thinking-part.tsx' +import type { UIMessage } from '../types.ts' + +export interface ToolCallRenderProps { + id: string + name: string + arguments: string + state: string + approval?: { + id: string + needsApproval: boolean + approved?: boolean + } + output?: unknown +} + +/** @deprecated Use `createChatUI()` Message instead. Removed in 1.0.0. */ +export interface ChatMessageProps { + message: UIMessage + class?: string + userClass?: string + assistantClass?: string + textPartRenderer?: (props: { content: string }) => RemixNode + thinkingPartRenderer?: (props: { + content: string + isComplete?: boolean + }) => RemixNode + toolsRenderer?: Record RemixNode> + defaultToolRenderer?: (props: ToolCallRenderProps) => RemixNode + toolResultRenderer?: (props: { + toolCallId: string + content: string + state: string + }) => RemixNode +} + +function toolResultContentToString( + content: string | Array<{ type: string; content?: string }>, +): string { + if (typeof content === 'string') return content + return content + .filter((part) => part.type === 'text') + .map((part) => part.content ?? '') + .join('') +} + +/** @deprecated Use `createChatUI()` Message instead. Removed in 1.0.0. */ +export function ChatMessage(handle: Handle) { + return () => { + const message = handle.props.message + const roleClass = + message.role === 'user' + ? handle.props.userClass + : handle.props.assistantClass + const combinedClass = [handle.props.class, roleClass] + .filter(Boolean) + .join(' ') + + return ( +
    + {message.parts.map((part, index) => ( + p.type === 'text') + } + part={part} + textPartRenderer={handle.props.textPartRenderer} + thinkingPartRenderer={handle.props.thinkingPartRenderer} + toolResultRenderer={handle.props.toolResultRenderer} + toolsRenderer={handle.props.toolsRenderer} + /> + ))} +
    + ) + } +} + +function MessagePart( + handle: Handle<{ + part: UIMessage['parts'][number] + isThinkingComplete?: boolean + textPartRenderer?: ChatMessageProps['textPartRenderer'] + thinkingPartRenderer?: ChatMessageProps['thinkingPartRenderer'] + toolsRenderer?: ChatMessageProps['toolsRenderer'] + defaultToolRenderer?: ChatMessageProps['defaultToolRenderer'] + toolResultRenderer?: ChatMessageProps['toolResultRenderer'] + }>, +) { + return () => { + const part = handle.props.part + + if (part.type === 'text') { + if (handle.props.textPartRenderer) { + return handle.props.textPartRenderer({ content: part.content }) + } + return ( +
    + {part.content} +
    + ) + } + + if (part.type === 'thinking') { + if (handle.props.thinkingPartRenderer) { + return handle.props.thinkingPartRenderer({ + content: part.content, + isComplete: handle.props.isThinkingComplete, + }) + } + return ( + + ) + } + + if (part.type === 'tool-call') { + const toolProps: ToolCallRenderProps = { + id: part.id, + name: part.name, + arguments: part.arguments, + state: part.state, + approval: part.approval, + output: part.output, + } + const named = handle.props.toolsRenderer?.[part.name] + if (named) return named(toolProps) + if (handle.props.defaultToolRenderer) { + return handle.props.defaultToolRenderer(toolProps) + } + return ( +
    +
    + {part.name} + {part.state} +
    + {part.arguments ? ( +
    +
    {part.arguments}
    +
    + ) : null} + {part.approval ? ( +
    + {part.approval.approved !== undefined + ? part.approval.approved + ? 'Approved' + : 'Denied' + : 'Awaiting approval...'} +
    + ) : null} + {part.output ? ( +
    +
    {JSON.stringify(part.output, null, 2)}
    +
    + ) : null} +
    + ) + } + + if (part.type === 'tool-result') { + const content = toolResultContentToString(part.content) + if (handle.props.toolResultRenderer) { + return handle.props.toolResultRenderer({ + toolCallId: part.toolCallId, + content, + state: part.state, + }) + } + return ( +
    +
    {content}
    +
    + ) + } + + return null + } +} diff --git a/packages/ai-remix/src/chat-ui/chat-messages.tsx b/packages/ai-remix/src/chat-ui/chat-messages.tsx new file mode 100644 index 0000000000..c41a3835c1 --- /dev/null +++ b/packages/ai-remix/src/chat-ui/chat-messages.tsx @@ -0,0 +1,67 @@ +import { ref } from 'remix/ui' +import type { Handle, RemixNode } from 'remix/ui' +import { useChatContext } from './chat.tsx' +import { ChatMessage } from './chat-message.tsx' +import type { UIMessage } from '../types.ts' + +/** @deprecated Use `createChatUI()` Messages instead. Removed in 1.0.0. */ +export interface ChatMessagesProps { + children?: (message: UIMessage, index: number) => RemixNode + class?: string + emptyState?: RemixNode + loadingState?: RemixNode + errorState?: (props: { + error: Error + reload: () => Promise + }) => RemixNode + autoScroll?: boolean +} + +/** @deprecated Use `createChatUI()` Messages instead. Removed in 1.0.0. */ +export function ChatMessages(handle: Handle) { + let container: HTMLElement | null = null + + return () => { + const { messages, isLoading, error, reload } = useChatContext(handle) + const autoScroll = handle.props.autoScroll !== false + + if (autoScroll && container) { + container.scrollTop = container.scrollHeight + } + + if (error && handle.props.errorState) { + return handle.props.errorState({ error, reload }) + } + + if (isLoading && messages.length === 0 && handle.props.loadingState) { + return handle.props.loadingState + } + + if (messages.length === 0 && handle.props.emptyState) { + return handle.props.emptyState + } + + return ( +
    { + container = node + }), + ]} + > + {messages.map((message, index) => + typeof handle.props.children === 'function' ? ( +
    + {handle.props.children(message, index)} +
    + ) : ( + + ), + )} +
    + ) + } +} diff --git a/packages/ai-remix/src/chat-ui/chat.tsx b/packages/ai-remix/src/chat-ui/chat.tsx new file mode 100644 index 0000000000..701735ddef --- /dev/null +++ b/packages/ai-remix/src/chat-ui/chat.tsx @@ -0,0 +1,48 @@ +import type { ConnectionAdapter } from '@tanstack/ai-client' +import type { Handle, RemixNode } from 'remix/ui' +import { createChat } from '../create-chat.ts' +import type { CreateChatReturn, UIMessage } from '../types.ts' + +/** @deprecated Use `createChatUI()` Chat/Provider instead. Removed in 1.0.0. */ +export interface ChatProps { + children?: RemixNode + class?: string + connection: ConnectionAdapter + initialMessages?: Array + id?: string + body?: Record + tools?: Array +} + +/** @deprecated Use `createChatHook().useChatContext(handle)` from `@tanstack/ai-remix/ui` instead. Removed in 1.0.0. */ +export function useChatContext(handle: Handle): CreateChatReturn { + const chat = handle.context.get(Chat) + if (!chat) { + throw new Error( + 'Chat components must be wrapped in . Make sure you use Chat.Messages, Chat.Input, and the rest inside a component.', + ) + } + return chat +} + +/** + * @deprecated Use `createChatHook()` from `@tanstack/ai-remix/ui` instead. + * Removed in 1.0.0. + */ +export function Chat(handle: Handle) { + const chat = createChat(handle, { + connection: handle.props.connection, + ...(handle.props.initialMessages !== undefined + ? { initialMessages: handle.props.initialMessages } + : {}), + ...(handle.props.id !== undefined ? { threadId: handle.props.id } : {}), + ...(handle.props.body !== undefined ? { body: handle.props.body } : {}), + ...(handle.props.tools !== undefined ? { tools: handle.props.tools } : {}), + }) + handle.context.set(chat) + return () => ( +
    + {handle.props.children} +
    + ) +} diff --git a/packages/ai-remix/src/chat-ui/create-chat-hook.ts b/packages/ai-remix/src/chat-ui/create-chat-hook.ts new file mode 100644 index 0000000000..d968aace84 --- /dev/null +++ b/packages/ai-remix/src/chat-ui/create-chat-hook.ts @@ -0,0 +1,66 @@ +import type { InferredClientContext } from '@tanstack/ai-client' +import type { + ChatUIInterruptsOf, + ChatUISchemaOf, + ChatUIToolsOf, +} from '@tanstack/ai-client/ui' +import type { Handle } from 'remix/ui' +import { createChat as createUnboundChat } from '../create-chat.ts' +import type { CreateChatOptions } from '../types.ts' +import { createChatUI } from './create-ui.tsx' +import type { ChatUIFactoryConfig, ChatUIHost } from './create-ui.tsx' + +type HeadlessOptions = CreateChatOptions< + ChatUIToolsOf, + ChatUISchemaOf, + InferredClientContext>, + ChatUIInterruptsOf +> + +type ChatInstanceOverrides = { + threadId?: string + live?: boolean + forwardedProps?: Record + body?: Record + initialMessages?: HeadlessOptions['initialMessages'] +} + +/** + * Bind chat options and UI widgets once at module scope. + * + * Returns a bound `createAppChat`, the UI kit (`ui`), and `useChatContext`. + * Call `createAppChat(handle)` in a Remix setup function. Render + * ``. Pass instance overrides such as `threadId` + * into `createAppChat(handle, overrides)`. + */ +export function createChatHook({ + options, + ...chatComponents +}: { + options: TOptions +} & ChatUIFactoryConfig>) { + const ui = createChatUI( + options, + chatComponents as ChatUIFactoryConfig>, + ) + + function createAppChat( + handle: Handle, + overrides?: ChatInstanceOverrides, + ): ChatUIHost { + const chat = overrides + ? createUnboundChat(handle, { + ...(options as HeadlessOptions), + ...overrides, + }) + : createUnboundChat(handle, options as HeadlessOptions) + // oxlint-disable-next-line eslint-js/no-restricted-syntax -- return shape always includes partial/final; ChatUIHost gates those on TSchema + return chat as unknown as ChatUIHost + } + + return { + createAppChat, + ui, + useChatContext: ui.useChatContext, + } +} diff --git a/packages/ai-remix/src/chat-ui/create-ui.tsx b/packages/ai-remix/src/chat-ui/create-ui.tsx new file mode 100644 index 0000000000..1df7c617e3 --- /dev/null +++ b/packages/ai-remix/src/chat-ui/create-ui.tsx @@ -0,0 +1,516 @@ +import { + automaticPartsForMessage, + collectInlineToolNames, + resolveInterruptComponent, + selectChatUI, + selectMessageUI, +} from '@tanstack/ai-client/ui' +import type { + ChatUIData, + ChatUIHasNamedInterrupts, + ChatUIHasNamedTools, + ChatUIInterrupt, + ChatUIInterruptName, + ChatUIInterruptOf, + ChatUIInterruptsOf, + ChatUIMessages, + ChatUINamedInterruptId, + ChatUIPartKey, + ChatUIPartOf, + ChatUISchemaOf, + ChatUISelectedPart, + ChatUIToolApproval, + ChatUIToolName, + ChatUIToolsOf, +} from '@tanstack/ai-client/ui' +import type { + MessagePart, + QueuedMessage, + ToolCallPart, + ToolResultPart, + UIMessage, +} from '@tanstack/ai-client' +import type { Handle, RemixNode } from 'remix/ui' +import type { CreateChatReturn } from '../types.ts' + +export type ChatUIHost = CreateChatReturn< + ChatUIToolsOf, + ChatUISchemaOf, + ChatUIInterruptsOf +> + +export type ChatUIQueueItem = QueuedMessage & { + cancelQueued: () => void +} + +type RemixComp

    = (handle: Handle

    ) => () => RemixNode + +export type LayoutProps = { + Messages: RemixComp + Interrupts: RemixComp + Queue: RemixComp + Input: RemixComp + readonly __ui?: TOptions +} + +export type MessageProps = { + message: UIMessage, ChatUIData> + Parts: RemixComp +} + +export type InputProps = { + readonly __ui?: TOptions +} + +export type QueueProps = { + item: ChatUIQueueItem + readonly __ui?: TOptions +} + +export type PartProps = { + part: ChatUIPartOf +} + +export type ToolProps< + TOptions, + TName extends ChatUIToolName = ChatUIToolName, +> = { + part: Extract>, { name: TName }> + result?: ToolResultPart + interrupt?: ChatUIToolApproval +} + +export type InterruptProps< + TOptions, + TName extends ChatUIInterruptName = never, +> = { + interrupt: ChatUIInterruptOf + readonly __ui?: TOptions +} + +type GenericInterruptComponents = + ChatUIHasNamedInterrupts extends true + ? { + [K in ChatUINamedInterruptId]: RemixComp< + InterruptProps> + > + } & { + fallback?: RemixComp> + } + : { + fallback?: RemixComp> + } + +type ToolApprovalMap = { + [K in ChatUIToolName]?: RemixComp< + InterruptProps> + > +} + +/** The chrome around the message list: `layout`, `message`, and `input`. */ +export type ChatUIChromeComponents = { + layout: RemixComp> + message: RemixComp> + input?: RemixComp> + queue?: RemixComp> +} + +export type ChatUIPartsComponents = { + [K in ChatUIPartKey]?: RemixComp> +} & { + fallback?: RemixComp> +} + +export type ChatUIInterruptsComponents = { + tools?: ToolApprovalMap + generic: GenericInterruptComponents +} + +export type ChatUIComponents = { + components: ChatUIChromeComponents + partsComponents: ChatUIPartsComponents +} & (ChatUIHasNamedTools extends true + ? { + toolsComponents: { + [K in ChatUIToolName]: RemixComp> + } + } + : { + toolsComponents?: { + [K in ChatUIToolName]?: RemixComp> + } + }) & + (ChatUIHasNamedInterrupts extends true + ? { interruptsComponents: ChatUIInterruptsComponents } + : { + interruptsComponents?: { + tools?: ToolApprovalMap + generic?: GenericInterruptComponents + } + }) + +export type ChatUIFactoryConfig = ChatUIComponents + +type ComponentsValue = { + chat: ChatUIHost + warn: (key: string, message: string) => void + inlineToolNames: ReadonlyArray +} + +type ProviderProps = { + chat: ChatUIHost + children?: RemixNode +} + +type MessageRenderValue = { + message: ChatUIMessages[number] + interrupts: ReadonlyArray + inlineToolNames: ReadonlyArray +} + +function createWarnOnce() { + const seen = new Set() + return (key: string, message: string) => { + if (process.env.NODE_ENV === 'production') return + if (seen.has(key)) return + seen.add(key) + console.warn(message) + } +} + +function readMessages(chat: ChatUIHost) { + return chat.messages as ChatUIMessages +} + +function readInterrupts(chat: ChatUIHost) { + return chat.interrupts ?? [] +} + +/** + * Bind chat options and UI widgets once at module scope. This matches Form + * `createFormHook` and Table `createTableHook`. + * + * `chatOptions` is type-only at runtime. Widgets register here in named + * groups. `` closes over them, so you do not pass + * a components prop at render time. + */ +export function createChatUI( + options: TOptions, + config: ChatUIFactoryConfig>, +) { + void options + const { + components, + partsComponents: parts, + toolsComponents: tools, + interruptsComponents: interrupts, + } = config as ChatUIFactoryConfig & { + toolsComponents?: Record | undefined> + interruptsComponents?: { + tools?: Record | undefined> + generic?: Record | undefined> + } + } + const { + layout: Layout, + message: MessageComponent, + input: InputComponent, + queue: QueueItemComponent, + } = components + const warn = createWarnOnce() + const inlineToolNames = collectInlineToolNames( + interrupts?.tools as Record | undefined, + Object.keys(tools ?? {}), + ) + + function Provider( + handle: Handle, ComponentsValue>, + ) { + handle.context.set({ + get chat() { + return handle.props.chat + }, + warn, + inlineToolNames, + }) + return () => handle.props.children ?? null + } + + function useChatContext(handle: Handle): ChatUIHost { + const value = handle.context.get(Provider) + if (!value?.chat) { + throw new Error( + '`useChatContext` must be used within `UI.Provider` or `UI.Chat`.', + ) + } + return value.chat + } + + function MissingInput(_handle: Handle) { + warn( + 'input', + '[tanstack-ai-ui] Rendered but no `input` component is registered.', + ) + return () => null + } + + function Chat(handle: Handle<{ chat: ChatUIHost }>) { + return () => ( + + + + ) + } + + function Queue(handle: Handle) { + return () => { + const chat = useChatContext(handle) + if (!QueueItemComponent) return null + return chat.queue.map((item) => ( + { + chat.cancelQueued(item.id) + }, + }} + /> + )) + } + } + + function Messages( + handle: Handle<{ + children?: (messages: ChatUIMessages) => RemixNode + }>, + ) { + return () => { + const chat = useChatContext(handle) + const messages = readMessages(chat) + const interruptsList = readInterrupts(chat) + if (typeof handle.props.children === 'function') { + return handle.props.children(messages) + } + return messages.map((message) => ( + + )) + } + } + + function MessageScope( + handle: Handle< + MessageRenderValue & { children?: RemixNode }, + MessageRenderValue + >, + ) { + handle.context.set({ + get message() { + return handle.props.message + }, + get interrupts() { + return handle.props.interrupts + }, + get inlineToolNames() { + return handle.props.inlineToolNames + }, + }) + return () => handle.props.children ?? null + } + + function Parts(handle: Handle) { + return () => { + const scope = handle.context.get(MessageScope) + if (!scope) { + throw new Error('`Parts` must be rendered by a `message` component.') + } + return ( + + ) + } + } + + function MessageView( + handle: Handle<{ + message: ChatUIMessages[number] + interrupts: ReadonlyArray + inlineToolNames: ReadonlyArray + children?: (parts: Array) => RemixNode + }>, + ) { + return () => { + const selected = selectMessageUI(handle.props.message, { + interrupts: handle.props.interrupts, + inlineToolNames: handle.props.inlineToolNames, + }) + if (typeof handle.props.children === 'function') { + return handle.props.children(selected.parts) + } + return ( + + + + ) + } + } + + function Message( + handle: Handle<{ + message: ChatUIMessages[number] + children?: (parts: Array) => RemixNode + }>, + ) { + return () => { + const chat = useChatContext(handle) + return ( + + ) + } + } + + function AutomaticParts( + handle: Handle<{ + message: ChatUIMessages[number] + interrupts: ReadonlyArray + inlineToolNames: ReadonlyArray + }>, + ) { + return () => { + const selected = selectMessageUI(handle.props.message, { + interrupts: handle.props.interrupts, + inlineToolNames: handle.props.inlineToolNames, + }) + return automaticPartsForMessage(selected).map((part, index) => ( + + )) + } + } + + function SelectedPartView(handle: Handle<{ selected: ChatUISelectedPart }>) { + return () => { + const selected = handle.props.selected + if (selected.key === 'toolCall') { + const name = selected.part.name + const Tool = tools?.[name as ChatUIToolName] as + | RemixComp> + | undefined + if (!Tool) { + warn( + `tool:${name}`, + `[tanstack-ai-ui] Missing tools.${name} component`, + ) + return null + } + return ( + ['interrupt']} + part={selected.part as ToolProps['part']} + result={selected.result} + /> + ) + } + const PartComponent = (parts[selected.key] ?? parts.fallback) as + | RemixComp> + | undefined + if (!PartComponent) { + warn( + `part:${selected.key}`, + `[tanstack-ai-ui] Missing parts.${selected.key} component`, + ) + return null + } + return ( + ['part']} /> + ) + } + } + + function Part(handle: Handle<{ part: MessagePart }>) { + return () => { + const chat = useChatContext(handle) + const selected = selectMessageUI( + { id: 'part', role: 'assistant', parts: [handle.props.part] }, + { interrupts: readInterrupts(chat), inlineToolNames: [] }, + ).parts[0] + if (!selected) return null + return + } + } + + function InterruptView(handle: Handle<{ interrupt: ChatUIInterrupt }>) { + return () => { + const Component = resolveInterruptComponent( + handle.props.interrupt, + interrupts, + ) as RemixComp> | undefined + if (!Component) { + warn( + `interrupt:${handle.props.interrupt.id}`, + `[tanstack-ai-ui] Missing interrupt component for ${handle.props.interrupt.kind}`, + ) + return null + } + return + } + } + + function Interrupts( + handle: Handle<{ + children?: (interrupts: ReadonlyArray) => RemixNode + }>, + ) { + return () => { + const chat = useChatContext(handle) + const selected = selectChatUI({ + messages: readMessages(chat), + interrupts: readInterrupts(chat), + inlineToolNames, + }) + if (typeof handle.props.children === 'function') { + return handle.props.children(selected.interrupts) + } + return selected.interrupts.map((interrupt) => ( + + )) + } + } + + return { + Chat, + Provider, + Queue, + Messages, + Message, + Part, + Interrupts, + Interrupt: InterruptView, + useChatContext, + Input: InputComponent, + } +} diff --git a/packages/ai-remix/src/chat-ui/text-part.tsx b/packages/ai-remix/src/chat-ui/text-part.tsx new file mode 100644 index 0000000000..7dc1472eff --- /dev/null +++ b/packages/ai-remix/src/chat-ui/text-part.tsx @@ -0,0 +1,29 @@ +import type { Handle } from 'remix/ui' + +export interface TextPartProps { + content: string + role?: 'user' | 'assistant' | 'system' + class?: string + userClass?: string + assistantClass?: string +} + +export function TextPart(handle: Handle) { + return () => { + const roleClass = + handle.props.role === 'user' + ? handle.props.userClass + : handle.props.role === 'assistant' + ? handle.props.assistantClass + : undefined + const combinedClass = [handle.props.class, roleClass] + .filter(Boolean) + .join(' ') + + return ( +

    + {handle.props.content} +
    + ) + } +} diff --git a/packages/ai-remix/src/chat-ui/thinking-part.tsx b/packages/ai-remix/src/chat-ui/thinking-part.tsx new file mode 100644 index 0000000000..ecdc3a8498 --- /dev/null +++ b/packages/ai-remix/src/chat-ui/thinking-part.tsx @@ -0,0 +1,44 @@ +import { on } from 'remix/ui' +import type { Handle } from 'remix/ui' + +export interface ThinkingPartProps { + content: string + class?: string + isComplete?: boolean +} + +export function ThinkingPart(handle: Handle) { + let collapsed = false + let sawComplete = false + + return () => { + if (handle.props.isComplete && !sawComplete) { + collapsed = true + sawComplete = true + } + + return ( +
    + + {collapsed ? null :
    {handle.props.content}
    } +
    + ) + } +} diff --git a/packages/ai-remix/src/chat-ui/tool-approval.tsx b/packages/ai-remix/src/chat-ui/tool-approval.tsx new file mode 100644 index 0000000000..0620dbce8c --- /dev/null +++ b/packages/ai-remix/src/chat-ui/tool-approval.tsx @@ -0,0 +1,94 @@ +import { on } from 'remix/ui' +import type { Handle, RemixNode } from 'remix/ui' +import { useChatContext } from './chat.tsx' + +export interface ToolApprovalRenderProps { + toolName: string + input: unknown + onApprove: () => void + onDeny: () => void + hasResponded: boolean + approved?: boolean +} + +/** @deprecated Use `createChatUI()` interrupt components with `chat.interrupts`. Removed in 1.0.0. */ +export interface ToolApprovalProps { + toolCallId: string + toolName: string + input: unknown + approval: { + id: string + needsApproval: boolean + approved?: boolean + } + class?: string + children?: (props: ToolApprovalRenderProps) => RemixNode +} + +/** + * @deprecated Use `createChatUI()` interrupt components with `chat.interrupts`. + * Removed in 1.0.0. + */ +export function ToolApproval(handle: Handle) { + return () => { + const { addToolApprovalResponse } = useChatContext(handle) + const approval = handle.props.approval + + function onApprove() { + void addToolApprovalResponse({ id: approval.id, approved: true }) + } + + function onDeny() { + void addToolApprovalResponse({ id: approval.id, approved: false }) + } + + const hasResponded = approval.approved !== undefined + const renderProps: ToolApprovalRenderProps = { + toolName: handle.props.toolName, + input: handle.props.input, + onApprove, + onDeny, + hasResponded, + approved: approval.approved, + } + + if (typeof handle.props.children === 'function') { + return handle.props.children(renderProps) + } + + if (hasResponded) { + return ( +
    + {approval.approved ? 'Approved' : 'Denied'} +
    + ) + } + + return ( +
    +
    + {handle.props.toolName} requires approval +
    +
    +
    {JSON.stringify(handle.props.input, null, 2)}
    +
    +
    + + +
    +
    + ) + } +} diff --git a/packages/ai-remix/src/create-audio-recorder.ts b/packages/ai-remix/src/create-audio-recorder.ts new file mode 100644 index 0000000000..47f0e1b4be --- /dev/null +++ b/packages/ai-remix/src/create-audio-recorder.ts @@ -0,0 +1,106 @@ +import { AudioRecorder } from '@tanstack/ai-client' +import type { + AudioRecorderOptions, + AudioRecording, + InferAudioRecordingOutput, +} from '@tanstack/ai-client' +import type { Handle } from 'remix/ui' + +export type CreateAudioRecorderOptions = AudioRecorderOptions & { + /** + * Optional transform applied to the recording when `stop()` resolves. Its + * (awaited) return value becomes `recording` and the resolved value of + * `stop()`. Return nothing to keep the raw `AudioRecording`. + */ + onComplete?: TOnComplete +} + +/** + * Remix factory for recording an audio message. Call in setup with Handle. + * The resolved {@link AudioRecording} carries `.part` (an audio content part + * for `createChat.sendMessage`) and `.base64` (for generation helpers). + * + * Recorder state changes call `handle.update()`. Disconnect aborts + * `handle.signal`, which unsubscribes and cancels the recorder. + * + * Errors are delivered via `onError`. `start()` and `stop()` also reject on + * failure (and `stop()` rejects with `Recording cancelled` if the component + * disconnects while a stop is in flight) — handle one channel, not both. + * + * @param handle Remix component handle from setup. Re-renders on recorder + * state changes and cancels on disconnect. + * @param options Recorder options plus an optional `onComplete` transform. + * + * @example + * ```tsx + * function Voice(handle: Handle) { + * const recorder = createAudioRecorder(handle) + * return () => ( + * + * ) + * } + * ``` + */ +// TOnComplete defaults to undefined so `{ onError }` does not infer +// `unknown` and collapse `recording` / `stop()` (issue #1001). +export function createAudioRecorder< + TOnComplete extends ((recording: AudioRecording) => unknown) | undefined = + undefined, +>(handle: Handle, options: CreateAudioRecorderOptions = {}) { + const recorder = new AudioRecorder({ + ...(options.audio !== undefined && { audio: options.audio }), + ...(options.mimeType !== undefined && { mimeType: options.mimeType }), + onError: (error) => options.onError?.(error), + }) + + let isRecording = false + let recording: InferAudioRecordingOutput | null = null + + const unsubscribe = recorder.subscribe((state) => { + isRecording = state === 'recording' + void handle.update() + }) + + const teardown = () => { + unsubscribe() + recorder.cancel() + } + handle.signal.addEventListener('abort', teardown, { once: true }) + if (handle.signal.aborted) { + teardown() + } + + return { + get recording() { + return recording + }, + get isRecording() { + return isRecording + }, + get isSupported() { + return AudioRecorder.isSupported() + }, + start: () => recorder.start(), + async stop() { + const rawRecording = await recorder.stop() + if (handle.signal.aborted) { + throw new Error('Recording cancelled') + } + const transformed = await options.onComplete?.(rawRecording) + if (handle.signal.aborted) { + throw new Error('Recording cancelled') + } + // Only `undefined` (returning nothing) keeps the raw recording; a + // returned null is a real value, matching the inferred output type. + const output = ( + transformed === undefined ? rawRecording : transformed + ) as InferAudioRecordingOutput + recording = output + void handle.update() + return output + }, + cancel: () => recorder.cancel(), + } +} diff --git a/packages/ai-remix/src/create-byok.ts b/packages/ai-remix/src/create-byok.ts new file mode 100644 index 0000000000..cbea057a3d --- /dev/null +++ b/packages/ai-remix/src/create-byok.ts @@ -0,0 +1,29 @@ +import type { ByokClient } from '@tanstack/ai-client/byok' +import type { Handle } from 'remix/ui' + +/** + * Subscribe to a BYOK snapshot in Remix setup. + * + * Call this from a component setup function. The returned getter reads the + * latest snapshot. When the client changes, the helper calls `handle.update()` + * so the component renders again. It unsubscribes when `handle.signal` aborts. + * + * @param handle Remix setup handle + * @param client BYOK keyring + */ +export function createByok( + handle: Pick, + client: Pick, +) { + let snapshot = client.getSnapshot() + const unsubscribe = client.subscribe(() => { + snapshot = client.getSnapshot() + void handle.update() + }) + if (handle.signal.aborted) { + unsubscribe() + } else { + handle.signal.addEventListener('abort', unsubscribe, { once: true }) + } + return () => snapshot +} diff --git a/packages/ai-remix/src/create-chat.ts b/packages/ai-remix/src/create-chat.ts new file mode 100644 index 0000000000..c8c5ecb2e6 --- /dev/null +++ b/packages/ai-remix/src/create-chat.ts @@ -0,0 +1,421 @@ +import { ChatClient } from '@tanstack/ai-client' +import { createChatDevtoolsBridge } from '@tanstack/ai-client/devtools' +import type { Handle } from 'remix/ui' +import type { + ChatClientState, + ResolvableChatInterrupt, + ChatInterruptState, + ChatResumeState, + ConnectionStatus, + InferredClientContext, + QueuedMessage, + SendMessageOptions, + StructuredOutputPart, +} from '@tanstack/ai-client' +import type { + AnyClientTool, + InterruptDefinition, + InferSchemaType, + ModelMessage, + RunAgentResumeItem, + SchemaInput, + StreamChunk, +} from '@tanstack/ai/client' +import type { + CreateChatOptions, + CreateChatReturn, + DeepPartial, + MultimodalContent, + UIMessage, +} from './types.ts' + +const EMPTY_INTERRUPTS = Object.freeze([]) +const EMPTY_INTERRUPT_ERRORS = Object.freeze([]) + +/** + * Create a chat helper for a Remix component. + * + * Call this in setup with the component Handle from `remix/ui`. The helper + * wraps ChatClient and stores chat state in local variables. ChatClient state + * callbacks write those variables and then call `handle.update()`, so render + * reads the latest snapshot through getters. + * + * The default thread id is `options.threadId ?? handle.id`. Cleanup runs when + * `handle.signal` aborts. Do not pass identification in `options.devtools`; + * the helper sets `framework: 'remix'` and `hookName: 'createChat'`. + * + * @param handle Remix component handle from setup. + * @param options Chat client options. Pass `connection` or `fetcher`. + * + * @example + * ```tsx + * import { createChat } from '@tanstack/ai-remix' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * import type { Handle } from 'remix/ui' + * + * function Chat(handle: Handle) { + * const chat = createChat(handle, { + * connection: fetchServerSentEvents('/api/chat'), + * }) + * return () => ( + *
    + * {chat.messages.map((message) => ( + *
    {message.role}
    + * ))} + * + *
    + * ) + * } + * ``` + * + * @see {@link CreateChatReturn} + */ +export function createChat< + const TTools extends ReadonlyArray = any, + TSchema extends SchemaInput | undefined = undefined, + TContext = InferredClientContext, + const TInterrupts extends ReadonlyArray< + InterruptDefinition + > = readonly [], +>( + handle: Pick, + options: CreateChatOptions, +) { + let messages = options.initialMessages || [] + let isLoading = false + let error: Error | undefined + let status: ChatClientState = 'ready' + let isSubscribed = false + let connectionStatus: ConnectionStatus = 'disconnected' + let sessionGenerating = false + let queue: Array = [] + let runId: string | null = null + let interruptState: ChatInterruptState = { + interrupts: EMPTY_INTERRUPTS, + pendingInterrupts: EMPTY_INTERRUPTS, + interruptErrors: EMPTY_INTERRUPT_ERRORS, + resuming: false, + } + let closed = false + + type Partial = DeepPartial>> + type Final = InferSchemaType> + + const threadId = options.threadId ?? handle.id + const transport = options.connection + ? { connection: options.connection } + : { fetcher: options.fetcher } + + function commit() { + if (closed) return + void handle.update() + } + + const client = new ChatClient({ + devtoolsBridgeFactory: createChatDevtoolsBridge, + ...transport, + ...(options.initialMessages !== undefined && { + initialMessages: options.initialMessages, + }), + ...(options.persistence + ? { + persistence: options.persistence, + threadId, + } + : { threadId }), + ...(options.initialResumeSnapshot !== undefined && { + initialResumeSnapshot: options.initialResumeSnapshot, + }), + ...(options.body !== undefined && { body: options.body }), + ...(options.forwardedProps !== undefined && { + forwardedProps: options.forwardedProps, + }), + ...(options.byok !== undefined && { byok: options.byok }), + byokProvider: () => options.byokProvider?.(), + ...(options.context !== undefined && { context: options.context }), + devtools: { + ...options.devtools, + framework: 'remix', + hookName: 'createChat', + outputKind: options.outputSchema ? 'structured' : 'chat', + }, + onResponse: (response) => options.onResponse?.(response), + onChunk: (chunk: StreamChunk) => { + options.onChunk?.(chunk) + }, + onFinish: (message) => { + options.onFinish?.(message) + }, + onError: (err) => { + options.onError?.(err) + }, + ...(options.tools !== undefined && { tools: options.tools }), + ...(options.interrupts !== undefined && { + interrupts: options.interrupts, + }), + onCustomEvent: (eventType, data, context) => + options.onCustomEvent?.(eventType, data, context), + ...(options.streamProcessor !== undefined && { + streamProcessor: options.streamProcessor, + }), + onMessagesChange: (newMessages: Array>) => { + messages = newMessages + commit() + }, + onLoadingChange: (newIsLoading: boolean) => { + isLoading = newIsLoading + syncResumeState() + commit() + }, + onStatusChange: (newStatus: ChatClientState) => { + status = newStatus + commit() + }, + onErrorChange: (newError: Error | undefined) => { + error = newError + commit() + }, + onSubscriptionChange: (nextIsSubscribed: boolean) => { + isSubscribed = nextIsSubscribed + commit() + }, + onConnectionStatusChange: (nextStatus: ConnectionStatus) => { + connectionStatus = nextStatus + commit() + }, + onSessionGeneratingChange: (isGenerating: boolean) => { + sessionGenerating = isGenerating + commit() + }, + ...(options.queue !== undefined && { queue: options.queue }), + onQueueChange: (nextQueue: Array) => { + queue = nextQueue + commit() + }, + onRunIdChange: (nextRunId) => { + runId = nextRunId + commit() + }, + onInterruptStateChange: (nextInterruptState, context) => { + interruptState = nextInterruptState + options.onInterruptStateChange?.(nextInterruptState, context) + commit() + }, + }) + + function syncResumeState() { + runId = client.getCurrentRunId() + interruptState = client.getInterruptState() + } + + messages = client.getMessages() + interruptState = client.getInterruptState() + runId = client.getCurrentRunId() + + function close() { + if (closed) return + closed = true + client.detach() + if (options.live) { + client.unsubscribe() + } else { + client.stop() + } + client.dispose() + } + + if (handle.signal.aborted) { + close() + } else { + handle.signal.addEventListener('abort', close, { once: true }) + if (options.live) { + client.subscribe() + } + client.attach() + client.mountDevtools() + } + + const sendMessage = async ( + content: string | MultimodalContent, + sendOptions?: SendMessageOptions, + ) => { + try { + await client.sendMessage(content, undefined, sendOptions) + } finally { + syncResumeState() + } + } + + const cancelQueued = (id: string) => client.cancelQueued(id) + + const append = async (message: ModelMessage | UIMessage) => { + try { + await client.append(message) + } finally { + syncResumeState() + } + } + + const reload = async () => { + try { + await client.reload() + } finally { + syncResumeState() + } + } + + const stop = () => { + client.stop() + } + + const clear = () => { + client.clear() + syncResumeState() + } + + const setMessages = (newMessages: Array>) => { + client.setMessagesManually(newMessages) + } + + const addToolResult = async (result: { + toolCallId: string + tool: string + output: any + state?: 'output-available' | 'output-error' + errorText?: string + }) => { + await client.addToolResult(result) + } + + /** @deprecated Use a bound `tool-approval` interrupt and `interrupt.resolveInterrupt`. */ + const addToolApprovalResponse = async (response: { + id: string + approved: boolean + }) => { + await client.addToolApprovalResponse(response) + syncResumeState() + } + + const resumeInterrupts = async ( + resumeItems: Array, + state?: ChatResumeState, + ) => { + const result = await client.resumeInterrupts(resumeItems, state) + syncResumeState() + return result + } + + const resolveInterrupts = ( + resolution: + | boolean + | (( + interrupt: ResolvableChatInterrupt, + ) => undefined), + ) => { + if (typeof resolution === 'boolean') { + client.resolveInterrupts(resolution) + } else { + client.resolveInterrupts(resolution) + } + } + + const cancelInterrupts = () => { + client.cancelInterrupts() + } + + const retryInterrupts = () => { + client.retryInterrupts() + } + + const resumeInterruptsUnsafe = ( + resumeItems: Array, + state?: ChatResumeState, + ) => client.resumeInterruptsUnsafe(resumeItems, state) + + function activeStructuredPart(): StructuredOutputPart | null { + let lastUserIndex = -1 + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i]?.role === 'user') { + lastUserIndex = i + break + } + } + if (lastUserIndex === -1) return null + for (let i = messages.length - 1; i > lastUserIndex; i--) { + const m = messages[i] + if (m?.role !== 'assistant') continue + const part = m.parts.find( + (p): p is StructuredOutputPart => p.type === 'structured-output', + ) + if (part) return part + } + return null + } + + return { + get messages() { + return messages + }, + get isLoading() { + return isLoading + }, + get error() { + return error + }, + get status() { + return status + }, + get isSubscribed() { + return isSubscribed + }, + get connectionStatus() { + return connectionStatus + }, + get sessionGenerating() { + return sessionGenerating + }, + get queue() { + return queue + }, + get runId() { + return runId + }, + get interrupts() { + return interruptState.interrupts + }, + get pendingInterrupts() { + return interruptState.interrupts + }, + get interruptErrors() { + return interruptState.interruptErrors + }, + get resuming() { + return interruptState.resuming + }, + get partial() { + const part = activeStructuredPart() + if (!part) return {} as Partial + const v = part.partial ?? part.data + return (v ?? {}) as Partial + }, + get final() { + const part = activeStructuredPart() + if (!part || part.status !== 'complete') return null + return part.data as Final + }, + sendMessage, + cancelQueued, + append, + reload, + stop, + setMessages, + clear, + addToolResult, + addToolApprovalResponse, + resolveInterrupts, + cancelInterrupts, + retryInterrupts, + resumeInterruptsUnsafe, + resumeInterrupts, + } +} diff --git a/packages/ai-remix/src/create-generate-audio.ts b/packages/ai-remix/src/create-generate-audio.ts new file mode 100644 index 0000000000..afe203e0dd --- /dev/null +++ b/packages/ai-remix/src/create-generate-audio.ts @@ -0,0 +1,93 @@ +import { createGeneration } from './create-generation.ts' +import { reconstructAudioResult } from '@tanstack/ai-client' +import type { Handle } from 'remix/ui' +import type { AudioGenerationResult } from '@tanstack/ai' +import type { + AudioGenerateInput, + GenerationPersistenceOptions, +} from '@tanstack/ai-client' +import type { + CreateGenerationOptions, + CreateGenerationReturn, +} from './create-generation.ts' + +/** + * Options for the createGenerateAudio helper. + * + * Handle is the first argument of the helper. It is not part of this type. + * + * @template TOutput - The output type after optional transform (defaults to AudioGenerationResult) + */ +export type CreateGenerateAudioOptions = Omit< + CreateGenerationOptions, + 'onResult' | 'reconstructResult' +> & { + onResult?: (result: AudioGenerationResult) => TOutput | null | void +} + +/** + * Return type for the createGenerateAudio helper. + * + * @template TOutput - The output type (after optional transform) + */ +export type CreateGenerateAudioReturn = + CreateGenerationReturn + +/** + * Creates an audio generation helper for Remix setup. + * + * Call this in a Remix component setup function. Pass the component Handle as + * the first argument. + * + * @example + * ```tsx + * import { createGenerateAudio } from '@tanstack/ai-remix' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * import type { Handle } from 'remix/ui' + * + * function AudioGenerator(handle: Handle) { + * const audio = createGenerateAudio(handle, { + * connection: fetchServerSentEvents('/api/generate/audio'), + * }) + * + * return () => ( + *
    + * + * {audio.result?.audio.url ? ( + *
    + * ) + * } + * ``` + */ +export function createGenerateAudio( + handle: Pick, + options: Omit< + CreateGenerateAudioOptions, + 'onResult' | 'persistence' | 'threadId' + > & { + onResult?: (result: AudioGenerationResult) => TTransformed + } & GenerationPersistenceOptions, +) { + const devtools = { + ...options.devtools, + hookName: 'createGenerateAudio', + outputKind: 'audio' as const, + } + return createGeneration< + AudioGenerateInput, + AudioGenerationResult, + TTransformed + >(handle, { + ...options, + devtools, + reconstructResult: reconstructAudioResult, + }) +} diff --git a/packages/ai-remix/src/create-generate-image.ts b/packages/ai-remix/src/create-generate-image.ts new file mode 100644 index 0000000000..b4f7b67192 --- /dev/null +++ b/packages/ai-remix/src/create-generate-image.ts @@ -0,0 +1,94 @@ +import { createGeneration } from './create-generation.ts' +import { reconstructImageResult } from '@tanstack/ai-client' +import type { Handle } from 'remix/ui' +import type { ImageGenerationResult } from '@tanstack/ai' +import type { + GenerationPersistenceOptions, + ImageGenerateInput, +} from '@tanstack/ai-client' +import type { + CreateGenerationOptions, + CreateGenerationReturn, +} from './create-generation.ts' + +/** + * Options for the createGenerateImage helper. + * + * Handle is the first argument of the helper. It is not part of this type. + * + * @template TOutput - The output type after optional transform (defaults to ImageGenerationResult) + */ +export type CreateGenerateImageOptions = Omit< + CreateGenerationOptions, + 'onResult' | 'reconstructResult' +> & { + onResult?: (result: ImageGenerationResult) => TOutput | null | void +} + +/** + * Return type for the createGenerateImage helper. + * + * @template TOutput - The output type (after optional transform) + */ +export type CreateGenerateImageReturn = + CreateGenerationReturn + +/** + * Creates an image generation helper for Remix setup. + * + * Supports two transport modes: + * - **ConnectConnectionAdapter** — Streaming transport (SSE, HTTP stream, custom) + * - **Fetcher** — Direct async function call + * + * Call this in a Remix component setup function. Pass the component Handle as + * the first argument. + * + * @example + * ```tsx + * import { createGenerateImage } from '@tanstack/ai-remix' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * import type { Handle } from 'remix/ui' + * + * function ImageGenerator(handle: Handle) { + * const image = createGenerateImage(handle, { + * connection: fetchServerSentEvents('/api/generate/image'), + * }) + * + * return () => ( + *
    + * + * {image.isLoading ?

    Generating...

    : null} + * {image.result?.images.map((img) => ( + * + * ))} + *
    + * ) + * } + * ``` + */ +export function createGenerateImage( + handle: Pick, + options: Omit< + CreateGenerateImageOptions, + 'onResult' | 'persistence' | 'threadId' + > & { + onResult?: (result: ImageGenerationResult) => TTransformed + } & GenerationPersistenceOptions, +) { + const devtools = { + ...options.devtools, + hookName: 'createGenerateImage', + outputKind: 'image' as const, + } + return createGeneration< + ImageGenerateInput, + ImageGenerationResult, + TTransformed + >(handle, { + ...options, + devtools, + reconstructResult: reconstructImageResult, + }) +} diff --git a/packages/ai-remix/src/create-generate-speech.ts b/packages/ai-remix/src/create-generate-speech.ts new file mode 100644 index 0000000000..19fb75986d --- /dev/null +++ b/packages/ai-remix/src/create-generate-speech.ts @@ -0,0 +1,93 @@ +import { createGeneration } from './create-generation.ts' +import { reconstructSpeechResult } from '@tanstack/ai-client' +import type { Handle } from 'remix/ui' +import type { TTSResult } from '@tanstack/ai' +import type { + GenerationPersistenceOptions, + SpeechGenerateInput, +} from '@tanstack/ai-client' +import type { + CreateGenerationOptions, + CreateGenerationReturn, +} from './create-generation.ts' + +/** + * Options for the createGenerateSpeech helper. + * + * Handle is the first argument of the helper. It is not part of this type. + * + * @template TOutput - The output type after optional transform (defaults to TTSResult) + */ +export type CreateGenerateSpeechOptions = Omit< + CreateGenerationOptions, + 'onResult' | 'reconstructResult' +> & { + onResult?: (result: TTSResult) => TOutput | null | void +} + +/** + * Return type for the createGenerateSpeech helper. + * + * @template TOutput - The output type (after optional transform) + */ +export type CreateGenerateSpeechReturn = + CreateGenerationReturn + +/** + * Creates a speech generation (text-to-speech) helper for Remix setup. + * + * Call this in a Remix component setup function. Pass the component Handle as + * the first argument. + * + * @example + * ```tsx + * import { createGenerateSpeech } from '@tanstack/ai-remix' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * import type { Handle } from 'remix/ui' + * + * function SpeechGenerator(handle: Handle) { + * const speech = createGenerateSpeech(handle, { + * connection: fetchServerSentEvents('/api/generate/speech'), + * }) + * + * return () => ( + *
    + * + * {speech.result ? ( + *
    + * ) + * } + * ``` + */ +export function createGenerateSpeech( + handle: Pick, + options: Omit< + CreateGenerateSpeechOptions, + 'onResult' | 'persistence' | 'threadId' + > & { + onResult?: (result: TTSResult) => TTransformed + } & GenerationPersistenceOptions, +) { + const devtools = { + ...options.devtools, + hookName: 'createGenerateSpeech', + outputKind: 'audio' as const, + } + return createGeneration( + handle, + { + ...options, + devtools, + reconstructResult: reconstructSpeechResult, + }, + ) +} diff --git a/packages/ai-remix/src/create-generate-video.ts b/packages/ai-remix/src/create-generate-video.ts new file mode 100644 index 0000000000..65d6b4914b --- /dev/null +++ b/packages/ai-remix/src/create-generate-video.ts @@ -0,0 +1,347 @@ +import { VideoGenerationClient } from '@tanstack/ai-client' +import { createVideoDevtoolsBridge } from '@tanstack/ai-client/devtools' +import type { Handle } from 'remix/ui' +import type { StreamChunk } from '@tanstack/ai' +import type { + AIDevtoolsDisplayOptions, + ConnectConnectionAdapter, + GenerationClientState, + GenerationFetcher, + GenerationPersistenceOptions, + InferGenerationOutputFromReturn, + VideoGenerateInput, + VideoGenerateResult, + VideoGenerationClientOptions, + VideoStatusInfo, +} from '@tanstack/ai-client' +import type { ByokClient } from '@tanstack/ai-client/byok' +import type { ProviderId } from '@tanstack/ai/byok' + +/** + * Options for the createGenerateVideo helper. + * + * Handle is the first argument of the helper. It is not part of this type. + * + * @template TOutput - The output type after optional transform (defaults to VideoGenerateResult) + */ +export interface CreateGenerateVideoOptions { + /** Connect-based adapter for streaming transport (server handles polling) */ + connection?: ConnectConnectionAdapter + /** Direct async function that returns a completed video result */ + fetcher?: GenerationFetcher + /** Additional body parameters to send with connect-based adapter requests */ + body?: Record + /** Optional BYOK keyring. Keys go in `x-byok-*` headers, never the body. */ + byok?: ByokClient + /** Optional provider id. If it returns a slug, only that key is sent. If no slug resolves (`byokProvider`, then `body.provider`), generate throws. */ + byokProvider?: () => ProviderId | undefined + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * How this generation persists across reloads. + * - Omit / `false`: ephemeral, in-memory only. + * - `true`: server-driven — on mount the client hydrates the last generation + * for its `threadId` from the server (needs a connection with a + * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. + */ + persistence?: boolean + /** + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill — not a link to a chat conversation. + * + * The helper starts empty and produces many runs over its life; each gets its + * own `runId`, but all belong to one scope. Persistence keys on this, so + * derive it from your own domain and keep it identical across reloads (e.g. + * `` `video-${videoId}-start-frame` ``). It is also sent as the AG-UI thread + * id on the wire, which the protocol requires. + * + * **Required whenever `persistence` is set** — an app that cannot name the + * scope has nothing to restore to. Optional for ephemeral generations. If + * omitted, the helper uses `handle.id`. + */ + threadId?: string + /** + * Server-driven hydration handler for `persistence: true` when the + * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / + * `rpcStream()` adapter built without handlers) — typically a one-line + * server-function call. The connection's own handler takes precedence. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + /** + * Re-attach handler that replays a run still generating to completion on + * mount, when the connection doesn't carry one. Without it, a restored + * `running` snapshot surfaces as an (interrupted) error. The connection's + * own handler takes precedence. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] + /** + * Callback when video generation completes. Can optionally return a transformed value. + * + * - Return a non-null value to transform and store it as the result + * - Return `null` to keep the previous result unchanged + * - Return nothing (`void`) to store the raw result as-is + */ + onResult?: (result: VideoGenerateResult) => TOutput | null | void + /** Callback when an error occurs */ + onError?: (error: Error) => void + /** Callback when progress is reported (0-100) */ + onProgress?: (progress: number, message?: string) => void + /** Callback when a video job is created */ + onJobCreated?: (jobId: string) => void + /** Callback on each status update */ + onStatusUpdate?: (status: VideoStatusInfo) => void + /** Callback for each stream chunk (connect-based adapter mode only) */ + onChunk?: (chunk: StreamChunk) => void +} + +/** + * Return type for the createGenerateVideo helper. + * + * Fields are getters over local lets. Remix re-renders read the latest values + * after `handle.update()`. + * + * @template TOutput - The output type (after optional transform) + */ +export interface CreateGenerateVideoReturn { + /** The final video result (with URL), or null */ + readonly result: TOutput | null + /** The current job ID, or null */ + readonly jobId: string | null + /** Current video generation status info, or null */ + readonly videoStatus: VideoStatusInfo | null + /** Whether generation/polling is in progress */ + readonly isLoading: boolean + /** Current error, if any */ + readonly error: Error | undefined + /** Current state of the generation */ + readonly status: GenerationClientState + /** Trigger video generation */ + generate: (input: VideoGenerateInput) => Promise + /** Abort the current generation/polling */ + stop: () => void + /** Clear all state and return to idle */ + reset: () => void + /** + * The id of the generation job currently running, or `null` when nothing is in + * flight. Each call to `generate` is one job with its own id. Pass it to your + * own endpoint to cancel or poll the provider job — `stop()` only aborts the + * local stream, it does not stop work already running on the provider. + */ + readonly runId: string | null +} + +/** + * Creates a video generation helper for Remix setup. + * + * Video generation is asynchronous: a job is created, then polled for status + * until completion. This helper handles the full lifecycle. + * + * Call this in a Remix component setup function. Pass the component Handle as + * the first argument. + * + * @example + * ```tsx + * import { createGenerateVideo } from '@tanstack/ai-remix' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * import type { Handle } from 'remix/ui' + * + * function VideoGenerator(handle: Handle) { + * const video = createGenerateVideo(handle, { + * connection: fetchServerSentEvents('/api/generate/video'), + * onStatusUpdate: (status) => console.log(`Progress: ${status.progress}%`), + * }) + * + * return () => ( + *
    + * + * {video.isLoading && video.videoStatus ? ( + *

    + * Status: {video.videoStatus.status} ({video.videoStatus.progress}%) + *

    + * ) : null} + * {video.result ?
    + * ) + * } + * ``` + */ +// `TTransformed` infers from the `onResult` return position so the callback +// parameter is typed as `VideoGenerateResult` and `result` narrows to the +// transform's return. See issue #848. +export function createGenerateVideo( + handle: Pick, + options: Omit< + CreateGenerateVideoOptions, + 'onResult' | 'persistence' | 'threadId' + > & { + onResult?: (result: VideoGenerateResult) => TTransformed + } & GenerationPersistenceOptions, +) { + type TOutput = InferGenerationOutputFromReturn< + VideoGenerateResult, + TTransformed + > + + let result: TOutput | null = null + let jobId: string | null = null + let videoStatus: VideoStatusInfo | null = null + let isLoading = false + let error: Error | undefined = undefined + let status: GenerationClientState = 'idle' + let runId: string | null = null + let disposed = false + + const notify = () => { + if (disposed) return + void handle.update() + } + + const threadId = options.threadId ?? handle.id + + const baseOptions: Omit< + VideoGenerationClientOptions, + 'persistence' | 'threadId' + > = { + ...(options.body !== undefined && { body: options.body }), + ...(options.hydrateGeneration !== undefined && { + hydrateGeneration: options.hydrateGeneration, + }), + ...(options.joinRun !== undefined && { joinRun: options.joinRun }), + ...(options.byok !== undefined && { byok: options.byok }), + byokProvider: () => options.byokProvider?.(), + devtoolsBridgeFactory: createVideoDevtoolsBridge, + devtools: { + hookName: 'createGenerateVideo', + ...options.devtools, + framework: 'remix', + outputKind: 'video' as const, + }, + // The transform's raw return type (`TTransformed`) and the stored output + // (`TOutput`, with null/void/undefined stripped) are identical at runtime; + // the cast bridges the relationship that the conditional type hides. + onResult: ((r: VideoGenerateResult) => options.onResult?.(r)) as ( + result: VideoGenerateResult, + ) => TOutput | null | void, + onError: (e: Error) => { + if (!disposed) options.onError?.(e) + }, + onProgress: (p: number, m?: string) => { + if (!disposed) options.onProgress?.(p, m) + }, + onChunk: (c: StreamChunk) => { + if (!disposed) options.onChunk?.(c) + }, + onJobCreated: (id: string) => { + if (!disposed) options.onJobCreated?.(id) + }, + onStatusUpdate: (s: VideoStatusInfo) => { + if (!disposed) options.onStatusUpdate?.(s) + }, + onResultChange: (r: TOutput | null) => { + if (disposed) return + result = r + notify() + }, + onLoadingChange: (l: boolean) => { + if (disposed) return + isLoading = l + notify() + }, + onErrorChange: (e: Error | undefined) => { + if (disposed) return + error = e + notify() + }, + onStatusChange: (s: GenerationClientState) => { + if (disposed) return + status = s + notify() + }, + onJobIdChange: (id: string | null) => { + if (disposed) return + jobId = id + notify() + }, + onVideoStatusChange: (s: VideoStatusInfo | null) => { + if (disposed) return + videoStatus = s + notify() + }, + onResumeStateChange: (rs: { runId: string } | null) => { + if (disposed) return + runId = rs?.runId ?? null + notify() + }, + } + + const persistenceProps = + typeof options.threadId === 'string' && options.persistence + ? { + persistence: options.persistence, + threadId: options.threadId, + } + : { + threadId, + } + + let client: VideoGenerationClient + if (options.connection) { + client = new VideoGenerationClient({ + ...baseOptions, + ...persistenceProps, + connection: options.connection, + }) + } else if (options.fetcher) { + client = new VideoGenerationClient({ + ...baseOptions, + ...persistenceProps, + fetcher: options.fetcher, + }) + } else { + throw new Error( + 'createGenerateVideo requires either a connection or fetcher option', + ) + } + + const dispose = () => { + if (disposed) return + disposed = true + client.dispose() + } + + if (handle.signal.aborted) { + dispose() + } else { + client.mountDevtools() + handle.signal.addEventListener('abort', dispose, { once: true }) + } + + return { + get result() { + return result + }, + get jobId() { + return jobId + }, + get videoStatus() { + return videoStatus + }, + get isLoading() { + return isLoading + }, + get error() { + return error + }, + get status() { + return status + }, + generate: (input: VideoGenerateInput) => client.generate(input), + stop: () => client.stop(), + reset: () => client.reset(), + get runId() { + return runId + }, + } +} diff --git a/packages/ai-remix/src/create-generation.ts b/packages/ai-remix/src/create-generation.ts new file mode 100644 index 0000000000..876b36858d --- /dev/null +++ b/packages/ai-remix/src/create-generation.ts @@ -0,0 +1,333 @@ +import { GenerationClient } from '@tanstack/ai-client' +import { createGenerationDevtoolsBridge } from '@tanstack/ai-client/devtools' +import type { Handle } from 'remix/ui' +import type { StreamChunk } from '@tanstack/ai' +import type { + AIDevtoolsDisplayOptions, + ConnectConnectionAdapter, + GenerationClientOptions, + GenerationClientState, + GenerationFetcher, + GenerationPersistenceOptions, + GenerationRestoredResult, + InferGenerationOutputFromReturn, +} from '@tanstack/ai-client' +import type { ByokClient } from '@tanstack/ai-client/byok' +import type { ProviderId } from '@tanstack/ai/byok' + +type RemixHandle = Pick + +/** + * Options for the createGeneration helper. + * + * Accepts either a `connection` (streaming transport) or a `fetcher` (direct async call). + * Handle is the first argument of the helper. It is not part of this type. + * + * @template TInput - The input type for the generation request + * @template TResult - The result type returned by the generation + * @template TOutput - The output type after optional transform (defaults to TResult) + */ +export interface CreateGenerationOptions { + /** Connect-based adapter for streaming transport (SSE, HTTP stream, custom) */ + connection?: ConnectConnectionAdapter + /** Direct async function for one-shot generation (no streaming protocol needed) */ + fetcher?: GenerationFetcher + /** Additional body parameters to send with connect-based adapter requests */ + body?: Record + /** Optional BYOK keyring. Keys go in `x-byok-*` headers, never the body. */ + byok?: ByokClient + /** Optional provider id. If it returns a slug, only that key is sent. If no slug resolves (`byokProvider`, then `body.provider`), generate throws. */ + byokProvider?: () => ProviderId | undefined + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * How this generation persists across reloads. + * - Omit / `false`: ephemeral, in-memory only. + * - `true`: server-driven — on mount the client hydrates the last generation + * for its `threadId` from the server (needs a connection with a + * `hydrateGeneration` handler) and repaints it; it never auto-starts a run. + */ + persistence?: boolean + /** + * The **scope** this generation belongs to: a stable, app-chosen name for the + * slot successive runs fill — not a link to a chat conversation. + * + * The helper starts empty and produces many runs over its life; each gets its + * own `runId`, but all belong to one scope. Persistence keys on this, so + * derive it from your own domain and keep it identical across reloads (e.g. + * `` `video-${videoId}-start-frame` ``). It is also sent as the AG-UI thread + * id on the wire, which the protocol requires. + * + * **Required whenever `persistence` is set** — an app that cannot name the + * scope has nothing to restore to. Optional for ephemeral generations. If + * omitted, the helper uses `handle.id`. + */ + threadId?: string + /** + * Server-driven hydration handler for `persistence: true` when the + * connection doesn't carry one (e.g. alongside `fetcher`, or a `stream()` / + * `rpcStream()` adapter built without handlers) — typically a one-line + * server-function call. The connection's own handler takes precedence. + */ + hydrateGeneration?: ConnectConnectionAdapter['hydrateGeneration'] + /** + * Re-attach handler that replays a run still generating to completion on + * mount, when the connection doesn't carry one. Without it, a restored + * `running` snapshot surfaces as an (interrupted) error. The connection's + * own handler takes precedence. + */ + joinRun?: ConnectConnectionAdapter['joinRun'] + /** + * Callback when a result is received. Can optionally return a transformed value. + * + * - Return a non-null value to transform and store it as the result + * - Return `null` to keep the previous result unchanged + * - Return nothing (`void`) to store the raw result as-is + */ + onResult?: (result: TResult) => TOutput | null | void + /** Callback when an error occurs */ + onError?: (error: Error) => void + /** Callback when progress is reported (0-100) */ + onProgress?: (progress: number, message?: string) => void + /** Callback for each stream chunk (connect-based adapter mode only) */ + onChunk?: (chunk: StreamChunk) => void + /** + * @internal Rebuild a typed result from a restored snapshot, injected by each + * specialized helper (image / speech / audio / transcription / summarize). + * Forwarded to the client so a server-hydrate restore repaints `result`. + */ + reconstructResult?: (restored: GenerationRestoredResult) => TResult | null +} + +/** + * Return type for the createGeneration helper. + * + * Fields are getters over local lets. Remix re-renders read the latest values + * after `handle.update()`. + * + * @template TOutput - The output type (after optional transform) + * @template TInput - The input type accepted by `generate` (defaults to any object) + */ +export interface CreateGenerationReturn< + TOutput, + TInput extends Record = Record, +> { + /** The generation result, or null if not yet generated */ + readonly result: TOutput | null + /** Whether a generation is currently in progress */ + readonly isLoading: boolean + /** Current error, if any */ + readonly error: Error | undefined + /** Current state of the generation client */ + readonly status: GenerationClientState + /** Trigger a generation request */ + generate: (input: TInput) => Promise + /** Abort the current generation */ + stop: () => void + /** Clear result, error, and return to idle */ + reset: () => void + /** + * The id of the generation job currently running, or `null` when nothing is in + * flight. Each call to `generate` is one job with its own id. Pass it to your + * own endpoint to cancel or poll the provider job — `stop()` only aborts the + * local stream, it does not stop work already running on the provider. + */ + readonly runId: string | null +} + +/** + * Creates a generation helper for Remix setup. + * + * This is the base helper used by `createGenerateImage`, `createGenerateSpeech`, + * `createTranscription`, and `createSummarize`. You can also use it for custom + * generation types. + * + * Call this in a Remix component setup function. Pass the component Handle as + * the first argument. Pass either `connection` or `fetcher`. + * + * @example + * ```tsx + * import { createGeneration } from '@tanstack/ai-remix' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * import type { Handle } from 'remix/ui' + * + * function CustomGenerator(handle: Handle) { + * const gen = createGeneration(handle, { + * connection: fetchServerSentEvents('/api/generate/custom'), + * }) + * + * return () => ( + *
    + * + * {gen.isLoading ?

    Generating...

    : null} + *
    + * ) + * } + * ``` + */ +// `TTransformed` infers from the `onResult` return position (a covariant +// inference site that works even for an optional nested property), which types +// the callback parameter as `TResult` and narrows `result`. Inferring the +// whole callback as a defaulted type parameter instead collapses to the +// default, leaving the parameter `any` — a hard error under `strict`. See +// issue #848. +export function createGeneration< + TInput extends Record, + TResult, + TTransformed = void, +>( + handle: RemixHandle, + options: Omit< + CreateGenerationOptions, + 'onResult' | 'persistence' | 'threadId' + > & { + onResult?: (result: TResult) => TTransformed + } & GenerationPersistenceOptions, +) { + type TOutput = InferGenerationOutputFromReturn + + let result: TOutput | null = null + let isLoading = false + let error: Error | undefined = undefined + let status: GenerationClientState = 'idle' + let runId: string | null = null + let disposed = false + + const notify = () => { + if (disposed) return + void handle.update() + } + + const threadId = options.threadId ?? handle.id + + // Conditional spread for `body` (strict-optional in target; + // local source is `Record | undefined`). Callbacks + // wrap optional ones in non-returning bodies so `?.()`'s + // implicit `undefined` doesn't pollute the function return type. + const clientOptions: Omit< + GenerationClientOptions, + 'persistence' | 'threadId' + > = { + ...(options.body !== undefined && { body: options.body }), + ...(options.hydrateGeneration !== undefined && { + hydrateGeneration: options.hydrateGeneration, + }), + ...(options.joinRun !== undefined && { joinRun: options.joinRun }), + ...(options.byok !== undefined && { byok: options.byok }), + byokProvider: () => options.byokProvider?.(), + ...(options.reconstructResult + ? { reconstructResult: options.reconstructResult } + : {}), + devtoolsBridgeFactory: createGenerationDevtoolsBridge, + devtools: { + hookName: 'createGeneration', + ...options.devtools, + framework: 'remix', + }, + // The transform's raw return type (`TTransformed`) and the stored output + // (`TOutput`, with null/void/undefined stripped) are identical at runtime; + // the cast bridges the relationship that the conditional type hides. + onResult: ((r: TResult) => options.onResult?.(r)) as ( + result: TResult, + ) => TOutput | null | void, + onError: (e: Error) => { + if (!disposed) options.onError?.(e) + }, + onProgress: (p: number, m?: string) => { + if (!disposed) options.onProgress?.(p, m) + }, + onChunk: (c: StreamChunk) => { + if (!disposed) options.onChunk?.(c) + }, + onResultChange: (r) => { + if (disposed) return + result = r + notify() + }, + onLoadingChange: (l) => { + if (disposed) return + isLoading = l + notify() + }, + onErrorChange: (e) => { + if (disposed) return + error = e + notify() + }, + onStatusChange: (s) => { + if (disposed) return + status = s + notify() + }, + onResumeStateChange: (rs) => { + if (disposed) return + runId = rs?.runId ?? null + notify() + }, + } + + const persistenceProps = + typeof options.threadId === 'string' && options.persistence + ? { + persistence: options.persistence, + threadId: options.threadId, + } + : { + threadId, + } + + let client: GenerationClient + if (options.connection) { + client = new GenerationClient({ + ...clientOptions, + ...persistenceProps, + connection: options.connection, + }) + } else if (options.fetcher) { + client = new GenerationClient({ + ...clientOptions, + ...persistenceProps, + fetcher: options.fetcher, + }) + } else { + throw new Error( + 'createGeneration requires either a connection or fetcher option', + ) + } + + const dispose = () => { + if (disposed) return + disposed = true + client.dispose() + } + + if (handle.signal.aborted) { + dispose() + } else { + client.mountDevtools() + handle.signal.addEventListener('abort', dispose, { once: true }) + } + + return { + get result() { + return result + }, + get isLoading() { + return isLoading + }, + get error() { + return error + }, + get status() { + return status + }, + generate: (input: TInput) => client.generate(input), + stop: () => client.stop(), + reset: () => client.reset(), + get runId() { + return runId + }, + } +} diff --git a/packages/ai-remix/src/create-mcp-app-bridge.ts b/packages/ai-remix/src/create-mcp-app-bridge.ts new file mode 100644 index 0000000000..bce929c763 --- /dev/null +++ b/packages/ai-remix/src/create-mcp-app-bridge.ts @@ -0,0 +1,36 @@ +import { createMcpAppBridge as createClientMcpAppBridge } from '@tanstack/ai-client' +import type { CreateMcpAppBridgeOptions } from '@tanstack/ai-client' +import type { Handle } from 'remix/ui' + +export type { CreateMcpAppBridgeOptions } + +/** + * Remix setup wrapper around the client `createMcpAppBridge`. + * + * Call once in component setup with the Remix `Handle`. Setup does not re-run, + * so the bridge is created once from `options`. The client bridge has no + * dispose, so `handle.signal` is unused. + * + * @param handle Remix component handle from setup. + * @param options Same options as the client factory (`threadId`, `callEndpoint`, + * `chat.sendMessage`, optional `fetchImpl` / `onLink`). + * + * @example + * ```tsx + * function Widget(handle: Handle) { + * const bridge = createMcpAppBridge(handle, { + * threadId: 't1', + * callEndpoint: '/api/mcp-apps-call', + * chat: { sendMessage }, + * onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'), + * }) + * return () => + * } + * ``` + */ +export function createMcpAppBridge( + handle: Handle, + options: CreateMcpAppBridgeOptions, +) { + return createClientMcpAppBridge(options) +} diff --git a/packages/ai-remix/src/create-realtime-chat.ts b/packages/ai-remix/src/create-realtime-chat.ts new file mode 100644 index 0000000000..9c2324ac03 --- /dev/null +++ b/packages/ai-remix/src/create-realtime-chat.ts @@ -0,0 +1,242 @@ +import { RealtimeClient } from '@tanstack/ai-client' +import type { + RealtimeMessage, + RealtimeMode, + RealtimeSessionConfig, + RealtimeStatus, +} from '@tanstack/ai' +import type { Handle } from 'remix/ui' +import type { CreateRealtimeChatOptions } from './realtime-types.ts' + +const emptyFrequencyData = new Uint8Array(128) +const emptyTimeDomainData = new Uint8Array(128).fill(128) + +/** + * Remix helper for realtime voice conversations. + * + * Call from component setup with Handle. State fields are getters so the + * render function reads the current value after `handle.update()`. + * + * @param handle - Remix Handle from setup. Used to re-render and to clean up + * when the component disconnects. + * @param options - Adapter, token loader, and optional session/callback config. + * + * @example + * ```typescript + * import { createRealtimeChat } from '@tanstack/ai-remix' + * import { openaiRealtime } from '@tanstack/ai-openai' + * import type { Handle } from 'remix/ui' + * + * function VoiceChat(handle: Handle) { + * const chat = createRealtimeChat(handle, { + * getToken: () => fetch('/api/realtime-token').then((r) => r.json()), + * adapter: openaiRealtime(), + * }) + * + * return () => ( + *
    + *

    Status: {chat.status}

    + * + *
    + * ) + * } + * ``` + */ +export function createRealtimeChat( + handle: Handle, + options: CreateRealtimeChatOptions, +) { + let status: RealtimeStatus = 'idle' + let mode: RealtimeMode = 'idle' + let messages: Array = [] + let pendingUserTranscript: string | null = null + let pendingAssistantTranscript: string | null = null + let error: Error | null = null + let animationFrame: number | null = null + + function notify() { + if (!handle.signal.aborted) { + void handle.update() + } + } + + function stopLevelLoop() { + if (animationFrame === null) return + cancelAnimationFrame(animationFrame) + animationFrame = null + } + + function startLevelLoop() { + if (animationFrame !== null) return + function tick() { + animationFrame = requestAnimationFrame(tick) + notify() + } + animationFrame = requestAnimationFrame(tick) + } + + // Each optional source field is spread conditionally because the + // `RealtimeClientOptions` target declares strict optionals + // (`field?: T`) and `exactOptionalPropertyTypes` rejects passing + // `undefined` for absent values. + const client = new RealtimeClient({ + getToken: () => options.getToken(), + adapter: { + get provider() { + return options.adapter.provider + }, + connect(token, tools) { + return options.adapter.connect(token, tools) + }, + }, + ...(options.tools !== undefined && { tools: options.tools }), + ...(options.instructions !== undefined && { + instructions: options.instructions, + }), + ...(options.voice !== undefined && { voice: options.voice }), + ...(options.autoPlayback !== undefined && { + autoPlayback: options.autoPlayback, + }), + ...(options.autoCapture !== undefined && { + autoCapture: options.autoCapture, + }), + ...(options.vadMode !== undefined && { vadMode: options.vadMode }), + ...(options.outputModalities !== undefined && { + outputModalities: options.outputModalities, + }), + ...(options.temperature !== undefined && { + temperature: options.temperature, + }), + ...(options.maxOutputTokens !== undefined && { + maxOutputTokens: options.maxOutputTokens, + }), + ...(options.semanticEagerness !== undefined && { + semanticEagerness: options.semanticEagerness, + }), + onStatusChange: (newStatus) => { + status = newStatus + if (newStatus === 'connected') { + startLevelLoop() + } else { + stopLevelLoop() + } + notify() + options.onStatusChange?.(newStatus) + }, + onModeChange: (newMode) => { + mode = newMode + notify() + options.onModeChange?.(newMode) + }, + onMessage: (message) => { + messages = [...messages, message] + notify() + options.onMessage?.(message) + }, + onUsage: (usage) => { + options.onUsage?.(usage) + }, + onGoAway: (timeLeft) => { + options.onGoAway?.(timeLeft) + }, + onError: (err) => { + error = err + notify() + options.onError?.(err) + }, + onConnect: () => { + error = null + notify() + options.onConnect?.() + }, + onDisconnect: () => { + options.onDisconnect?.() + }, + onInterrupted: () => { + pendingAssistantTranscript = null + notify() + options.onInterrupted?.() + }, + }) + + client.onStateChange((state) => { + pendingUserTranscript = state.pendingUserTranscript + pendingAssistantTranscript = state.pendingAssistantTranscript + notify() + }) + + handle.signal.addEventListener('abort', () => { + stopLevelLoop() + client.destroy() + }) + + return { + get status() { + return status + }, + get error() { + return error + }, + connect: async () => { + error = null + messages = [] + pendingUserTranscript = null + pendingAssistantTranscript = null + notify() + await client.connect() + }, + disconnect: () => client.disconnect(), + + get mode() { + return mode + }, + get messages() { + return messages + }, + get pendingUserTranscript() { + return pendingUserTranscript + }, + get pendingAssistantTranscript() { + return pendingAssistantTranscript + }, + + startListening: () => { + client.startListening() + }, + stopListening: () => { + client.stopListening() + }, + interrupt: () => { + client.interrupt() + }, + + sendText: (text: string) => { + client.sendText(text) + }, + + sendImage: (imageData: string, mimeType: string) => { + client.sendImage(imageData, mimeType) + }, + + get inputLevel() { + return client.audio?.inputLevel ?? 0 + }, + get outputLevel() { + return client.audio?.outputLevel ?? 0 + }, + getInputFrequencyData: () => + client.audio?.getInputFrequencyData() ?? emptyFrequencyData, + getOutputFrequencyData: () => + client.audio?.getOutputFrequencyData() ?? emptyFrequencyData, + getInputTimeDomainData: () => + client.audio?.getInputTimeDomainData() ?? emptyTimeDomainData, + getOutputTimeDomainData: () => + client.audio?.getOutputTimeDomainData() ?? emptyTimeDomainData, + + updateSession: (config: RealtimeSessionConfig) => { + client.updateSession(config) + }, + } +} diff --git a/packages/ai-remix/src/create-summarize.ts b/packages/ai-remix/src/create-summarize.ts new file mode 100644 index 0000000000..84044e236b --- /dev/null +++ b/packages/ai-remix/src/create-summarize.ts @@ -0,0 +1,96 @@ +import { createGeneration } from './create-generation.ts' +import { reconstructSummarizeResult } from '@tanstack/ai-client' +import type { Handle } from 'remix/ui' +import type { SummarizationResult } from '@tanstack/ai' +import type { + GenerationPersistenceOptions, + SummarizeGenerateInput, +} from '@tanstack/ai-client' +import type { + CreateGenerationOptions, + CreateGenerationReturn, +} from './create-generation.ts' + +/** + * Options for the createSummarize helper. + * + * Handle is the first argument of the helper. It is not part of this type. + * + * @template TOutput - The output type after optional transform (defaults to SummarizationResult) + */ +export type CreateSummarizeOptions = Omit< + CreateGenerationOptions, + 'onResult' | 'reconstructResult' +> & { + onResult?: (result: SummarizationResult) => TOutput | null | void +} + +/** + * Return type for the createSummarize helper. + * + * @template TOutput - The output type (after optional transform) + */ +export type CreateSummarizeReturn = + CreateGenerationReturn + +/** + * Creates a text summarization helper for Remix setup. + * + * Call this in a Remix component setup function. Pass the component Handle as + * the first argument. + * + * @example + * ```tsx + * import { createSummarize } from '@tanstack/ai-remix' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * import type { Handle } from 'remix/ui' + * + * function Summarizer(handle: Handle) { + * const summarizer = createSummarize(handle, { + * connection: fetchServerSentEvents('/api/summarize'), + * }) + * + * return () => ( + *
    + * + * {summarizer.isLoading ?

    Summarizing...

    : null} + * {summarizer.result ?

    {summarizer.result.summary}

    : null} + *
    + * ) + * } + * ``` + */ +export function createSummarize( + handle: Pick, + options: Omit< + CreateSummarizeOptions, + 'onResult' | 'persistence' | 'threadId' + > & { + onResult?: (result: SummarizationResult) => TTransformed + } & GenerationPersistenceOptions, +) { + const devtools = { + ...options.devtools, + hookName: 'createSummarize', + outputKind: 'text' as const, + } + return createGeneration< + SummarizeGenerateInput, + SummarizationResult, + TTransformed + >(handle, { + ...options, + devtools, + reconstructResult: reconstructSummarizeResult, + }) +} diff --git a/packages/ai-remix/src/create-transcription.ts b/packages/ai-remix/src/create-transcription.ts new file mode 100644 index 0000000000..483ac9eda2 --- /dev/null +++ b/packages/ai-remix/src/create-transcription.ts @@ -0,0 +1,105 @@ +import { createGeneration } from './create-generation.ts' +import { reconstructTranscriptionResult } from '@tanstack/ai-client' +import type { Handle } from 'remix/ui' +import type { TranscriptionResult } from '@tanstack/ai' +import type { + GenerationPersistenceOptions, + TranscriptionGenerateInput, +} from '@tanstack/ai-client' +import type { + CreateGenerationOptions, + CreateGenerationReturn, +} from './create-generation.ts' + +/** + * Options for the createTranscription helper. + * + * Handle is the first argument of the helper. It is not part of this type. + * + * @template TOutput - The output type after optional transform (defaults to TranscriptionResult) + */ +export type CreateTranscriptionOptions = Omit< + CreateGenerationOptions< + TranscriptionGenerateInput, + TranscriptionResult, + TOutput + >, + 'onResult' | 'reconstructResult' +> & { + onResult?: (result: TranscriptionResult) => TOutput | null | void +} + +/** + * Return type for the createTranscription helper. + * + * @template TOutput - The output type (after optional transform) + */ +export type CreateTranscriptionReturn = + CreateGenerationReturn + +/** + * Creates an audio transcription helper for Remix setup. + * + * Call this in a Remix component setup function. Pass the component Handle as + * the first argument. + * + * @example + * ```tsx + * import { createTranscription } from '@tanstack/ai-remix' + * import { fetchServerSentEvents } from '@tanstack/ai-client' + * import type { Handle } from 'remix/ui' + * + * function Transcriber(handle: Handle) { + * const transcription = createTranscription(handle, { + * connection: fetchServerSentEvents('/api/transcribe'), + * }) + * + * return () => ( + *
    + * { + * const file = event.currentTarget.files?.[0] + * if (!file) return + * const reader = new FileReader() + * reader.onload = () => { + * const audio = reader.result + * if (typeof audio === 'string') { + * transcription.generate({ audio, language: 'en' }) + * } + * } + * reader.readAsDataURL(file) + * }} + * /> + * {transcription.isLoading ?

    Transcribing...

    : null} + * {transcription.result ?

    {transcription.result.text}

    : null} + *
    + * ) + * } + * ``` + */ +export function createTranscription( + handle: Pick, + options: Omit< + CreateTranscriptionOptions, + 'onResult' | 'persistence' | 'threadId' + > & { + onResult?: (result: TranscriptionResult) => TTransformed + } & GenerationPersistenceOptions, +) { + const devtools = { + ...options.devtools, + hookName: 'createTranscription', + outputKind: 'text' as const, + } + return createGeneration< + TranscriptionGenerateInput, + TranscriptionResult, + TTransformed + >(handle, { + ...options, + devtools, + reconstructResult: reconstructTranscriptionResult, + }) +} diff --git a/packages/ai-remix/src/index.ts b/packages/ai-remix/src/index.ts new file mode 100644 index 0000000000..3c668cddec --- /dev/null +++ b/packages/ai-remix/src/index.ts @@ -0,0 +1,92 @@ +export { createChat } from './create-chat.ts' +export { createByok } from './create-byok.ts' +export { createRealtimeChat } from './create-realtime-chat.ts' +export { createMcpAppBridge } from './create-mcp-app-bridge.ts' +export type { CreateMcpAppBridgeOptions } from './create-mcp-app-bridge.ts' +export type { + DeepPartial, + CreateChatOptions, + CreateChatReturn, + UIMessage, + ChatRequestBody, + QueuedMessage, + SendMessageOptions, + WhenBusy, + QueueConfig, + QueueStrategy, + QueueOption, +} from './types.ts' +export type { + CreateRealtimeChatOptions, + CreateRealtimeChatReturn, +} from './realtime-types.ts' + +export { createGeneration } from './create-generation.ts' +export type { + CreateGenerationOptions, + CreateGenerationReturn, +} from './create-generation.ts' +export { createGenerateImage } from './create-generate-image.ts' +export type { + CreateGenerateImageOptions, + CreateGenerateImageReturn, +} from './create-generate-image.ts' +export { createGenerateAudio } from './create-generate-audio.ts' +export type { + CreateGenerateAudioOptions, + CreateGenerateAudioReturn, +} from './create-generate-audio.ts' +export { createGenerateSpeech } from './create-generate-speech.ts' +export type { + CreateGenerateSpeechOptions, + CreateGenerateSpeechReturn, +} from './create-generate-speech.ts' +export { createTranscription } from './create-transcription.ts' +export type { + CreateTranscriptionOptions, + CreateTranscriptionReturn, +} from './create-transcription.ts' +export { createSummarize } from './create-summarize.ts' +export type { + CreateSummarizeOptions, + CreateSummarizeReturn, +} from './create-summarize.ts' +export { createGenerateVideo } from './create-generate-video.ts' +export type { + CreateGenerateVideoOptions, + CreateGenerateVideoReturn, +} from './create-generate-video.ts' +export { createAudioRecorder } from './create-audio-recorder.ts' +export type { CreateAudioRecorderOptions } from './create-audio-recorder.ts' + +// Re-export from ai-client for convenience (mirror octane index.ts). +// createMcpAppBridge / CreateMcpAppBridgeOptions come from ./create-mcp-app-bridge. +export { + fetchServerSentEvents, + fetchHttpStream, + xhrServerSentEvents, + xhrHttpStream, + stream, + rpcStream, + createChatClientOptions, + type McpAppBridge, + type ChatFetcher, + type ChatFetcherInput, + type ChatFetcherOptions, + type ConnectionAdapter, + type ConnectConnectionAdapter, + type SubscribeConnectionAdapter, + type RunAgentInputContext, + type FetchConnectionOptions, + type XhrConnectionOptions, + type InferChatMessages, + type GenerationClientState, + type ImageGenerateInput, + type AudioGenerateInput, + type SpeechGenerateInput, + type TranscriptionGenerateInput, + type SummarizeGenerateInput, + type VideoGenerateInput, + type VideoGenerateResult, + type VideoStatusInfo, +} from '@tanstack/ai-client' diff --git a/packages/ai-remix/src/realtime-types.ts b/packages/ai-remix/src/realtime-types.ts new file mode 100644 index 0000000000..18b6129c79 --- /dev/null +++ b/packages/ai-remix/src/realtime-types.ts @@ -0,0 +1,150 @@ +import type { + AnyClientTool, + RealtimeMessage, + RealtimeMode, + RealtimeSessionConfig, + RealtimeStatus, + RealtimeToken, + UsageInfo, +} from '@tanstack/ai' +import type { RealtimeAdapter } from '@tanstack/ai-client' + +/** + * Options for the createRealtimeChat helper. + * + * Called in Remix setup with Handle. Handle is not part of this type. + */ +export interface CreateRealtimeChatOptions { + /** + * Function to fetch a realtime token from the server. + * Called on connect and when token needs refresh. + */ + getToken: () => Promise + + /** + * The realtime adapter to use (e.g., openaiRealtime()) + */ + adapter: RealtimeAdapter + + /** + * Client-side tools with execution logic + */ + tools?: ReadonlyArray + + /** + * Auto-play assistant audio (default: true) + */ + autoPlayback?: boolean + + /** + * Request microphone access on connect (default: true) + */ + autoCapture?: boolean + + /** + * System instructions for the assistant + */ + instructions?: string + + /** + * Voice to use for audio output + */ + voice?: string + + /** + * Voice activity detection mode (default: 'server') + */ + vadMode?: 'server' | 'semantic' | 'manual' + + /** + * Output modalities for responses (e.g., ['audio', 'text']) + */ + outputModalities?: Array<'audio' | 'text'> + + /** + * Temperature for generation (provider-specific range) + */ + temperature?: number + + /** + * Maximum number of tokens in a response + */ + maxOutputTokens?: number | 'inf' + + /** + * Eagerness level for semantic VAD ('low', 'medium', 'high') + */ + semanticEagerness?: 'low' | 'medium' | 'high' + + // Callbacks + onConnect?: () => void + onDisconnect?: () => void + onError?: (error: Error) => void + onMessage?: (message: RealtimeMessage) => void + onModeChange?: (mode: RealtimeMode) => void + onInterrupted?: () => void + onUsage?: (usage: UsageInfo) => void + onGoAway?: (timeLeft?: string) => void + onStatusChange?: (status: RealtimeStatus) => void +} + +/** + * Return type for the createRealtimeChat helper. + * + * Called in Remix setup with Handle. Fields are plain values. + */ +export interface CreateRealtimeChatReturn { + // Connection state + /** Current connection status */ + status: RealtimeStatus + /** Current error, if any */ + error: Error | null + /** Connect to the realtime session */ + connect: () => Promise + /** Disconnect from the realtime session */ + disconnect: () => Promise + + // Conversation state + /** Current mode (idle, listening, thinking, speaking) */ + mode: RealtimeMode + /** Conversation messages */ + messages: Array + /** User transcript while speaking (before finalized) */ + pendingUserTranscript: string | null + /** Assistant transcript while speaking (before finalized) */ + pendingAssistantTranscript: string | null + + // Voice control + /** Start listening for voice input (manual VAD mode) */ + startListening: () => void + /** Stop listening for voice input (manual VAD mode) */ + stopListening: () => void + /** Interrupt the current assistant response */ + interrupt: () => void + + // Text input + /** Send a text message instead of voice */ + sendText: (text: string) => void + + // Image input + /** Send an image to the conversation */ + sendImage: (imageData: string, mimeType: string) => void + + // Audio visualization (0-1 normalized) + /** Current input (microphone) volume level */ + inputLevel: number + /** Current output (speaker) volume level */ + outputLevel: number + /** Get frequency data for input audio visualization */ + getInputFrequencyData: () => Uint8Array + /** Get frequency data for output audio visualization */ + getOutputFrequencyData: () => Uint8Array + /** Get time domain data for input waveform */ + getInputTimeDomainData: () => Uint8Array + /** Get time domain data for output waveform */ + getOutputTimeDomainData: () => Uint8Array + + // Session control + /** Update the active session and persist the configuration for reconnects. */ + updateSession: (config: RealtimeSessionConfig) => void +} diff --git a/packages/ai-remix/src/types.ts b/packages/ai-remix/src/types.ts new file mode 100644 index 0000000000..845724e3bf --- /dev/null +++ b/packages/ai-remix/src/types.ts @@ -0,0 +1,313 @@ +import type { + AnyClientTool, + InterruptDefinition, + InferSchemaType, + ModelMessage, + RunAgentResumeItem, + SchemaInput, +} from '@tanstack/ai/client' +import type { + AIDevtoolsDisplayOptions, + BoundInterrupts, + ChatClientOptions, + ChatClientState, + ResolvableChatInterrupt, + ChatInterruptState, + ChatRequestBody, + ChatResumeState, + ClientContextOptionFromTools, + ConnectionStatus, + DistributedOmit, + InferredClientContext, + MultimodalContent, + QueueConfig, + QueueOption, + QueueStrategy, + QueuedMessage, + SendMessageOptions, + UIMessage, + WhenBusy, +} from '@tanstack/ai-client' + +// Re-export types from ai-client +export type { + ChatRequestBody, + MultimodalContent, + QueueConfig, + QueuedMessage, + QueueOption, + QueueStrategy, + SendMessageOptions, + UIMessage, + WhenBusy, +} + +/** + * Recursive partial. Every property and every nested array element is optional. + * Used to type the in-flight `partial` value the helper exposes while a + * structured output stream is still arriving (the JSON has shape but is + * incomplete). + */ +export type DeepPartial = + T extends ReadonlyArray + ? Array> + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T + +/** + * Options for the createChat helper. + * + * Call `createChat(handle, options)` in Remix setup with Handle from + * `remix/ui`. Handle is the first argument of the helper. It is not part of + * this type. The default id is `options.threadId ?? handle.id`. + * + * Pass either `connection` or `fetcher`. The XOR is enforced at the type + * level via `ChatTransport`. + * + * This extends ChatClientOptions but omits the state change callbacks that + * createChat manages internally: + * - `onMessagesChange` - Managed internally (exposed as `messages`) + * - `onLoadingChange` - Managed internally (exposed as `isLoading`) + * - `onErrorChange` - Managed internally (exposed as `error`) + * - `onStatusChange` - Managed internally (exposed as `status`) + * + * All other callbacks (onResponse, onChunk, onFinish, onError) are + * passed through to the underlying ChatClient and can be used for side effects. + * + * When `outputSchema` is supplied, the helper returns a typed `partial` (live + * progressive object, updated from `TEXT_MESSAGE_CONTENT` deltas via + * `parsePartialJSON`) and `final` (validated terminal payload from the + * `structured-output.complete` event). The schema is used purely for type + * inference on the client. Server-side validation still runs against the + * schema you pass to `chat({ outputSchema })` on the server route. + * + * Changing `connection` or `fetcher` updates the active ChatClient in place, + * preserving its state. Changing `threadId` creates a fresh client. + */ +export type CreateChatOptions< + TTools extends ReadonlyArray = any, + TSchema extends SchemaInput | undefined = undefined, + TContext = InferredClientContext, + TInterrupts extends ReadonlyArray> = + readonly [], +> = DistributedOmit< + ChatClientOptions, + | 'onMessagesChange' + | 'onLoadingChange' + | 'onErrorChange' + | 'onStatusChange' + | 'onSubscriptionChange' + | 'onConnectionStatusChange' + | 'onSessionGeneratingChange' + | 'onQueueChange' + | 'onResumeStateChange' + | 'onRunIdChange' + | 'context' + | 'devtools' +> & { + /** Display options for TanStack AI Devtools. */ + devtools?: AIDevtoolsDisplayOptions + /** + * Opt into live subscription behavior when the helper is called in Remix + * setup with Handle. When enabled, the helper subscribes on setup and + * unsubscribes on dispose. + */ + live?: boolean + /** + * Standard-schema-compatible schema (Zod, Valibot, ArkType, or a plain JSON + * Schema). Used to infer the shape of `partial` and `final` in the return. + * The schema is **not** sent to the server. Server-side validation runs + * against the schema passed to `chat({ outputSchema })` on the server route. + */ + outputSchema?: TSchema +} & ClientContextOptionFromTools + +/** + * Discriminated return shape from the createChat helper. When `outputSchema` + * is supplied, the helper adds typed `partial` / `final` fields. When it is + * omitted (default), the return is unchanged. Fields are plain values. + */ +export type CreateChatReturn< + TTools extends ReadonlyArray = any, + TSchema extends SchemaInput | undefined = undefined, + TInterrupts extends ReadonlyArray> = + readonly [], +> = BaseCreateChatReturn< + TTools, + TSchema extends SchemaInput ? InferSchemaType : unknown, + TInterrupts +> & + (TSchema extends SchemaInput + ? { + /** + * Live, progressively-parsed structured output. Updated from + * `TEXT_MESSAGE_CONTENT` deltas via `parsePartialJSON` while the stream + * is still arriving, and snapped to the validated payload when + * `structured-output.complete` fires. Resets on every new run + * (`sendMessage` / `reload`). + */ + partial: DeepPartial> + /** + * Final, schema-validated structured output. `null` until the terminal + * `structured-output.complete` event arrives. Resets on every new run. + */ + final: InferSchemaType | null + } + : Record) + +interface BaseCreateChatReturn< + TTools extends ReadonlyArray = any, + TData = unknown, + TInterrupts extends ReadonlyArray> = + readonly [], +> { + /** + * Current messages in the conversation. When `outputSchema` is supplied, + * `messages[i].parts.find(p => p.type === 'structured-output')` is typed + * with the schema's inferred shape: `data: T`, `partial: DeepPartial`. + */ + messages: Array> + + /** + * Send a message and get a response. + * Can be a simple string or multimodal content with images, audio, etc. + * By default, sends while busy are queued until the run settles successfully + * (`queue: 'drop'` restores the old drop-while-busy behavior). + * Pass `{ whenBusy }` to override the policy for a single send, or + * `{ body }` to merge per-call JSON into this request's `forwardedProps`. + */ + sendMessage: ( + content: string | MultimodalContent, + options?: SendMessageOptions, + ) => Promise + + /** + * Pending messages queued while the client is busy (streaming, claiming a + * send, or draining). Separate from `messages` until they drain. + */ + queue: Array + + /** + * Cancel a queued message before it drains. No-op if already sent. + */ + cancelQueued: (id: string) => void + + /** + * Append a message to the conversation + */ + append: (message: ModelMessage | UIMessage) => Promise + + /** + * Add the result of a client-side tool execution + */ + addToolResult: (result: { + toolCallId: string + tool: string + output: any + state?: 'output-available' | 'output-error' + errorText?: string + }) => Promise + + /** + * @deprecated Use a bound `tool-approval` interrupt and + * `interrupt.resolveInterrupt`. + */ + addToolApprovalResponse: (response: { + id: string // approval.id, not toolCallId + approved: boolean + }) => Promise + + /** + * The id of the run this client has in flight (one it started or rejoined), + * or `null` when there is none (including while a run sits paused on an + * interrupt, waiting on approval). + * + * A run is one turn of the conversation, so this changes from turn to turn. A + * whole tool loop stays inside one run, while resuming after an interrupt + * continues the turn under a new id — so one user message can produce several + * run ids. Use it to talk to your own server about that run (cancel it, poll + * it, correlate a log line). + */ + runId: string | null + interrupts: BoundInterrupts + /** @deprecated Use `interrupts`. */ + pendingInterrupts: BoundInterrupts + interruptErrors: ChatInterruptState['interruptErrors'] + resuming: boolean + resolveInterrupts: { + (approved: boolean): void + ( + resolver: ( + interrupt: ResolvableChatInterrupt, + ) => undefined, + ): void + } + cancelInterrupts: () => void + retryInterrupts: () => void + resumeInterruptsUnsafe: ( + resume: Array, + state?: ChatResumeState, + ) => Promise + /** @deprecated Use bound interrupt methods or `resumeInterruptsUnsafe`. */ + resumeInterrupts: ( + resume: Array, + state?: ChatResumeState, + ) => Promise + + /** + * Reload the last assistant message + */ + reload: () => Promise + + /** + * Stop the current response generation + */ + stop: () => void + + /** + * Whether a response is currently being generated + */ + isLoading: boolean + + /** + * Current error, if any + */ + error: Error | undefined + + /** + * Current status of the chat client + */ + status: ChatClientState + + /** + * Whether the subscription loop is currently active + */ + isSubscribed: boolean + + /** + * Current connection lifecycle status + */ + connectionStatus: ConnectionStatus + + /** + * Whether the shared session is actively generating. + * Derived from stream run events (RUN_STARTED / RUN_FINISHED / RUN_ERROR). + * Unlike `isLoading` (request-local), this reflects shared generation + * activity visible to all subscribers (e.g. across tabs/devices). + */ + sessionGenerating: boolean + + /** + * Set messages manually + */ + setMessages: (messages: Array>) => void + + /** + * Clear all messages + */ + clear: () => void +} + +// createChatClientOptions and InferChatMessages live in @tanstack/ai-client +// and are re-exported from there. diff --git a/packages/ai-remix/src/ui.ts b/packages/ai-remix/src/ui.ts new file mode 100644 index 0000000000..c7cc3cce7b --- /dev/null +++ b/packages/ai-remix/src/ui.ts @@ -0,0 +1,41 @@ +// Barrel entry for the `@tanstack/ai-remix/ui` subpath. +export { + createChatUI, + type ChatUIComponents, + type ChatUIFactoryConfig, + type ChatUIHost, + type ChatUIQueueItem, + type InputProps, + type InterruptProps, + type LayoutProps, + type MessageProps, + type PartProps, + type QueueProps, + type ToolProps, +} from './chat-ui/create-ui.tsx' +export { createChatHook } from './chat-ui/create-chat-hook.ts' +export { Chat, useChatContext, type ChatProps } from './chat-ui/chat.tsx' +export { + ChatMessages, + type ChatMessagesProps, +} from './chat-ui/chat-messages.tsx' +export { + ChatMessage, + type ChatMessageProps, + type ToolCallRenderProps, +} from './chat-ui/chat-message.tsx' +export { + ChatInput, + type ChatInputProps, + type ChatInputRenderProps, +} from './chat-ui/chat-input.tsx' +export { + ToolApproval, + type ToolApprovalProps, + type ToolApprovalRenderProps, +} from './chat-ui/tool-approval.tsx' +export { TextPart, type TextPartProps } from './chat-ui/text-part.tsx' +export { + ThinkingPart, + type ThinkingPartProps, +} from './chat-ui/thinking-part.tsx' diff --git a/packages/ai-remix/tests/create-audio-recorder.test.ts b/packages/ai-remix/tests/create-audio-recorder.test.ts new file mode 100644 index 0000000000..88683c27e4 --- /dev/null +++ b/packages/ai-remix/tests/create-audio-recorder.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createAudioRecorder } from '../src/create-audio-recorder' +import type { FrameHandle, Handle } from 'remix/ui' + +class FakeMediaRecorder { + ondataavailable: ((event: { data: Blob }) => void) | null = null + onstop: (() => void) | null = null + onerror: (() => void) | null = null + start() {} + stop() {} +} + +function createFrameHandle(): FrameHandle { + return Object.assign(new EventTarget(), { + src: '', + async reload() { + return new AbortController().signal + }, + async replace() {}, + }) +} + +function createTestHandle() { + const controller = new AbortController() + const frame = createFrameHandle() + const handle: Handle = { + id: 'test', + props: {}, + context: { + set() {}, + get() { + return undefined + }, + }, + async update() { + return new AbortController().signal + }, + queueTask() {}, + frame, + frames: { + top: frame, + get() { + return undefined + }, + }, + signal: controller.signal, + } + return { handle, abort: () => controller.abort() } +} + +beforeEach(() => { + vi.stubGlobal('navigator', { + mediaDevices: { + getUserMedia: vi.fn(async () => ({ + getTracks: () => [{ stop: vi.fn() }], + })), + }, + }) + vi.stubGlobal('MediaRecorder', FakeMediaRecorder) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('createAudioRecorder', () => { + it('starts idle and is recording after start', async () => { + const { handle } = createTestHandle() + const recorder = createAudioRecorder(handle) + expect(recorder.isSupported).toBe(true) + expect(recorder.isRecording).toBe(false) + + await recorder.start() + expect(recorder.isRecording).toBe(true) + }) + + it('releases the mic when handle.signal aborts', async () => { + const trackStop = vi.fn() + vi.stubGlobal('navigator', { + mediaDevices: { + getUserMedia: vi.fn(async () => ({ + getTracks: () => [{ stop: trackStop }], + })), + }, + }) + const { handle, abort } = createTestHandle() + const recorder = createAudioRecorder(handle) + await recorder.start() + abort() + expect(trackStop).toHaveBeenCalled() + }) +}) diff --git a/packages/ai-remix/tests/create-byok.test.ts b/packages/ai-remix/tests/create-byok.test.ts new file mode 100644 index 0000000000..3fe0e6452a --- /dev/null +++ b/packages/ai-remix/tests/create-byok.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import { createByok } from '../src/create-byok' +import type { ByokClient, ByokSnapshot } from '@tanstack/ai-client/byok' +import type { Handle } from 'remix/ui' + +const INITIAL: ByokSnapshot = { + status: {}, + locked: false, + prompt: null, + storageError: null, +} + +const UPDATED: ByokSnapshot = { + status: { openai: { state: 'set', masked: 'ghij' } }, + locked: false, + prompt: null, + storageError: null, +} + +const AFTER_ABORT: ByokSnapshot = { + status: { anthropic: { state: 'set', masked: 'wxyz' } }, + locked: true, + prompt: null, + storageError: null, +} + +function createFakeClient(snapshot: ByokSnapshot) { + let current = snapshot + const listeners = new Set<() => void>() + const client: Pick = { + getSnapshot: () => current, + subscribe: (listener) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + } + return { + client, + emit(next: ByokSnapshot) { + current = next + for (const listener of listeners) { + listener() + } + }, + } +} + +function createFakeHandle() { + const controller = new AbortController() + const update = vi.fn(() => Promise.resolve(controller.signal)) + const handle: Pick = { + update, + signal: controller.signal, + } + return { + handle, + update, + abort: () => controller.abort(), + } +} + +describe('createByok', () => { + it('updates the getter and handle when the client emits', () => { + const { client, emit } = createFakeClient(INITIAL) + const { handle, update } = createFakeHandle() + const getSnapshot = createByok(handle, client) + + expect(getSnapshot()).toEqual(INITIAL) + + emit(UPDATED) + + expect(getSnapshot()).toEqual(UPDATED) + expect(update).toHaveBeenCalledTimes(1) + }) + + it('does not update after the handle signal aborts', () => { + const { client, emit } = createFakeClient(INITIAL) + const { handle, update, abort } = createFakeHandle() + const getSnapshot = createByok(handle, client) + + emit(UPDATED) + abort() + emit(AFTER_ABORT) + + expect(getSnapshot()).toEqual(UPDATED) + expect(update).toHaveBeenCalledTimes(1) + }) + + it('unsubscribes immediately when the handle signal is already aborted', () => { + const { client, emit } = createFakeClient(INITIAL) + const { handle, update, abort } = createFakeHandle() + abort() + const getSnapshot = createByok(handle, client) + + emit(UPDATED) + + expect(getSnapshot()).toEqual(INITIAL) + expect(update).not.toHaveBeenCalled() + }) +}) diff --git a/packages/ai-remix/tests/create-chat.test.ts b/packages/ai-remix/tests/create-chat.test.ts new file mode 100644 index 0000000000..956f68c167 --- /dev/null +++ b/packages/ai-remix/tests/create-chat.test.ts @@ -0,0 +1,105 @@ +import { EventType } from '@tanstack/ai/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createChat } from '../src/create-chat' +import type { Handle } from 'remix/ui' +import type { StreamChunk } from '@tanstack/ai/client' + +const abortControllers: Array = [] + +afterEach(() => { + for (const abort of abortControllers) { + abort.abort() + } + abortControllers.length = 0 + vi.restoreAllMocks() +}) + +function createHandle() { + const abort = new AbortController() + abortControllers.push(abort) + const frame = Object.assign(new EventTarget(), { + src: '', + reload: async () => abort.signal, + replace: async () => {}, + }) + const handle: Handle = { + id: 'handle-1', + update: vi.fn(async () => abort.signal), + signal: abort.signal, + props: {}, + context: { + set() {}, + get() { + return undefined + }, + }, + queueTask() {}, + frame, + frames: { + top: frame, + get() { + return undefined + }, + }, + } + return { handle, abort } +} + +function textChunk(delta: string, messageId = 'assistant-1'): StreamChunk { + return { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + timestamp: Date.now(), + delta, + } +} + +function runFinished(runId = 'run-1'): StreamChunk { + return { + type: EventType.RUN_FINISHED, + runId, + threadId: 'thread-1', + timestamp: Date.now(), + } +} + +function createConnection(chunks: Array) { + return { + async *connect() { + for (const chunk of chunks) { + yield chunk + } + }, + } +} + +describe('createChat', () => { + it('records user and assistant messages and calls handle.update after sendMessage', async () => { + const { handle } = createHandle() + const chat = createChat(handle, { + connection: createConnection([textChunk('Hi'), runFinished()]), + }) + + await chat.sendMessage('Hello') + + expect(chat.messages.map((message) => message.role)).toEqual([ + 'user', + 'assistant', + ]) + expect(handle.update).toHaveBeenCalled() + }) + + it('does not call handle.update after the handle signal aborts', async () => { + const { handle, abort } = createHandle() + const chat = createChat(handle, { + connection: createConnection([textChunk('Hi'), runFinished()]), + }) + + vi.mocked(handle.update).mockClear() + abort.abort() + + await chat.sendMessage('Hello').catch(() => {}) + + expect(handle.update).not.toHaveBeenCalled() + }) +}) diff --git a/packages/ai-remix/tests/create-generation.test.ts b/packages/ai-remix/tests/create-generation.test.ts new file mode 100644 index 0000000000..44da7930f6 --- /dev/null +++ b/packages/ai-remix/tests/create-generation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest' +import { createGenerateImage } from '../src/create-generate-image' +import { createGeneration } from '../src/create-generation' +import type { ImageGenerationResult } from '@tanstack/ai' + +function createMockHandle() { + const controller = new AbortController() + return { + id: 'handle-1', + update: vi.fn(async () => new AbortController().signal), + signal: controller.signal, + } +} + +describe('createGenerateImage', () => { + it('sets result from a fetcher and updates the handle', async () => { + const handle = createMockHandle() + const mockResult: ImageGenerationResult = { + id: 'img-1', + images: [{ url: 'https://example.com/x.png' }], + model: 'dall-e-3', + } + + const gen = createGenerateImage(handle, { + fetcher: async () => mockResult, + }) + + await gen.generate({ prompt: 'A sunset' }) + + expect(gen.result).toEqual(mockResult) + expect(handle.update).toHaveBeenCalled() + }) +}) + +describe('createGeneration', () => { + it('throws when connection and fetcher are both missing', () => { + const handle = createMockHandle() + expect(() => createGeneration(handle, {})).toThrow( + 'createGeneration requires either a connection or fetcher option', + ) + }) +}) diff --git a/packages/ai-remix/tests/create-mcp-app-bridge.test.ts b/packages/ai-remix/tests/create-mcp-app-bridge.test.ts new file mode 100644 index 0000000000..470a19bfcc --- /dev/null +++ b/packages/ai-remix/tests/create-mcp-app-bridge.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Handle } from 'remix/ui' +import { createMcpAppBridge } from '../src/create-mcp-app-bridge' +import type { CreateMcpAppBridgeOptions } from '../src/create-mcp-app-bridge' + +type SendMessage = CreateMcpAppBridgeOptions['chat']['sendMessage'] + +function createHandle(): Handle { + const signal = new AbortController().signal + const frame: Handle['frame'] = Object.assign(new EventTarget(), { + src: '/', + reload: async () => signal, + replace: async () => {}, + }) + + return { + id: 'h1', + props: {}, + context: { + set() {}, + get() { + return undefined + }, + }, + update: async () => signal, + queueTask() {}, + frame, + frames: { + get top() { + return frame + }, + get() { + return undefined + }, + }, + signal, + } +} + +function options( + overrides?: Partial, +): CreateMcpAppBridgeOptions { + return { + threadId: 't1', + callEndpoint: '/api/mcp-apps-call', + chat: { sendMessage: vi.fn(async () => {}) }, + ...overrides, + } +} + +describe('createMcpAppBridge', () => { + it('returns a bridge exposing callTool, sendPrompt and openLink', () => { + const bridge = createMcpAppBridge(createHandle(), options()) + expect(typeof bridge.callTool).toBe('function') + expect(typeof bridge.sendPrompt).toBe('function') + expect(typeof bridge.openLink).toBe('function') + }) + + it('sendPrompt forwards text to chat.sendMessage', async () => { + const sendMessage = vi.fn(async () => {}) + const bridge = createMcpAppBridge( + createHandle(), + options({ chat: { sendMessage } }), + ) + + await bridge.sendPrompt('hello') + + expect(sendMessage.mock.calls).toEqual([['hello']]) + }) +}) diff --git a/packages/ai-remix/tests/create-realtime-chat.test.ts b/packages/ai-remix/tests/create-realtime-chat.test.ts new file mode 100644 index 0000000000..422fce8e18 --- /dev/null +++ b/packages/ai-remix/tests/create-realtime-chat.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RealtimeToken } from '@tanstack/ai' +import type { RealtimeAdapter, RealtimeConnection } from '@tanstack/ai-client' +import type { Handle } from 'remix/ui' +import { createRealtimeChat } from '../src/create-realtime-chat' +import type { CreateRealtimeChatOptions } from '../src/realtime-types' + +function createConnection(): RealtimeConnection { + return { + disconnect: vi.fn(async () => {}), + startAudioCapture: vi.fn(async () => {}), + stopAudioCapture: vi.fn(), + sendText: vi.fn(), + sendImage: vi.fn(), + sendToolResult: vi.fn(), + updateSession: vi.fn(), + interrupt: vi.fn(), + on: () => () => {}, + getAudioVisualization: () => ({ + inputLevel: 0, + outputLevel: 0, + getInputFrequencyData: () => new Uint8Array(128), + getOutputFrequencyData: () => new Uint8Array(128), + getInputTimeDomainData: () => new Uint8Array(128), + getOutputTimeDomainData: () => new Uint8Array(128), + inputSampleRate: 48_000, + outputSampleRate: 48_000, + }), + } +} + +function createAdapter( + provider: string, + connections: Array, +) { + const remaining = [...connections] + const connect = vi.fn(async () => { + const connection = remaining.shift() + if (!connection) throw new Error(`No ${provider} test connection remains`) + return connection + }) + const adapter: RealtimeAdapter = { provider, connect } + return { adapter, connect } +} + +function createToken( + provider: string, + value: string, + expiresAt: number = Date.now() + 3_600_000, +): RealtimeToken { + return { provider, token: value, expiresAt, config: {} } +} + +function createHandle() { + const controller = new AbortController() + const frame = Object.assign(new EventTarget(), { + src: '', + reload: async () => controller.signal, + replace: async () => {}, + }) + const handle: Handle = { + id: 'realtime-chat', + props: {}, + context: { + set() {}, + get() { + return undefined + }, + }, + update: vi.fn(async () => controller.signal), + queueTask() {}, + frame, + frames: { + top: frame, + get() { + return undefined + }, + }, + signal: controller.signal, + } + return { handle, abort: () => controller.abort() } +} + +function createOptions( + adapter: RealtimeAdapter, + getToken: CreateRealtimeChatOptions['getToken'], +): CreateRealtimeChatOptions { + return { adapter, getToken, autoCapture: false } +} + +let abortHandle: (() => void) | undefined + +beforeEach(() => { + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 1), + ) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) +}) + +afterEach(() => { + abortHandle?.() + abortHandle = undefined + vi.unstubAllGlobals() +}) + +describe('createRealtimeChat', () => { + it('connects and exposes connected status', async () => { + const { handle, abort } = createHandle() + abortHandle = abort + const connection = createConnection() + const testAdapter = createAdapter('test', [connection]) + const getToken = vi.fn(async () => createToken('test', 'token')) + const chat = createRealtimeChat( + handle, + createOptions(testAdapter.adapter, getToken), + ) + + expect(chat.status).toBe('idle') + await chat.connect() + expect(chat.status).toBe('connected') + }) +}) diff --git a/packages/ai-remix/tests/create-ui.test.ts b/packages/ai-remix/tests/create-ui.test.ts new file mode 100644 index 0000000000..44dbb925b5 --- /dev/null +++ b/packages/ai-remix/tests/create-ui.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { createElement } from 'remix/ui' +import { createChatHook } from '../src/chat-ui/create-chat-hook.ts' +import { createChatUI } from '../src/chat-ui/create-ui.tsx' +import type { + ChatUIFactoryConfig, + ChatUIHost, + LayoutProps, + MessageProps, +} from '../src/chat-ui/create-ui.tsx' +import type { Handle, RemixNode } from 'remix/ui' +import type { UIMessage } from '../src/types.ts' + +const weatherMessage: UIMessage = { + id: 'message-1', + role: 'assistant', + parts: [ + { + type: 'tool-call', + id: 'call-weather', + name: 'getWeather', + arguments: '{"city":"London"}', + input: { city: 'London' }, + state: 'complete', + }, + ], +} + +const chatOptions = { + tools: [{ name: 'getWeather' as const }], +} + +function getWeather(handle: Handle<{ part: { input?: { city?: string } } }>) { + return () => createElement('strong', {}, handle.props.part.input?.city) +} + +const kit = { + components: { + layout(handle: Handle>) { + return () => { + const { Messages } = handle.props + return createElement(Messages) + } + }, + message(handle: Handle>) { + return () => { + const { Parts } = handle.props + return createElement(Parts) + } + }, + }, + partsComponents: { + fallback() { + return () => null + }, + }, + toolsComponents: { + getWeather, + }, +} as ChatUIFactoryConfig + +function host(messages: Array): ChatUIHost { + return { + messages, + interrupts: [], + queue: [], + cancelQueued() {}, + } as unknown as ChatUIHost +} + +describe('createChatHook', () => { + it('returns createAppChat, ui, and useChatContext', () => { + const { createAppChat, ui, useChatContext } = createChatHook({ + options: chatOptions, + ...kit, + }) + expect(createAppChat).toBeTypeOf('function') + expect(ui.Chat).toBeTypeOf('function') + expect(useChatContext).toBeTypeOf('function') + }) +}) + +describe('createChatUI', () => { + let cleanup: (() => void) | undefined + + afterEach(() => { + cleanup?.() + cleanup = undefined + }) + + it('renders a getWeather tool component with the city text', async () => { + const UI = createChatUI(chatOptions, kit) + + let render: + | ((node: RemixNode) => { container: HTMLElement; cleanup: () => void }) + | undefined + try { + ;({ render } = await import('remix/ui/test')) + } catch { + render = undefined + } + + const result = render + ? render( + createElement(UI.Chat, { + chat: host([weatherMessage]), + }), + ) + : undefined + + if (result) { + cleanup = result.cleanup + expect(result.container.textContent).toContain('London') + return + } + + expect(getWeather).toBeTypeOf('function') + const node = getWeather({ + props: { + part: weatherMessage.parts[0], + }, + } as unknown as Handle<{ part: { input?: { city?: string } } }>)() + expect(readText(node)).toContain('London') + }) +}) + +function readText(node: RemixNode): string { + if (node == null || typeof node === 'boolean') return '' + if (typeof node === 'string' || typeof node === 'number') return String(node) + if (Array.isArray(node)) return node.map(readText).join('') + if (typeof node === 'object' && 'props' in node) { + return readText(node.props.children as RemixNode) + } + return '' +} diff --git a/packages/ai-remix/tests/exports.test.ts b/packages/ai-remix/tests/exports.test.ts new file mode 100644 index 0000000000..30d4d63543 --- /dev/null +++ b/packages/ai-remix/tests/exports.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { + createAudioRecorder, + createByok, + createChat, + createGenerateAudio, + createGenerateImage, + createGenerateSpeech, + createGenerateVideo, + createGeneration, + createMcpAppBridge, + createRealtimeChat, + createSummarize, + createTranscription, +} from '../src/index' + +describe('package exports', () => { + it('exports every create helper as a function', () => { + expect(createChat).toBeTypeOf('function') + expect(createByok).toBeTypeOf('function') + expect(createRealtimeChat).toBeTypeOf('function') + expect(createMcpAppBridge).toBeTypeOf('function') + expect(createGeneration).toBeTypeOf('function') + expect(createGenerateImage).toBeTypeOf('function') + expect(createGenerateAudio).toBeTypeOf('function') + expect(createGenerateSpeech).toBeTypeOf('function') + expect(createGenerateVideo).toBeTypeOf('function') + expect(createTranscription).toBeTypeOf('function') + expect(createSummarize).toBeTypeOf('function') + expect(createAudioRecorder).toBeTypeOf('function') + }) +}) diff --git a/packages/ai-remix/tsconfig.json b/packages/ai-remix/tsconfig.json new file mode 100644 index 0000000000..b311aeea74 --- /dev/null +++ b/packages/ai-remix/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "esnext", + "lib": ["esnext", "dom", "dom.iterable"], + "target": "esnext", + "jsx": "react-jsx", + "noEmit": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "noEmitOnError": true, + "noErrorTruncation": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "types": ["node"], + "strict": true, + "jsxImportSource": "remix/ui" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-remix/vite.config.ts b/packages/ai-remix/vite.config.ts new file mode 100644 index 0000000000..a520e689e6 --- /dev/null +++ b/packages/ai-remix/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitest/config' +import packageJson from './package.json' + +export default defineConfig({ + esbuild: { + jsx: 'automatic', + jsxImportSource: 'remix/ui', + }, + oxc: { + jsx: { + runtime: 'automatic', + importSource: 'remix/ui', + }, + }, + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: false, + environment: 'happy-dom', + include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + include: ['src/**'], + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23a8488ebf..1995359206 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1274,6 +1274,34 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + examples/ts-remix-chat: + dependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../../packages/ai + '@tanstack/ai-client': + specifier: workspace:* + version: link:../../packages/ai-client + '@tanstack/ai-openai': + specifier: workspace:* + version: link:../../packages/ai-openai + '@tanstack/ai-remix': + specifier: workspace:* + version: link:../../packages/ai-remix + remix: + specifier: ^3.0.0-rc.1 + version: 3.0.0-rc.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(mysql2@3.15.3) + zod: + specifier: ^4.2.0 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.10.3 + typescript: + specifier: 5.9.3 + version: 5.9.3 + examples/ts-solid-chat: dependencies: '@tailwindcss/vite': @@ -2530,6 +2558,34 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + packages/ai-remix: + dependencies: + '@tanstack/ai-client': + specifier: workspace:^ + version: link:../ai-client + devDependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@types/node': + specifier: ^24.10.1 + version: 24.10.3 + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) + happy-dom: + specifier: ^20.11.2 + version: 20.11.2 + remix: + specifier: ^3.0.0-rc.1 + version: 3.0.0-rc.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(mysql2@3.15.3) + vite: + specifier: ^8.2.1 + version: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + packages/ai-sandbox: dependencies: '@modelcontextprotocol/sdk': @@ -6320,6 +6376,133 @@ packages: resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} engines: {node: '>=14'} + '@oxc-minify/binding-android-arm-eabi@0.121.0': + resolution: {integrity: sha512-RcQXLj3JLLVm41n80/6+7OUion2PSQWOH5EUvlD9kCWSF1fWLXCNX1A6t/+nFNjeyaCXZ3YbIWwCTiGXhxxHEw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-minify/binding-android-arm64@0.121.0': + resolution: {integrity: sha512-VnFvB9DgADWpgwQb6LmeRv302xwdgpD/45WlQNWI380YUgWVXmhoZoNOgnaCSbuFEz+ElQDb/iE2U2LADkfu8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-minify/binding-darwin-arm64@0.121.0': + resolution: {integrity: sha512-0EKcroW5oMgJ27DOUWD724nQmLhV1PLArkXW5F4t7cUoRZy81OlFMqS97AOWIrQPlNPaC/1MYfCtIoZIW8OElQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-minify/binding-darwin-x64@0.121.0': + resolution: {integrity: sha512-DvsiLCZQ7KvufItkGuU45ovM4paB99M3/J5ZqpzjSnHpyFmcWUx19gwG9RTDOmHHA+7TPCq3b02aQoCiX6xiaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-minify/binding-freebsd-x64@0.121.0': + resolution: {integrity: sha512-b+ngbloTvuei3HxfOz6nCwWkIl8dhgp42W1TREBUVRRe80iKe4bclrpZHxacFQYmVZ/bDjIV7ePPRSCSKM93RA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-minify/binding-linux-arm-gnueabihf@0.121.0': + resolution: {integrity: sha512-Vj1xJ46zDTJlnF4UQgAVqX4xb2uv6hpmtHkypCMiaNbuop7bJ+VbqSfs7SCKvg23fygK530XTUxr+A7YDbkEzQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-minify/binding-linux-arm-musleabihf@0.121.0': + resolution: {integrity: sha512-lVhZ/y6Piqi+TlM+VB3UdRWWtqm7ks2He5VrYmZfO0a8A/wBE7KTpIK+RoUFGW3ii3wr4Hc8AEWZNEjU4fs38Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-minify/binding-linux-arm64-gnu@0.121.0': + resolution: {integrity: sha512-FMEtjwWKVRehcs4ebsmM8nj7F7/kVH54dcFZodNFsk1iUsVdqPrOWhzanMcU55AYrGmXHeKFx7PlrimDOz2ZdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-minify/binding-linux-arm64-musl@0.121.0': + resolution: {integrity: sha512-ZFCqQWU7TP4oCiu9q0q9xg1wg78Et4bRSCv9LzMAn/N9zJezPa+u3kVqKXkQnvAgrA7fBo9VPSaEx0XMpXsPhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-minify/binding-linux-ppc64-gnu@0.121.0': + resolution: {integrity: sha512-WSV2TNT7a6wfwfWHHvpaOoHVKwB0tKyJpMjj3P401k8tFEZpH/xNqDNofdvXQznKqJ3nyYxIC4llvNGCXUtTzQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-minify/binding-linux-riscv64-gnu@0.121.0': + resolution: {integrity: sha512-hTToDA4mEd4P4HdwnmULtyyWP6CsNwuxdiToGZ5LjQvznpF5acRi9KEAqF8zmNXQ9r1RbrbGbYHATfRWogEbfw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-minify/binding-linux-riscv64-musl@0.121.0': + resolution: {integrity: sha512-zhgxjY8IkVZ2MpuElCiK37DjEwX2uk9r7fawRh0J4yjkYWVQR5kmmMEo5oMPbBMtri61vnSiqLZmD25cTFP1vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-minify/binding-linux-s390x-gnu@0.121.0': + resolution: {integrity: sha512-YruvsabXqUdhtfe9Qjv2F1tb0u1PqqNBnf0jFhC8K4qJLctgveH/2rBYE8WAqdahxfdR59ByFZd0u6dqwDCKPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-minify/binding-linux-x64-gnu@0.121.0': + resolution: {integrity: sha512-OOUpoGKeGN6D9bP9dr2lczK3SgOFeMLFiJuldPxOcY21VAxlemEiTPFTPXp4VWzw65sy3bCx0k8R6wyE8TA3EQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-minify/binding-linux-x64-musl@0.121.0': + resolution: {integrity: sha512-ixdrFcKUdRXsavlAe+ttKQHtR6nUyXSrCjLTkB4eiy8U/5f5A1BQXAKDdw9rUNoPkLc4vrKohG8frI0pjg7S6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-minify/binding-openharmony-arm64@0.121.0': + resolution: {integrity: sha512-P52luYhm78qAPjACwHEMWJQag4hgX3InczjXazLqSWJPf5ismBWDmrSiccVWi2B6nPGSuYd4YQVR3j0h2IELyA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-minify/binding-wasm32-wasi@0.121.0': + resolution: {integrity: sha512-1XDHPrAJa6W8dGqaDnlt+0k5In5JzGE0EOI87cJnOkSGsUAb1Sk8mKNhUe3/PuGiBDDat8eZ0wlq/VcUeOmsoA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-minify/binding-win32-arm64-msvc@0.121.0': + resolution: {integrity: sha512-FvUEX7eTfSh1OBB+/AGSWhkNX/8jPFGM2jvMwrrAZ5vj8kTtnETNTkJdkkPMUEiIEVupxoARKc5UFU2/k+3THw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-minify/binding-win32-ia32-msvc@0.121.0': + resolution: {integrity: sha512-ZNcMq+yy9QBgekrBP/NxTD4RW1sZKHOWO+aH5SgqRvfU035Bldvns7zHC8VdaY4Sz3PcaChfmSeapfUoUGqT5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-minify/binding-win32-x64-msvc@0.121.0': + resolution: {integrity: sha512-/0qRGvYnBVhzwSXHcJ6sF+2rb2QpotbJeAr1wmADgq/hm7JjdRukktngmwXQuIILmC+UELYLoVpa0PTBoEqwrg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-parser/binding-android-arm-eabi@0.121.0': resolution: {integrity: sha512-n07FQcySwOlzap424/PLMtOkbS7xOu8nsJduKL8P3COGHKgKoDYXwoAHCbChfgFpHnviehrLWIPX0lKGtbEk/A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6574,6 +6757,10 @@ packages: cpu: [x64] os: [win32] + '@oxc-project/runtime@0.121.0': + resolution: {integrity: sha512-p0bQukD8OEHxzY4T9OlANBbEFGnOnjo1CYi50HES7OD36UO2yPh6T+uOJKLtlg06eclxroipRCpQGMpeH8EJ/g==} + engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-project/types@0.101.0': resolution: {integrity: sha512-nuFhqlUzJX+gVIPPfuE6xurd4lST3mdcWOhyK/rZO0B9XWMKm79SuszIQEnSMmmDhq1DC8WWVYGVd+6F93o1gQ==} @@ -6695,6 +6882,133 @@ packages: cpu: [x64] os: [win32] + '@oxc-transform/binding-android-arm-eabi@0.121.0': + resolution: {integrity: sha512-NNYkyDjTID7oVW0LUZ04kDShtyY6hgsTakd2u3mz/hN765JviCuyBIi5qT9dDOmgX0t1y74nuS7FwiLgaCcZ4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-transform/binding-android-arm64@0.121.0': + resolution: {integrity: sha512-zO5az3E5JUmF/k7xOOL9TCipqaVn/d8QHK5T8/bcw6qTWAPVFJjQRK8+5MSmp2ItO2Dmxed5DdWMSxG2NNfA5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-transform/binding-darwin-arm64@0.121.0': + resolution: {integrity: sha512-3vcZdmL8OAdYzXfPDeXrO9KagTgUbXPSFXotoww9N0jVNbdCvSpKJHia1aqdltyevrCWF4KqJyOeeUfGcw7AJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-transform/binding-darwin-x64@0.121.0': + resolution: {integrity: sha512-R63ZXF4Fuer3FEZYX9UmzIKAENSEYQZTglTkzWoyNPyuHDhSfyJIK+X+wgy2Wc1lTad1XquCUq5SDuRSd37fcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-transform/binding-freebsd-x64@0.121.0': + resolution: {integrity: sha512-0krk8L6iOJ6fobs3f9XHo4RSgEas0yLq9/xGZMuwxFs+rI/rnpYPX+1LLSmreHqeZM77a7r+UF12WjwI1odVUA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-transform/binding-linux-arm-gnueabihf@0.121.0': + resolution: {integrity: sha512-cNkTaw77UaNiGOCIv2R1kHZ3OkTVlr/059agLCUaeQmZGl76Ad7DrDcDyhC0Iugw0jEdWZ9zeUS5VLmzblnTXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-transform/binding-linux-arm-musleabihf@0.121.0': + resolution: {integrity: sha512-eDwTIN0UUCQePgFR41doxorzsxoMoUTbXo6bEbvdFH7P4ZoaUXgHYN10Qjd9K6k0x/bBnU6oC4YPSWYKvQDr9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-transform/binding-linux-arm64-gnu@0.121.0': + resolution: {integrity: sha512-UthSp+L23xeV0lIVloiRDU1d3aOvq0KRif3s6vszeSGnWf69+EVcZcondqLuX9optUhKV0/L8xwe2wLr9WkaDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-arm64-musl@0.121.0': + resolution: {integrity: sha512-J5vKUF8Jml1m9Fl48fKp2/wPl8LhGdjJWZ3PrrT+S16SbW7yEKixq5upzO2arhrky5elRYMXWwfi60ex1tBi6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-transform/binding-linux-ppc64-gnu@0.121.0': + resolution: {integrity: sha512-ya+/TL/YH/VcfWeRs95pMIgEj1eQgKg3kR/9AkQgSi8i9jIDEXrgrcQ8cwRYSZ3THlT6cxe3KGJa6vwcHG6JEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-riscv64-gnu@0.121.0': + resolution: {integrity: sha512-XhUBS/6bxL3maLMvkyY5jM23jFCORl+noYc7KkMydpb0Ot08XSu+8c2o7QpGVHWf85eTH/1Tx0aOTrcWek7EAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-riscv64-musl@0.121.0': + resolution: {integrity: sha512-kAcZZrU2Wxopcpt38D1u5OeLUwV78EXyOu3VfFNkP/vrMiKB4Tbca8ZxBq+XTkpijuKE4DdCQaLZylsFj7L00w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-transform/binding-linux-s390x-gnu@0.121.0': + resolution: {integrity: sha512-jHyHS+NwPAlUEuY6BzFBDoT4LfSBEW/Ne2FeMzdK8LXOvgHFrJiBf6x8FgekatrTGrDpy1hLiACNnPA81Hs2pQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-x64-gnu@0.121.0': + resolution: {integrity: sha512-KedV2jkFxeMvUqfh6SgXjCnO5SBZ+SorTUxSBeql7zp59ONZgAcehWAqDX+YWsK8wEpt23Q8ydC/0d6ebJIAzQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-x64-musl@0.121.0': + resolution: {integrity: sha512-jFAZwvgjsswiHET2xxxNvxhKCI74yVmewl0F00i3vzt9C088ZVaUvvWlqDS1GRvD4ORBmpJWOYkHdscpIJijEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-transform/binding-openharmony-arm64@0.121.0': + resolution: {integrity: sha512-xn9nxaq31f19PUyGh1xKMOSs8MVPImeaESWNOHtAIznckE+qa5/oHtYALzF3z8uvy1EC/eZODWcHrsYOVNaWug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-transform/binding-wasm32-wasi@0.121.0': + resolution: {integrity: sha512-7lj6FBMX8zLfTqIY4YHHTE/b6oyCzZaUwqi2n9KX4FkgjtBpfmq5KSUgi/I+YiE7JJHu1g8Bd3uWJq1lbehL8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-transform/binding-win32-arm64-msvc@0.121.0': + resolution: {integrity: sha512-+ve3UajNq2ldcCEEmpMVn7Ic3v/qCykPTSx3lZfe0iCW6tisIWvkYiXpf6B5dvwSY7SDyrdt9EyPMS75b41iPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-transform/binding-win32-ia32-msvc@0.121.0': + resolution: {integrity: sha512-9ZUHa4bXWlPRLzbjYsU3VBSvqwSVHAknQlN+nUO1DVu6j958Ui9ux0I9pZHwxb07I26VMdDhd7AjJyz1ZtZlkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-transform/binding-win32-x64-msvc@0.121.0': + resolution: {integrity: sha512-vV/rzJsmJeeXI1q/xuy93PnoL/IYMwCCyYMX9MmIgMx2a4Lu3vIjUNBLJx1R5CqP/NnvAelsuz05sKlO017FmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxfmt/binding-android-arm-eabi@0.59.0': resolution: {integrity: sha512-bNTnfbuG7sAwb2PakMNaDukx5kXeW9duXOBeWtTOiLz3fXz3q2DlWguufPZ+c2IHEVrRXHD+M4aUgEWm841LDA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7977,6 +8291,170 @@ packages: peerDependencies: '@redis/client': ^1.0.0 + '@remix-run/assert@0.3.0': + resolution: {integrity: sha512-GBA9klvJdG5YSTwRNur54LQcfqDOtir+x1TDbv3dzB9FS2Li9CEiRLp1EpBcaYrj4ByO03WJCWFBiY8bTMy4vQ==} + + '@remix-run/assets@0.6.0': + resolution: {integrity: sha512-mIYfP8PCCn5thlEhUhYffb9XbNqNVSmsXfW9+Fsp7go5BW0XTJtugMW1K7oo8TbXEo/xHXSGg4hWPdrBmTnsDg==} + + '@remix-run/async-context-middleware@0.3.5': + resolution: {integrity: sha512-H8HbZyuX9u1FUQDvaR5Yib/QmALKhMe+40Jjis4+B7u+c23YKUwUB/0pjWRMOrXE8udZ1y848/B9z7stZHP1WA==} + + '@remix-run/auth-middleware@0.2.5': + resolution: {integrity: sha512-R3R+qtnnglotSiCGm60l2zLbPl9tU9E0dJwuXR7gWYcA4Pop5IqCHxXXurLPDDWaqiMF/FZIo0akwxFZ6vS9iA==} + + '@remix-run/auth@0.3.0': + resolution: {integrity: sha512-ZcQng5NtF7jyKx5hdR1necSXRbVG14OOQIJ4ghM/Zyfs5CgQ7kNA9sNTaXKRaPkIo57jFP6Dd9AKA6Z46lrKxg==} + + '@remix-run/cli@0.6.0': + resolution: {integrity: sha512-he8W0bAQkNOpUNIA06T8ayB7JVO987qyKbTx3QXFzr55GsnoU+OauUPIEILXsvyow25xltIoIwNtKvg+8iKn6Q==} + engines: {node: '>=24.3.0'} + + '@remix-run/compression-middleware@0.1.13': + resolution: {integrity: sha512-b7c+mFPIuT/eRaO2UEhH6YT4SXwhzh0PyA/EtE2H66KvjbWy/KsAY4gu1jtczprhF3AFYaE29m/mEKT8X4o67g==} + + '@remix-run/cookie@0.6.0': + resolution: {integrity: sha512-juCoNHiHbXLT0gXDAYg06eVaFSKNqMS/dud49ZQdCHfCpHk1PPFDAbWajSz90ZG5BWWMPSWDvod4o1SAqPnlFA==} + + '@remix-run/cop-middleware@0.1.8': + resolution: {integrity: sha512-0AOzbNsTyjxFmwpF0im1RGQCpaj6I2gyUTbai5/vjx2D2H/6LRg6t2eJXj715m1e1dDuyQwURZUJKGKFjVYAlw==} + + '@remix-run/cors-middleware@0.1.8': + resolution: {integrity: sha512-Vhgtvrbafl1ng8tGmoYKNZ0nxa6vDtNKR2QwLbrW/xwjprTcwkJPSP2IiyQI+75HTzG5zWgJ/Z6OMO+iKdBjuQ==} + + '@remix-run/csrf-middleware@0.1.8': + resolution: {integrity: sha512-7zMWNLjJQW5UwWC2eIwNxT2yFLXzriBD5tls1SEkXyLMlk/NVnqqzISbd0BbZuAX+a+p6RD2vyE4ldcpLdaj+w==} + + '@remix-run/data-schema@0.3.0': + resolution: {integrity: sha512-rjGaFJduzO3iMFOKwA5URpZDuGbUxgBwcX9myBglfqbax8dBlhRcxurydepq+xi+XBE+bPrT9V57Jur6p1igow==} + + '@remix-run/data-table-mysql@0.5.1': + resolution: {integrity: sha512-0KA8A4F38BQpTyA4iS7xiJhuUGZdGhcdftADsGuaN6YPSpu/1huK40VzDr9eYdxmC4n6Oo7g9taqrp6bREAL4A==} + peerDependencies: + mysql2: ^3.15.3 + peerDependenciesMeta: + mysql2: + optional: true + + '@remix-run/data-table-postgres@0.5.1': + resolution: {integrity: sha512-CsjfpEzgbHau5G3zx8cKdrXtWFraQxxDE3qolkZmRUPWw1Er8IEeURfMQGT22h+a7t5iKDH7YbRaqAAbggXI7g==} + peerDependencies: + pg: ^8.16.3 + peerDependenciesMeta: + pg: + optional: true + + '@remix-run/data-table-sqlite@0.6.1': + resolution: {integrity: sha512-CVwHZbsCDa9Sg//GR2orWrh0KL/nVa1e5CvWW/G33flX6sBwv+A5OGZJHe+ZFCTLT9EAfY06+8Cew8AgIB8xCw==} + + '@remix-run/data-table@0.5.0': + resolution: {integrity: sha512-ntc23qDYnSCA2iq1r2L5+u/9MH+5207mopekOM4NRsVDrMt0e14DVq37JhfVmzcZDCb8lx7iP+D+H8i9a9IiCw==} + + '@remix-run/fetch-proxy@0.8.5': + resolution: {integrity: sha512-NZmqaa/61xyWYkbERgIth3fPqkqOkmmlMYwmNFL0sG2RE29kH9wG18I6fCg/fu1W3qvSOrMSIS5hMk5ggqZzHg==} + + '@remix-run/fetch-router@0.21.0': + resolution: {integrity: sha512-dN3Lsd4dHAV0USTF3Dm04effXcdr7GMgXiqeozTFDfvVIx2DbRAl94bwF6UdAWurgTOK2Oyz4cs9uadW/ylWXA==} + + '@remix-run/file-storage-s3@0.1.4': + resolution: {integrity: sha512-n1Z97PpaDIE5tOiRBJTy864ieZtWO1laqdz4pvKbr9KdfUxLRaWmk5gj+UaH7nC+e0sB3wBK9QhjfyoFuGOY6A==} + + '@remix-run/file-storage@0.13.7': + resolution: {integrity: sha512-7tDgyzs1v0dQgfMt+lpbIzEVkrgrcKJLGbBeXrpJITuCeWoc0XgFfDTp6gghS3s8aWQTm5XHbgnldLoQCEU9Mw==} + + '@remix-run/form-data-middleware@0.3.5': + resolution: {integrity: sha512-gPb12PC46F0RbUUm2qjjqmRmVjzB589Wdp7WBA/Lhn6tDPskCYKmwCew5owIIhoEgBEAuXD5nm16gMuDaZ/DVA==} + + '@remix-run/form-data-parser@0.17.5': + resolution: {integrity: sha512-LPBZ6uazgMhtWnt17wKZ6N97aebVqLQMXcNIuLooKdFH2s8uIg7twlbTfEO6G6Jgl8jrpjLpPL5HZgN5cdTXOw==} + + '@remix-run/fs@0.4.6': + resolution: {integrity: sha512-xxvixX/WyufRr6YrjPLGXvyE5Lzb5G/Y4tygk6McJ6nr59f3hO9w3aVc/lvsVPD7liYfM1EyMnmd4qIByJq9bg==} + + '@remix-run/headers@0.21.1': + resolution: {integrity: sha512-DRhRveigepAQDUIi0MrOFhpcl3/KTv/fkpIhulv7Asv1pUwM8RpY2ENjdI8lfii6Z3MEsWtYhGt6If8bi6bYpQ==} + + '@remix-run/html-template@0.3.1': + resolution: {integrity: sha512-4yhaBtfh1iVC1razTr+ehu1xmKia0zvE4GWQ6JF2IRiA3kmw+DlrdNFEFqx5Wn1eXAM0vxxcJLdLD7e0XMzH5A==} + + '@remix-run/lazy-file@5.0.6': + resolution: {integrity: sha512-jIeeqkqR+AgTEKls5WMPbiGyXhrkAJugN4ot/K9X74CLzO9uMbcFDKef7CWCfjN1s+551u+5LxNWLJx1nztuVg==} + + '@remix-run/logger-middleware@0.3.5': + resolution: {integrity: sha512-UBIUjdhfD9kvYH3ZtMjXycyvEjhcCPAc3g4SytNVB9uKZzpX3SwrnYHmqNNYFONFsX9dI4P9cefdZEIk55U/QQ==} + + '@remix-run/method-override-middleware@0.1.13': + resolution: {integrity: sha512-Q3/oPl/kso7c/7TYNuC3IXXX/gnM0x0AGYhu4Brklzu71HZe28xBPCoiiDO47F+6uS2qvY9RhGQ6NGvTjZWzEw==} + + '@remix-run/mime@0.4.2': + resolution: {integrity: sha512-KpZZxBYIrLsvUtt2ZdwK+E2ClVmyvW11Nej6fPgwJpohBMpbCHtR78wtCTv0vqvK5ckLZ7ks2q4pV5dMYFypiA==} + + '@remix-run/multipart-parser@0.16.4': + resolution: {integrity: sha512-XrrfbuU4csgEpTmHv9rjrnFrGu/7iz+31PM51cFL2fcu/JZ7+wZhDyqaOca2uOE8qNhVgXuJHqEOl/8eFCYTHQ==} + + '@remix-run/node-fetch-server@0.14.1': + resolution: {integrity: sha512-pKenF5ysIN5lDCnCyvoUxDhKP0tfnGagvGbZh01xxHG6DJXSIXWVC9NpLSDGjxcxd1CKdCOKuZFleXBF3pChIg==} + + '@remix-run/node-hmr@0.1.0': + resolution: {integrity: sha512-LMONcqnqJ/kYfKxSfgG/dpTZZP7QmarUk47e+kd4861/q8G+SHZOYOC3vlWoiZgJdx/JwOkTnmvqovpQpjlXkA==} + + '@remix-run/node-tsx@0.1.1': + resolution: {integrity: sha512-k9Oi2gML1E7c1PLj+kX+Hsumkjg9tpps7+ufpy7ugsiRTe6fDfZOyEmlYOIkfR2K2Nf8D33+967EMlInm9Nvvg==} + engines: {node: '>=24.3.0'} + + '@remix-run/render-middleware@0.2.0': + resolution: {integrity: sha512-y1Hz9QRKr62aV3SUZDFp+QEdulsmURwuFh7DgS8z+1R3qquxbZl+cV00rL3Ly9qn6jUyrEZRQdPhYI8uf84MgA==} + + '@remix-run/response@0.3.8': + resolution: {integrity: sha512-ydrfv+ykKBRQg6DTHU0mSbxbOcWc3m1O7UVivO5Rgrp+K1u4Z3nqbhaMDkHbCPEFhuv1sKlQwS0Z5ClO/jGAiQ==} + + '@remix-run/route-pattern@0.24.0': + resolution: {integrity: sha512-w0/lhgY40l2GrBQjJIOeYWIuKm1rxlOsc9Hvt+nyydnka0v4aI9Vv0VK4XzLD3aP08JzGtTlxFxwf1PZ8SGdLg==} + + '@remix-run/session-middleware@0.4.0': + resolution: {integrity: sha512-v563WyWG8GABVF0T1tX+xPDEmHswbbEqMh54GfxwcfLMWr+wJl16kEzZb+q0dPmvjZjwDHBtUIj8b1rOFglFHQ==} + + '@remix-run/session-storage-memcache@0.1.2': + resolution: {integrity: sha512-VIJnz+DcJFo+vM1SRKy/8kbJHefVQtGyKS+V1AOMCk8OAMGCCyS+f4ERxmu3as48T/L55oBPsTxQX+rcPchDtQ==} + + '@remix-run/session-storage-redis@0.1.1': + resolution: {integrity: sha512-y/dhJq5hs4rs1zCCPbPJp+i0/EBcYkwQLnwQ4mYtJbSjYIUklSqD3Z7+K8V/MJtl7B4fq8RD1m20lLjLSESMLw==} + peerDependencies: + redis: ^5.10.0 + peerDependenciesMeta: + redis: + optional: true + + '@remix-run/session@0.4.2': + resolution: {integrity: sha512-NGzu6/gC5xD/tq40W0WqzTt4JhCdSCIwDHM1aa13JBqb4Ml/Mb0kmXqjfOe/gBYfu6AA11nRQ/BJyr+VduTodA==} + + '@remix-run/spa@0.1.0': + resolution: {integrity: sha512-WJudc3XjY++LQbD18xHqXVWP5140AqSrcZYXU4dbxbNtz7+yEhG9c+XMDS6vuNA927s81NNJ0Ijx9cbWfqNoZg==} + + '@remix-run/static-middleware@0.4.14': + resolution: {integrity: sha512-kfz94iBjwFTeORQjs/V/9Pp4pAfJAz9EGlDB7Sh9VvAbG8wggsPs84pFgIIm5xy6Os7ey9OEeQXeLSm70TIzQg==} + + '@remix-run/tar-parser@0.7.1': + resolution: {integrity: sha512-NZKTuA66rj0zqpljWAb6v147cNu5BtRCiv8FY5kn64ZPvLmoI62Ehm2hoUh0g0wJHeCNmgS5QZg1xhw6FX67SA==} + + '@remix-run/terminal@0.1.1': + resolution: {integrity: sha512-M8JNcsYp/mWZ0yOB12Ir+Ud1eLxodPr8YSKQcG2PTPeiq9p9c59D9gvF9m6EU2Hmd4esJMiTyy6lJHc/VKKvcw==} + + '@remix-run/test@0.6.0': + resolution: {integrity: sha512-1m35Okayxu0cdBGpyPucLdDvCgiAe4Unf1J6iigg2GAqwBhqy7K3+KKL08QNhwnBnSQBGca8wCcTp+6UJUa3jg==} + engines: {node: '>=24.3.0'} + peerDependencies: + playwright: ^1.60.0 + peerDependenciesMeta: + playwright: + optional: true + + '@remix-run/ui-hmr@0.1.0': + resolution: {integrity: sha512-9hV0iNWBSjCmrA6b9/8uG7qWSHPXyy5XuIzfMayIH661YfmKuBFchkOIiTenadaI4g7ZxCBT+igh/HtRvyxS4Q==} + + '@remix-run/ui@0.8.0': + resolution: {integrity: sha512-4OdZ3oj0zbyVVaCFT/x2vjK5YxpD63KpLONkLZ2QVKIEBcLjJUGSenp2f08BOzC8oythLHCJgzHLKKulYJpIQA==} + '@rolldown/binding-android-arm64@1.0.0-beta.53': resolution: {integrity: sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -9746,6 +10224,9 @@ packages: '@types/dom-mediacapture-record@1.0.22': resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==} + '@types/dom-navigation@1.0.7': + resolution: {integrity: sha512-Di4W+i2faYquHUnyWUg3bBQp5pTNvjDDA7mIYfD/1WlLgan6sKkeVjGbdL78K0CuNEk5Pfc/c0rfelwkz10mnQ==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -14389,6 +14870,10 @@ packages: outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + oxc-minify@0.121.0: + resolution: {integrity: sha512-XziD0au8etayM2zJnqcSiW+Pn3hEpqHsbwfL7G4Ej0SwqfvbIjiEF1/uNqONuHl0n9LkLI1ez378vSWZRJZWAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + oxc-parser@0.121.0: resolution: {integrity: sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -14400,6 +14885,10 @@ packages: oxc-resolver@11.21.3: resolution: {integrity: sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA==} + oxc-transform@0.121.0: + resolution: {integrity: sha512-Kf243wJU/vWF/ThV+ZyfLMQIrViVFRSyYO7UPKpZMMPGGMzxxcHgsNGWy0Uy+pcXD78+jdUnxVTR9rYT73Qw3A==} + engines: {node: ^20.19.0 || >=22.12.0} + oxfmt@0.59.0: resolution: {integrity: sha512-Xqk6cPZS1yMvVa7OAuenaDZUsgMDutvvbZ9/L5gSvAfW64+WN4HVhgipLj5rVERbYQt8fLs9TopyZ1rU1XEG/w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -15137,6 +15626,25 @@ packages: remeda@2.33.4: resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + remix@3.0.0-rc.1: + resolution: {integrity: sha512-GnVF3Zecw/SdkpklTaQOKeVvziyXnSIuiqPPq1MFeMaoBeYS5PXXIaxnQcmbDuuMyy1R4cmwi+mJGVoCP78x9w==} + engines: {node: '>=24.3.0'} + hasBin: true + peerDependencies: + mysql2: ^3.15.3 + pg: ^8.16.3 + playwright: ^1.60.0 + redis: ^5.10.0 + peerDependenciesMeta: + mysql2: + optional: true + pg: + optional: true + playwright: + optional: true + redis: + optional: true + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -16562,6 +17070,10 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + valibot@1.2.0: resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} peerDependencies: @@ -21000,6 +21512,71 @@ snapshots: '@opentelemetry/semantic-conventions@1.41.1': {} + '@oxc-minify/binding-android-arm-eabi@0.121.0': + optional: true + + '@oxc-minify/binding-android-arm64@0.121.0': + optional: true + + '@oxc-minify/binding-darwin-arm64@0.121.0': + optional: true + + '@oxc-minify/binding-darwin-x64@0.121.0': + optional: true + + '@oxc-minify/binding-freebsd-x64@0.121.0': + optional: true + + '@oxc-minify/binding-linux-arm-gnueabihf@0.121.0': + optional: true + + '@oxc-minify/binding-linux-arm-musleabihf@0.121.0': + optional: true + + '@oxc-minify/binding-linux-arm64-gnu@0.121.0': + optional: true + + '@oxc-minify/binding-linux-arm64-musl@0.121.0': + optional: true + + '@oxc-minify/binding-linux-ppc64-gnu@0.121.0': + optional: true + + '@oxc-minify/binding-linux-riscv64-gnu@0.121.0': + optional: true + + '@oxc-minify/binding-linux-riscv64-musl@0.121.0': + optional: true + + '@oxc-minify/binding-linux-s390x-gnu@0.121.0': + optional: true + + '@oxc-minify/binding-linux-x64-gnu@0.121.0': + optional: true + + '@oxc-minify/binding-linux-x64-musl@0.121.0': + optional: true + + '@oxc-minify/binding-openharmony-arm64@0.121.0': + optional: true + + '@oxc-minify/binding-wasm32-wasi@0.121.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-minify/binding-win32-arm64-msvc@0.121.0': + optional: true + + '@oxc-minify/binding-win32-ia32-msvc@0.121.0': + optional: true + + '@oxc-minify/binding-win32-x64-msvc@0.121.0': + optional: true + '@oxc-parser/binding-android-arm-eabi@0.121.0': optional: true @@ -21129,6 +21706,8 @@ snapshots: '@oxc-parser/binding-win32-x64-msvc@0.137.0': optional: true + '@oxc-project/runtime@0.121.0': {} + '@oxc-project/types@0.101.0': {} '@oxc-project/types@0.113.0': {} @@ -21202,6 +21781,71 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.21.3': optional: true + '@oxc-transform/binding-android-arm-eabi@0.121.0': + optional: true + + '@oxc-transform/binding-android-arm64@0.121.0': + optional: true + + '@oxc-transform/binding-darwin-arm64@0.121.0': + optional: true + + '@oxc-transform/binding-darwin-x64@0.121.0': + optional: true + + '@oxc-transform/binding-freebsd-x64@0.121.0': + optional: true + + '@oxc-transform/binding-linux-arm-gnueabihf@0.121.0': + optional: true + + '@oxc-transform/binding-linux-arm-musleabihf@0.121.0': + optional: true + + '@oxc-transform/binding-linux-arm64-gnu@0.121.0': + optional: true + + '@oxc-transform/binding-linux-arm64-musl@0.121.0': + optional: true + + '@oxc-transform/binding-linux-ppc64-gnu@0.121.0': + optional: true + + '@oxc-transform/binding-linux-riscv64-gnu@0.121.0': + optional: true + + '@oxc-transform/binding-linux-riscv64-musl@0.121.0': + optional: true + + '@oxc-transform/binding-linux-s390x-gnu@0.121.0': + optional: true + + '@oxc-transform/binding-linux-x64-gnu@0.121.0': + optional: true + + '@oxc-transform/binding-linux-x64-musl@0.121.0': + optional: true + + '@oxc-transform/binding-openharmony-arm64@0.121.0': + optional: true + + '@oxc-transform/binding-wasm32-wasi@0.121.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-transform/binding-win32-arm64-msvc@0.121.0': + optional: true + + '@oxc-transform/binding-win32-ia32-msvc@0.121.0': + optional: true + + '@oxc-transform/binding-win32-x64-msvc@0.121.0': + optional: true + '@oxfmt/binding-android-arm-eabi@0.59.0': optional: true @@ -23226,6 +23870,266 @@ snapshots: dependencies: '@redis/client': 1.6.1 + '@remix-run/assert@0.3.0': {} + + '@remix-run/assets@0.6.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@oxc-project/runtime': 0.121.0 + '@remix-run/file-storage': 0.13.7 + '@remix-run/headers': 0.21.1 + '@remix-run/mime': 0.4.2 + chokidar: 5.0.0 + es-module-lexer: 2.0.0 + get-tsconfig: 4.14.0 + lightningcss: 1.33.0 + magic-string: 0.30.21 + oxc-minify: 0.121.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + oxc-parser: 0.121.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + oxc-resolver: 11.21.3 + oxc-transform: 0.121.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + picomatch: 4.0.5 + source-map-js: 1.2.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@remix-run/async-context-middleware@0.3.5': + dependencies: + '@remix-run/fetch-router': 0.21.0 + + '@remix-run/auth-middleware@0.2.5': + dependencies: + '@remix-run/fetch-router': 0.21.0 + '@remix-run/session': 0.4.2 + + '@remix-run/auth@0.3.0': + dependencies: + '@remix-run/fetch-router': 0.21.0 + '@remix-run/session': 0.4.2 + + '@remix-run/cli@0.6.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(mysql2@3.15.3)': + dependencies: + '@remix-run/assets': 0.6.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@remix-run/data-table': 0.5.0 + '@remix-run/data-table-mysql': 0.5.1(mysql2@3.15.3) + '@remix-run/data-table-postgres': 0.5.1 + '@remix-run/data-table-sqlite': 0.6.1 + '@remix-run/terminal': 0.1.1 + '@remix-run/test': 0.6.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + jsonc-parser: 3.3.1 + semver: 7.8.4 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - mysql2 + - pg + - playwright + + '@remix-run/compression-middleware@0.1.13': + dependencies: + '@remix-run/fetch-router': 0.21.0 + '@remix-run/mime': 0.4.2 + '@remix-run/response': 0.3.8 + + '@remix-run/cookie@0.6.0': + dependencies: + '@remix-run/headers': 0.21.1 + + '@remix-run/cop-middleware@0.1.8': + dependencies: + '@remix-run/fetch-router': 0.21.0 + + '@remix-run/cors-middleware@0.1.8': + dependencies: + '@remix-run/fetch-router': 0.21.0 + '@remix-run/headers': 0.21.1 + + '@remix-run/csrf-middleware@0.1.8': + dependencies: + '@remix-run/fetch-router': 0.21.0 + '@remix-run/session': 0.4.2 + + '@remix-run/data-schema@0.3.0': + dependencies: + '@standard-schema/spec': 1.1.0 + + '@remix-run/data-table-mysql@0.5.1(mysql2@3.15.3)': + dependencies: + '@remix-run/data-table': 0.5.0 + optionalDependencies: + mysql2: 3.15.3 + + '@remix-run/data-table-postgres@0.5.1': + dependencies: + '@remix-run/data-table': 0.5.0 + + '@remix-run/data-table-sqlite@0.6.1': + dependencies: + '@remix-run/data-table': 0.5.0 + + '@remix-run/data-table@0.5.0': {} + + '@remix-run/fetch-proxy@0.8.5': + dependencies: + '@remix-run/headers': 0.21.1 + + '@remix-run/fetch-router@0.21.0': + dependencies: + '@remix-run/route-pattern': 0.24.0 + + '@remix-run/file-storage-s3@0.1.4': + dependencies: + '@remix-run/file-storage': 0.13.7 + aws4fetch: 1.0.20 + + '@remix-run/file-storage@0.13.7': + dependencies: + '@remix-run/fs': 0.4.6 + '@remix-run/lazy-file': 5.0.6 + + '@remix-run/form-data-middleware@0.3.5': + dependencies: + '@remix-run/fetch-router': 0.21.0 + '@remix-run/form-data-parser': 0.17.5 + + '@remix-run/form-data-parser@0.17.5': + dependencies: + '@remix-run/multipart-parser': 0.16.4 + + '@remix-run/fs@0.4.6': + dependencies: + '@remix-run/lazy-file': 5.0.6 + '@remix-run/mime': 0.4.2 + + '@remix-run/headers@0.21.1': {} + + '@remix-run/html-template@0.3.1': {} + + '@remix-run/lazy-file@5.0.6': + dependencies: + '@remix-run/mime': 0.4.2 + + '@remix-run/logger-middleware@0.3.5': + dependencies: + '@remix-run/fetch-router': 0.21.0 + '@remix-run/terminal': 0.1.1 + + '@remix-run/method-override-middleware@0.1.13': + dependencies: + '@remix-run/fetch-router': 0.21.0 + + '@remix-run/mime@0.4.2': {} + + '@remix-run/multipart-parser@0.16.4': + dependencies: + '@remix-run/headers': 0.21.1 + + '@remix-run/node-fetch-server@0.14.1': {} + + '@remix-run/node-hmr@0.1.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@remix-run/terminal': 0.1.1 + chokidar: 5.0.0 + oxc-parser: 0.121.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + source-map-js: 1.2.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@remix-run/node-tsx@0.1.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + get-tsconfig: 4.14.0 + oxc-transform: 0.121.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@remix-run/render-middleware@0.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@remix-run/assets': 0.6.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@remix-run/fetch-router': 0.21.0 + '@remix-run/response': 0.3.8 + '@remix-run/ui': 0.8.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@remix-run/response@0.3.8': + dependencies: + '@remix-run/headers': 0.21.1 + '@remix-run/html-template': 0.3.1 + '@remix-run/mime': 0.4.2 + + '@remix-run/route-pattern@0.24.0': {} + + '@remix-run/session-middleware@0.4.0': + dependencies: + '@remix-run/cookie': 0.6.0 + '@remix-run/fetch-router': 0.21.0 + '@remix-run/session': 0.4.2 + + '@remix-run/session-storage-memcache@0.1.2': + dependencies: + '@remix-run/session': 0.4.2 + + '@remix-run/session-storage-redis@0.1.1': + dependencies: + '@remix-run/session': 0.4.2 + + '@remix-run/session@0.4.2': {} + + '@remix-run/spa@0.1.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@remix-run/fetch-router': 0.21.0 + '@remix-run/render-middleware': 0.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@remix-run/ui': 0.8.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@remix-run/static-middleware@0.4.14': + dependencies: + '@remix-run/fetch-router': 0.21.0 + '@remix-run/fs': 0.4.6 + '@remix-run/html-template': 0.3.1 + '@remix-run/mime': 0.4.2 + '@remix-run/response': 0.3.8 + + '@remix-run/tar-parser@0.7.1': {} + + '@remix-run/terminal@0.1.1': {} + + '@remix-run/test@0.6.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@remix-run/node-tsx': 0.1.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@remix-run/terminal': 0.1.1 + es-module-lexer: 2.0.0 + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + oxc-resolver: 11.21.3 + source-map-js: 1.2.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@remix-run/ui-hmr@0.1.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + magic-string: 0.30.21 + oxc-parser: 0.121.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + source-map-js: 1.2.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@remix-run/ui@0.8.0': + dependencies: + '@types/dom-navigation': 1.0.7 + '@rolldown/binding-android-arm64@1.0.0-beta.53': optional: true @@ -25486,6 +26390,8 @@ snapshots: '@types/dom-mediacapture-record@1.0.22': {} + '@types/dom-navigation@1.0.7': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.9 @@ -31543,6 +32449,32 @@ snapshots: outdent@0.5.0: {} + oxc-minify@0.121.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + optionalDependencies: + '@oxc-minify/binding-android-arm-eabi': 0.121.0 + '@oxc-minify/binding-android-arm64': 0.121.0 + '@oxc-minify/binding-darwin-arm64': 0.121.0 + '@oxc-minify/binding-darwin-x64': 0.121.0 + '@oxc-minify/binding-freebsd-x64': 0.121.0 + '@oxc-minify/binding-linux-arm-gnueabihf': 0.121.0 + '@oxc-minify/binding-linux-arm-musleabihf': 0.121.0 + '@oxc-minify/binding-linux-arm64-gnu': 0.121.0 + '@oxc-minify/binding-linux-arm64-musl': 0.121.0 + '@oxc-minify/binding-linux-ppc64-gnu': 0.121.0 + '@oxc-minify/binding-linux-riscv64-gnu': 0.121.0 + '@oxc-minify/binding-linux-riscv64-musl': 0.121.0 + '@oxc-minify/binding-linux-s390x-gnu': 0.121.0 + '@oxc-minify/binding-linux-x64-gnu': 0.121.0 + '@oxc-minify/binding-linux-x64-musl': 0.121.0 + '@oxc-minify/binding-openharmony-arm64': 0.121.0 + '@oxc-minify/binding-wasm32-wasi': 0.121.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@oxc-minify/binding-win32-arm64-msvc': 0.121.0 + '@oxc-minify/binding-win32-ia32-msvc': 0.121.0 + '@oxc-minify/binding-win32-x64-msvc': 0.121.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + oxc-parser@0.121.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): dependencies: '@oxc-project/types': 0.121.0 @@ -31618,6 +32550,32 @@ snapshots: '@oxc-resolver/binding-win32-arm64-msvc': 11.21.3 '@oxc-resolver/binding-win32-x64-msvc': 11.21.3 + oxc-transform@0.121.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + optionalDependencies: + '@oxc-transform/binding-android-arm-eabi': 0.121.0 + '@oxc-transform/binding-android-arm64': 0.121.0 + '@oxc-transform/binding-darwin-arm64': 0.121.0 + '@oxc-transform/binding-darwin-x64': 0.121.0 + '@oxc-transform/binding-freebsd-x64': 0.121.0 + '@oxc-transform/binding-linux-arm-gnueabihf': 0.121.0 + '@oxc-transform/binding-linux-arm-musleabihf': 0.121.0 + '@oxc-transform/binding-linux-arm64-gnu': 0.121.0 + '@oxc-transform/binding-linux-arm64-musl': 0.121.0 + '@oxc-transform/binding-linux-ppc64-gnu': 0.121.0 + '@oxc-transform/binding-linux-riscv64-gnu': 0.121.0 + '@oxc-transform/binding-linux-riscv64-musl': 0.121.0 + '@oxc-transform/binding-linux-s390x-gnu': 0.121.0 + '@oxc-transform/binding-linux-x64-gnu': 0.121.0 + '@oxc-transform/binding-linux-x64-musl': 0.121.0 + '@oxc-transform/binding-openharmony-arm64': 0.121.0 + '@oxc-transform/binding-wasm32-wasi': 0.121.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@oxc-transform/binding-win32-arm64-msvc': 0.121.0 + '@oxc-transform/binding-win32-ia32-msvc': 0.121.0 + '@oxc-transform/binding-win32-x64-msvc': 0.121.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + oxfmt@0.59.0(svelte@5.56.9(@typescript-eslint/types@8.59.4)): dependencies: tinypool: 2.1.0 @@ -32762,6 +33720,61 @@ snapshots: remeda@2.33.4: optional: true + remix@3.0.0-rc.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(mysql2@3.15.3): + dependencies: + '@remix-run/assert': 0.3.0 + '@remix-run/assets': 0.6.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@remix-run/async-context-middleware': 0.3.5 + '@remix-run/auth': 0.3.0 + '@remix-run/auth-middleware': 0.2.5 + '@remix-run/cli': 0.6.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(mysql2@3.15.3) + '@remix-run/compression-middleware': 0.1.13 + '@remix-run/cookie': 0.6.0 + '@remix-run/cop-middleware': 0.1.8 + '@remix-run/cors-middleware': 0.1.8 + '@remix-run/csrf-middleware': 0.1.8 + '@remix-run/data-schema': 0.3.0 + '@remix-run/data-table': 0.5.0 + '@remix-run/data-table-mysql': 0.5.1(mysql2@3.15.3) + '@remix-run/data-table-postgres': 0.5.1 + '@remix-run/data-table-sqlite': 0.6.1 + '@remix-run/fetch-proxy': 0.8.5 + '@remix-run/fetch-router': 0.21.0 + '@remix-run/file-storage': 0.13.7 + '@remix-run/file-storage-s3': 0.1.4 + '@remix-run/form-data-middleware': 0.3.5 + '@remix-run/form-data-parser': 0.17.5 + '@remix-run/fs': 0.4.6 + '@remix-run/headers': 0.21.1 + '@remix-run/html-template': 0.3.1 + '@remix-run/lazy-file': 5.0.6 + '@remix-run/logger-middleware': 0.3.5 + '@remix-run/method-override-middleware': 0.1.13 + '@remix-run/mime': 0.4.2 + '@remix-run/multipart-parser': 0.16.4 + '@remix-run/node-fetch-server': 0.14.1 + '@remix-run/node-hmr': 0.1.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@remix-run/node-tsx': 0.1.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@remix-run/render-middleware': 0.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@remix-run/response': 0.3.8 + '@remix-run/route-pattern': 0.24.0 + '@remix-run/session': 0.4.2 + '@remix-run/session-middleware': 0.4.0 + '@remix-run/session-storage-memcache': 0.1.2 + '@remix-run/session-storage-redis': 0.1.1 + '@remix-run/spa': 0.1.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@remix-run/static-middleware': 0.4.14 + '@remix-run/tar-parser': 0.7.1 + '@remix-run/terminal': 0.1.1 + '@remix-run/test': 0.6.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@remix-run/ui': 0.8.0 + '@remix-run/ui-hmr': 0.1.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optionalDependencies: + mysql2: 3.15.3 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -34358,6 +35371,12 @@ snapshots: uuid@7.0.3: {} + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + valibot@1.2.0(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8e292988d4..f0023819b5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -20,6 +20,56 @@ minimumReleaseAgeExclude: # inside the 24h window. - 'octane@0.1.17' - '@octanejs/testing-library@0.1.14' + # Remix 3 RC and its @remix-run/* subpackages. Peer of @tanstack/ai-remix. + # Fresh RCs fail the 24h age gate, including nested @remix-run packages. + - 'remix' + - '@remix-run/assert' + - '@remix-run/assets' + - '@remix-run/async-context-middleware' + - '@remix-run/auth' + - '@remix-run/auth-middleware' + - '@remix-run/cli' + - '@remix-run/compression-middleware' + - '@remix-run/cookie' + - '@remix-run/cop-middleware' + - '@remix-run/cors-middleware' + - '@remix-run/csrf-middleware' + - '@remix-run/data-schema' + - '@remix-run/data-table' + - '@remix-run/data-table-mysql' + - '@remix-run/data-table-postgres' + - '@remix-run/data-table-sqlite' + - '@remix-run/fetch-proxy' + - '@remix-run/fetch-router' + - '@remix-run/file-storage' + - '@remix-run/file-storage-s3' + - '@remix-run/form-data-middleware' + - '@remix-run/form-data-parser' + - '@remix-run/fs' + - '@remix-run/headers' + - '@remix-run/html-template' + - '@remix-run/lazy-file' + - '@remix-run/logger-middleware' + - '@remix-run/method-override-middleware' + - '@remix-run/mime' + - '@remix-run/multipart-parser' + - '@remix-run/node-fetch-server' + - '@remix-run/node-hmr' + - '@remix-run/node-tsx' + - '@remix-run/render-middleware' + - '@remix-run/response' + - '@remix-run/route-pattern' + - '@remix-run/session' + - '@remix-run/session-middleware' + - '@remix-run/session-storage-memcache' + - '@remix-run/session-storage-redis' + - '@remix-run/spa' + - '@remix-run/static-middleware' + - '@remix-run/tar-parser' + - '@remix-run/terminal' + - '@remix-run/test' + - '@remix-run/ui' + - '@remix-run/ui-hmr' trustPolicyExclude: - 'chokidar@4.0.3' # existing transitive from sass/nitro; latest v4 with v5 available diff --git a/tsconfig.docs.json b/tsconfig.docs.json new file mode 100644 index 0000000000..29ffdec37a --- /dev/null +++ b/tsconfig.docs.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "remix/ui": [ + "./node_modules/.kiira/node_modules/remix/dist/ui.d.ts", + "./packages/ai-remix/node_modules/remix/dist/ui.d.ts" + ], + "remix/router": [ + "./node_modules/.kiira/node_modules/remix/dist/fetch-router.d.ts", + "./packages/ai-remix/node_modules/remix/dist/fetch-router.d.ts" + ], + "remix/routes": [ + "./node_modules/.kiira/node_modules/remix/dist/fetch-router/routes.d.ts", + "./packages/ai-remix/node_modules/remix/dist/fetch-router/routes.d.ts" + ], + "@remix-run/ui/jsx-runtime": [ + "./node_modules/.kiira/node_modules/@remix-run/ui/dist/jsx-runtime.d.ts" + ] + } + } +}