-
} />
+
+
+ )
+}
+
+let stream = renderToStream(
, {
+ resolveFrame: (src) => fetchFrameHtml(src),
+})
+
+return new Response(stream, {
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+})
+```
+
+### Client entry module
+
+```tsx
+// assets/entry.tsx
+import { run } from 'remix/component'
+
+let app = run({
+ async loadModule(moduleUrl, exportName) {
+ let mod = await import(moduleUrl)
+ return mod[exportName]
+ },
+ async resolveFrame(src, signal) {
+ let res = await fetch(src, { headers: { accept: 'text/html' }, signal })
+ return res.body ?? (await res.text())
+ },
+})
+
+await app.ready()
+```
+
+### Client entry component
+
+```tsx
+// assets/counter.tsx
+import { clientEntry, on, type Handle } from 'remix/component'
+
+export let Counter = clientEntry(
+ '/assets/counter.js#Counter',
+ function Counter(handle: Handle, setup: number) {
+ let count = setup
+
+ return (props: { label: string }) => (
+
+
+ {props.label}: {count}
+
+
+
+ )
+ },
+)
+```
+
+## Next Steps
+
+- [Components](./components.md) - Component structure and runtime behavior
+- [Handle API](./handle.md) - The component's interface to the framework
+- [Server Rendering](./server-rendering.md) - `renderToString` and
+ `renderToStream`
+- [Hydration](./hydration.md) - `clientEntry` and `run`
+- [Frames](./frames.md) - Streaming partial server UI with `
`
+- [Styling](./styling.md) - CSS mixin for inline styling
+- [Events](./events.md) - Event handling patterns
diff --git a/docs/agents/remix/component/docs/handle.md b/docs/agents/remix/component/docs/handle.md
new file mode 100644
index 0000000..6fec17d
--- /dev/null
+++ b/docs/agents/remix/component/docs/handle.md
@@ -0,0 +1,343 @@
+# Handle API
+
+The `Handle` object provides the component's interface to the framework.
+
+## `handle.update()`
+
+Schedules a component update and returns a promise that resolves with an
+`AbortSignal` after the update completes.
+
+```tsx
+function Counter(handle: Handle) {
+ let count = 0
+
+ return () => (
+
+ )
+}
+```
+
+Waiting for the update:
+
+```tsx
+function Player(handle: Handle) {
+ let isPlaying = false
+ let stopButton: HTMLButtonElement
+
+ return () => (
+
+ )
+}
+```
+
+## `handle.queueTask(task)`
+
+Schedules a task to run after the next update. The task receives an
+`AbortSignal` that's aborted when:
+
+- The component re-renders (new render cycle starts)
+- The component is removed from the tree
+
+**Use `queueTask` in event handlers when work needs to happen after DOM
+changes:**
+
+```tsx
+function Form(handle: Handle) {
+ let showDetails = false
+ let detailsSection: HTMLElement
+
+ return () => (
+
+ )
+}
+```
+
+**Use `queueTask` for work that needs to be reactive to prop changes:**
+
+When you need to perform async work (like data fetching) that should respond to
+prop changes, use `queueTask` in the render function. The signal will be aborted
+if props change or the component is removed, ensuring only the latest work
+completes.
+
+### Anti-patterns
+
+**Don't create states as values to "react to" on the next render with
+`queueTask`:**
+
+```tsx
+// ❌ Avoid: Creating state just to react to it in queueTask
+function BadExample(handle: Handle) {
+ let shouldLoad = false // Unnecessary state
+
+ return () => (
+
+
+
+ )
+}
+
+// ✅ Prefer: Do the work directly in the event handler or queueTask
+function GoodExample(handle: Handle) {
+ return () => (
+
+
+
+ )
+}
+```
+
+**When showing loading state before async work, await `handle.update()` and use
+the returned signal:**
+
+```tsx
+function AsyncExample(handle: Handle) {
+ let data: string[] = []
+ let loading = false
+
+ async function load() {
+ loading = true
+ let signal = await handle.update()
+
+ let response = await fetch('/api/data', { signal })
+ if (signal.aborted) return
+
+ data = await response.json()
+ loading = false
+ handle.update()
+ }
+
+ return () => (
+
+ )
+}
+```
+
+## `handle.signal`
+
+An `AbortSignal` that's aborted when the component is disconnected. Useful for
+cleanup operations.
+
+```tsx
+function Clock(handle: Handle) {
+ let interval = setInterval(() => {
+ if (handle.signal.aborted) {
+ clearInterval(interval)
+ return
+ }
+ handle.update()
+ }, 1000)
+
+ return () =>
{new Date().toString()}
+}
+```
+
+Or using event listeners:
+
+```tsx
+function Clock(handle: Handle) {
+ let interval = setInterval(handle.update, 1000)
+ handle.signal.addEventListener('abort', () => clearInterval(interval))
+
+ return () =>
{new Date().toString()}
+}
+```
+
+## `addEventListeners(target, handle.signal, listeners)`
+
+Listen to an `EventTarget` with automatic cleanup when the component
+disconnects. Ideal for global event targets like `document` and `window`.
+
+```tsx
+function KeyboardTracker(handle: Handle) {
+ let keys: string[] = []
+
+ addEventListeners(document, handle.signal, {
+ keydown(event) {
+ keys.push(event.key)
+ handle.update()
+ },
+ })
+
+ return () =>
Keys: {keys.join(', ')}
+}
+```
+
+## `handle.frames.top`
+
+The root frame for the current runtime tree. This is useful when nested
+components need to reload the entire page/frame tree instead of only their
+nearest frame.
+
+When server rendering with `renderToStream()`, pass the `frameSrc` option to
+populate `handle.frames.top.src` during SSR. For nested frame renders, also pass
+`topFrameSrc` to keep the top-frame URL fixed while `handle.frame.src` changes
+per frame.
+
+```tsx
+function RefreshAllButton(handle: Handle) {
+ return () => (
+
+ )
+}
+```
+
+## `handle.frames.get(name)`
+
+Look up a named frame in the current runtime tree. This is useful when one frame
+action should refresh adjacent frame content.
+
+Return value:
+
+- `FrameHandle` when a frame with that `name` is currently mounted
+- `undefined` when no such frame is mounted
+
+```tsx
+function CartRow(handle: Handle) {
+ return () => (
+
+ )
+}
+```
+
+If multiple mounted frames share the same name, the most recently mounted frame
+is returned.
+
+## `handle.id`
+
+Stable identifier per component instance. Useful for HTML APIs like `htmlFor`,
+`aria-owns`, etc.
+
+```tsx
+function LabeledInput(handle: Handle) {
+ return () => (
+
+
+
+
+ )
+}
+```
+
+## `handle.context`
+
+Context API for ancestor/descendant communication. See [Context](./context.md)
+for full documentation.
+
+```tsx
+function App(handle: Handle<{ theme: string }>) {
+ handle.context.set({ theme: 'dark' })
+
+ return () => (
+
+
+
+
+ )
+}
+
+function Header(handle: Handle) {
+ let { theme } = handle.context.get(App)
+ return () => (
+
+ )
+}
+```
+
+**Important:** `handle.context.set()` does not cause any updates—it simply
+stores a value. If you need the component tree to update when context changes,
+call `handle.update()` after setting the context.
+
+## See Also
+
+- [Events](./events.md) - Event handling patterns with signals
+- [Context](./context.md) - Context API with TypedEventTarget
+- [Patterns](./patterns.md) - Common usage patterns
diff --git a/docs/agents/remix/component/docs/hydration.md b/docs/agents/remix/component/docs/hydration.md
new file mode 100644
index 0000000..16e5690
--- /dev/null
+++ b/docs/agents/remix/component/docs/hydration.md
@@ -0,0 +1,135 @@
+# Hydration
+
+Hydration makes server-rendered HTML interactive on the client. You mark
+specific components as **client entries**, and the client runtime finds them in
+the page, loads their code, and hydrates them in place.
+
+Only the components you mark are hydrated. The rest of the page stays as static
+HTML.
+
+## Defining a client entry
+
+Use `clientEntry` to mark a component for hydration. The first argument is the
+module URL and export name the client will use to load the component:
+
+```tsx
+import { clientEntry, on, type Handle } from 'remix/component'
+
+export let Counter = clientEntry(
+ '/assets/counter.js#Counter',
+ function Counter(handle: Handle, setup: number) {
+ let count = setup
+
+ return (props: { label: string }) => (
+
+
+ {props.label}: {count}
+
+
+
+ )
+ },
+)
+```
+
+The format is `moduleUrl#ExportName`. If you omit the export name, the
+function's name is used as a fallback.
+
+On the server, `clientEntry` components render like any other component. The
+server wraps their output in comment markers and serializes their props into a
+`'
+let response = createHtmlResponse(html`
${unsafe}
`, { status: 400 })
+```
+
+The `html.raw` template tag can be used to interpolate values without escaping
+them. This has the same semantics as `String.raw` but for HTML snippets that
+have already been escaped or are from trusted sources:
+
+```ts
+// Use html.raw as a template tag to skip escaping interpolations
+let safeHtml = '
Bold'
+let content = html.raw`
${safeHtml}
`
+let response = createHtmlResponse(content)
+
+// This is particularly useful when building HTML from multiple safe fragments
+let header = '
'
+let body = '
Content'
+let footer = '
'
+let page = html.raw`
+
+
+
+ ${header}
+ ${body}
+ ${footer}
+
+
+`
+
+// You can nest html.raw inside html to preserve SafeHtml fragments
+let icon = html.raw`
`
+let button = html`
` // icon is not escaped
+```
+
+**Warning**: Only use `html.raw` with trusted content. Unlike the regular `html`
+template tag, `html.raw` does not escape its interpolations, which can lead to
+XSS vulnerabilities if used with untrusted user input.
+
+See the
+[`html-template` documentation](https://github.com/remix-run/remix/tree/main/packages/html-template#readme)
+for more details.
+
+### Testing
+
+Testing is straightforward because `fetch-router` uses the standard `fetch()`
+API:
+
+```ts
+import * as assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+
+describe('blog routes', () => {
+ it('creates a new post', async () => {
+ let response = await router.fetch('https://api.remix.run/posts', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ title: 'Hello', content: 'World' }),
+ })
+
+ assert.equal(response.status, 201)
+ let post = await response.json()
+ assert.equal(post.title, 'Hello')
+ })
+
+ it('returns 404 for missing posts', async () => {
+ let response = await router.fetch('https://api.remix.run/posts/not-found')
+ assert.equal(response.status, 404)
+ })
+})
+```
+
+No special test harness or mocking required! Just use `fetch()` like you would
+in production.
+
+## Related Packages
+
+- [auth-middleware](https://github.com/remix-run/remix/tree/main/packages/auth-middleware) -
+ Request authentication and route protection helpers
+- [session-middleware](https://github.com/remix-run/remix/tree/main/packages/session-middleware) -
+ Load and persist sessions in request context
+- [form-data-middleware](https://github.com/remix-run/remix/tree/main/packages/form-data-middleware) -
+ Parse request bodies into `context.get(FormData)`
+- [response](https://github.com/remix-run/remix/tree/main/packages/response) -
+ Response helpers for HTML, JSON, files, and redirects
+
+## Related Work
+
+- [headers](https://github.com/remix-run/remix/tree/main/packages/headers) - A
+ library for working with HTTP headers
+- [form-data-parser](https://github.com/remix-run/remix/tree/main/packages/form-data-parser) -
+ A library for parsing multipart/form-data requests
+- [route-pattern](https://github.com/remix-run/remix/tree/main/packages/route-pattern) -
+ The pattern matching library that powers `fetch-router`
+- [Express](https://expressjs.com/) - The classic Node.js web framework
+
+## License
+
+See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
diff --git a/docs/agents/remix/fetch-router/advanced-topics.md b/docs/agents/remix/fetch-router/advanced-topics.md
deleted file mode 100644
index 6237c9c..0000000
--- a/docs/agents/remix/fetch-router/advanced-topics.md
+++ /dev/null
@@ -1,125 +0,0 @@
-# Additional topics and HTML helpers
-
-Source: https://github.com/remix-run/remix/tree/main/packages/fetch-router
-
-## Additional topics
-
-### Scaling your application
-
-- how to use a TrieMatcher
-- how to spread controllers across multiple files
-
-### Error handling and aborted requests
-
-- wrap `router.fetch()` in a try/catch to handle errors
-- `AbortError` is thrown when a request is aborted
-
-### Content negotiation
-
-- use `Accept.from()` from `@remix-run/headers` to serve different responses
- based on the client's `Accept` header
- - maybe put this on `context.accepts()` for convenience?
-
-### Sessions
-
-- use a custom `sessionStorage` implementation to store session data
-- use `session.get()` and `session.set()` to get and set session data
-- use `session.flash()` to set a flash message
-- use `session.destroy()` to destroy the session
-
-### Form data and file uploads
-
-- use the `formData()` middleware to parse the `FormData` object from the
- request body
-- use the `formData` property of the context object to access the form data
-- use the `files` property of the context object to access the uploaded files
-- use the `uploadHandler` option of the `formData()` middleware to handle file
- uploads
-
-### Request method override
-
-- use the `methodOverride()` middleware to override the request method
-- use a hidden `
` to override the request
- method
-
-## Response helpers
-
-Response helpers for creating common HTTP responses are available in the
-[`@remix-run/response`](https://github.com/remix-run/remix/tree/main/packages/response)
-package:
-
-```tsx
-import { createFileResponse } from '@remix-run/response/file'
-import { createHtmlResponse } from '@remix-run/response/html'
-import { createRedirectResponse } from '@remix-run/response/redirect'
-import { compressResponse } from '@remix-run/response/compress'
-
-let response = createHtmlResponse('
Hello
')
-let response = Response.json({ message: 'Hello' })
-let response = createRedirectResponse('/')
-let response = compressResponse(uncompressedResponse, request)
-```
-
-See the
-[`@remix-run/response` documentation](https://github.com/remix-run/remix/tree/main/packages/response#readme)
-for more details.
-
-## Working with HTML
-
-For working with HTML strings and safe HTML interpolation, see the
-[`@remix-run/html-template`](https://github.com/remix-run/remix/tree/main/packages/html-template)
-package. It provides a `html` template tag with automatic escaping to prevent
-XSS vulnerabilities.
-
-```ts
-import { html } from '@remix-run/html-template'
-import { createHtmlResponse } from '@remix-run/response/html'
-
-// Use the template tag to escape unsafe variables in HTML.
-let unsafe = ''
-let response = createHtmlResponse(html`
${unsafe}
`, { status: 400 })
-```
-
-The `html.raw` template tag can be used to interpolate values without escaping
-them. This has the same semantics as `String.raw` but for HTML snippets that
-have already been escaped or are from trusted sources:
-
-```ts
-// Use html.raw as a template tag to skip escaping interpolations
-let safeHtml = '
Bold'
-let content = html.raw`
${safeHtml}
`
-let response = createHtmlResponse(content)
-
-// This is particularly useful when building HTML from multiple safe fragments
-let header = '
'
-let body = '
Content'
-let footer = '
'
-let page = html.raw`
-
-
-
- ${header}
- ${body}
- ${footer}
-
-
-`
-
-// You can nest html.raw inside html to preserve SafeHtml fragments
-let icon = html.raw`
`
-let button = html`
` // icon is not escaped
-```
-
-**Warning**: Only use `html.raw` with trusted content. Unlike the regular `html`
-template tag, `html.raw` does not escape its interpolations, which can lead to
-XSS vulnerabilities if used with untrusted user input.
-
-See the
-[`@remix-run/html-template` documentation](https://github.com/remix-run/remix/tree/main/packages/html-template#readme)
-for more details.
-
-## Navigation
-
-- [fetch-router overview](./index.md)
-- [Middleware and request context](./middleware.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/fetch-router/index.md b/docs/agents/remix/fetch-router/index.md
deleted file mode 100644
index 0b6f0e7..0000000
--- a/docs/agents/remix/fetch-router/index.md
+++ /dev/null
@@ -1,53 +0,0 @@
-# fetch-router
-
-Source: https://github.com/remix-run/remix/tree/main/packages/fetch-router
-
-## Overview
-
-A minimal, composable router built on the
-[web Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) and
-[`route-pattern`](../route-pattern). Ideal for building APIs, web services, and
-server-rendered applications across any JavaScript runtime.
-
-## Features
-
-- **Fetch API**: Built on standard web APIs that work everywhere - Node.js, Bun,
- Deno, Cloudflare Workers, and browsers
-- **Type-Safe Routing**: Leverage TypeScript for compile-time route validation
- and parameter inference
-- **Composable Architecture**: Nest routers, combine middleware, and organize
- routes hierarchically
-- **Declarative Route Maps**: Define your entire route structure upfront with
- type-safe route names and request methods
-- **Flexible Middleware**: Apply middleware globally, per-route, or to entire
- route hierarchies
-- **Easy Testing**: Use standard `fetch()` to test your routes - no special test
- harness required
-
-## Goals
-
-- **Simplicity**: A router should be simple to understand and use. The entire
- API surface fits in your head.
-- **Composability**: Small routers combine to build large applications.
- Middleware and nested routers make organization natural.
-- **Standards-Based**: Built on web standards that work across runtimes. No
- proprietary APIs or Node.js-specific code.
-
-## Installation
-
-```sh
-npm i remix
-```
-
-Import route definition helpers from `remix/fetch-router/routes`, and runtime
-APIs from `remix/fetch-router`.
-
-## Navigation
-
-- [Basic usage and route maps](./usage.md)
-- [Routing based on request method](./routing-methods.md)
-- [Resource-based routes](./routing-resources.md)
-- [Middleware and request context](./middleware.md)
-- [Additional topics and HTML helpers](./advanced-topics.md)
-- [Testing and related work](./testing-and-related.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/fetch-router/middleware.md b/docs/agents/remix/fetch-router/middleware.md
deleted file mode 100644
index 14b0110..0000000
--- a/docs/agents/remix/fetch-router/middleware.md
+++ /dev/null
@@ -1,130 +0,0 @@
-# Middleware and request context
-
-Source: https://github.com/remix-run/remix/tree/main/packages/fetch-router
-
-## Controllers and middleware
-
-Middleware functions run code before and/or after actions. They are a powerful
-way to add functionality to your app.
-
-A basic logging middleware might look like this:
-
-```ts
-import type { Middleware } from 'remix/fetch-router'
-
-// You can use the `Middleware` type to type middleware functions.
-function logger(): Middleware {
- return async (context, next) => {
- let start = new Date()
-
- // Call next() to invoke the next middleware or action in the chain.
- let response = await next()
-
- let end = new Date()
- let duration = end.getTime() - start.getTime()
-
- console.log(
- `${context.request.method} ${context.request.url} ${response.status} ${duration}ms`,
- )
-
- return response
- }
-}
-
-// Use it like this:
-let router = createRouter({
- middleware: [logger()],
-})
-```
-
-Middleware is typically built as a function that returns a middleware function.
-This allows you to pass options to the middleware function if needed. For
-example, the `auth()` middleware below allows you to pass a `token` option that
-is used to authenticate the request.
-
-```tsx
-interface AuthOptions {
- token: string
-}
-
-function auth(options?: AuthOptions): Middleware {
- let token = options?.token ?? 'secret'
-
- return (context, next) => {
- if (context.headers.get('Authorization') !== `Bearer ${token}`) {
- return new Response('Unauthorized', { status: 401 })
- }
- return next()
- }
-}
-```
-
-Middleware may be used in two different contexts: globally (at the router level)
-or inline (at the route level).
-
-Global middleware is added to the router when it is created using the
-`createRouter({ middleware })` option. This middleware runs before any routes
-are matched and is useful for doing things like logging, serving static files,
-profiling, and a variety of other things. Global middleware runs on every
-request, so it's important to keep them lightweight and fast.
-
-Inline (or "route") middleware is added to the router when actions are
-registered using either `router.map()` or one of the method-specific helpers
-like `router.get()`, `router.post()`, `router.put()`, `router.delete()`, etc.
-Route middleware runs after global middleware but before the route action, and
-is useful for doing things like authentication, authorization, and data
-validation.
-
-```tsx
-let routes = route({
- home: '/',
- admin: {
- dashboard: '/admin/dashboard',
- },
-})
-
-let router = createRouter({
- // This middleware runs on all requests.
- middleware: [staticFiles('./public')],
-})
-
-router.map(routes.home, () => new Response('Home'))
-
-router.map(routes.admin.dashboard, {
- // This middleware runs only on the `/admin/dashboard` route.
- middleware: [auth({ token: 'secret' })],
- action() {
- return new Response('Dashboard')
- },
-})
-```
-
-## Request context
-
-Every action and middleware receives a `context` object with useful properties:
-
-```ts
-router.get('/posts/:id', ({ request, url, params, storage }) => {
- // request: The original Request object
- console.log(request.method) // "GET"
- console.log(request.headers.get('Accept'))
-
- // url: Parsed URL object
- console.log(url.pathname) // "/posts/123"
- console.log(url.searchParams.get('sort'))
-
- // params: Route parameters (fully typed!)
- console.log(params.id) // "123"
-
- // storage: AppStorage for type-safe access to request-scoped data
- storage.set('user', currentUser)
-
- return new Response(`Post ${params.id}`)
-})
-```
-
-## Navigation
-
-- [fetch-router overview](./index.md)
-- [Routing based on request method](./routing-methods.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/fetch-router/routing-methods.md b/docs/agents/remix/fetch-router/routing-methods.md
deleted file mode 100644
index 62e183d..0000000
--- a/docs/agents/remix/fetch-router/routing-methods.md
+++ /dev/null
@@ -1,173 +0,0 @@
-# Routing based on request method
-
-Source: https://github.com/remix-run/remix/tree/main/packages/fetch-router
-
-## Routing based on request method
-
-In the example above, both the `home` and `contact` routes are able to be
-registered for any incoming
-[`request.method`](https://developer.mozilla.org/en-US/docs/Web/API/Request/method).
-If you inspect their types, you'll see:
-
-```tsx
-type HomeRoute = typeof routes.home // Route<'ANY', '/'>
-type ContactRoute = typeof routes.contact // Route<'ANY', '/contact'>
-```
-
-We used `router.get()` and `router.post()` to register actions on each route
-specifically for the `GET` and `POST` request methods.
-
-However, we can also encode the request method into the route definition itself
-using the `method` property on the route. When you include the `method` in the
-route definition, `router.map()` will register the action only for that specific
-request method. This can be more convenient than using `router.get()` and
-`router.post()` to register actions one at a time.
-
-```ts
-import * as assert from 'node:assert/strict'
-import { createRouter } from 'remix/fetch-router'
-import { route } from 'remix/fetch-router/routes'
-
-let routes = route({
- home: { method: 'GET', pattern: '/' },
- contact: {
- index: { method: 'GET', pattern: '/contact' },
- action: { method: 'POST', pattern: '/contact' },
- },
-})
-
-type Routes = typeof routes
-// Each route is now typed with a specific request method.
-// {
-// home: Route<'GET', '/'>,
-// contact: {
-// index: Route<'GET', '/contact'>,
-// action: Route<'POST', '/contact'>,
-// },
-// }
-
-let router = createRouter()
-
-router.map(routes, {
- home({ method }) {
- assert.equal(method, 'GET')
- return new Response('Home')
- },
- contact: {
- index({ method }) {
- assert.equal(method, 'GET')
- return new Response('Contact')
- },
- action({ method }) {
- assert.equal(method, 'POST')
- return new Response('Contact Action')
- },
- },
-})
-```
-
-## Declaring routes
-
-In additon to the `{ method, pattern }` syntax shown above, the router provides
-a few shorthand methods that help to eliminate some of the boilerplate when
-building complex route maps:
-
-- [`form`](#declaring-form-routes) - creates a route map with an `index` (`GET`)
- and `action` (`POST`) route. This is well-suited to showing a standard HTML
- `
-
-
- `)
- },
- // POST /contact - handles the form submission
- action({ formData }) {
- let message = formData.get('message') as string
- let body = html`
-
-
-
Thanks!
-
You said: ${message}
-
-
- Got more to say?
- Send another message
-
-
-
- `
-
- return createHtmlResponse(body)
- },
- },
-})
-```
-
-## Navigation
-
-- [Resource-based routes](./routing-resources.md)
-- [Basic usage and route maps](./usage.md)
-- [fetch-router overview](./index.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/fetch-router/routing-resources.md b/docs/agents/remix/fetch-router/routing-resources.md
deleted file mode 100644
index 150b7e5..0000000
--- a/docs/agents/remix/fetch-router/routing-resources.md
+++ /dev/null
@@ -1,186 +0,0 @@
-# Resource-based routes
-
-Source: https://github.com/remix-run/remix/tree/main/packages/fetch-router
-
-## Resource-based routes
-
-The router provides a `resources()` helper that creates a route map with a set
-of resource-based routes, useful when defining RESTful API routes or modeling
-resources in a web application (similar to Rails' `resources` helper).
-
-```ts
-import { createRouter } from 'remix/fetch-router'
-import { resources, route } from 'remix/fetch-router/routes'
-
-let routes = route({
- brands: {
- ...resources('brands', { only: ['index', 'show'] }),
- products: resources('brands/:brandId/products', {
- only: ['index', 'show'],
- }),
- },
-})
-
-type Routes = typeof routes
-// {
-// brands: {
-// index: Route<'GET', '/brands'>
-// show: Route<'GET', '/brands/:id'>
-// products: {
-// index: Route<'GET', '/brands/:brandId/products'>
-// show: Route<'GET', '/brands/:brandId/products/:id'>
-// },
-// },
-// }
-
-let router = createRouter()
-
-router.map(routes.brands, {
- // GET /brands
- index() {
- return new Response('Brands Index')
- },
- // GET /brands/:id
- show({ params }) {
- return new Response(`Brand ${params.id}`)
- },
- products: {
- // GET /brands/:brandId/products
- index() {
- return new Response('Products Index')
- },
- // GET /brands/:brandId/products/:id
- show({ params }) {
- return new Response(`Brand ${params.brandId}, Product ${params.id}`)
- },
- },
-})
-```
-
-The `resource()` helper creates a route map for a single resource (not something
-that is part of a collection). This is useful when defining operations on a
-singleton resource, like a user profile.
-
-```tsx
-import { createRouter } from 'remix/fetch-router'
-import { resource, resources, route } from 'remix/fetch-router/routes'
-
-let routes = route({
- user: {
- ...resources('users', { only: ['index', 'show'] }),
- profile: resource('users/:userId/profile', {
- only: ['show', 'edit', 'update'],
- }),
- },
-})
-
-type Routes = typeof routes
-// {
-// user: {
-// index: Route<'GET', '/users'>
-// show: Route<'GET', '/users/:id'>
-// profile: {
-// show: Route<'GET', '/users/:userId/profile'>
-// edit: Route<'GET', '/users/:userId/profile/edit'>
-// update: Route<'PUT', '/users/:userId/profile'>
-// },
-// },
-// }
-```
-
-Without the `only` option, a `resources('users')` route map contains 7 routes:
-`index`, `new`, `show`, `create`, `edit`, `update`, and `destroy`.
-
-```tsx
-let routes = resources('users')
-type Routes = typeof routes
-// {
-// index: Route<'GET', '/users'> - Lists all users
-// new: Route<'GET', '/users/new'> - Shows a form to create a new user
-// show: Route<'GET', '/users/:id'> - Shows a single user
-// create: Route<'POST', '/users'> - Creates a new user
-// edit: Route<'GET', '/users/:id/edit'> - Shows a form to edit a user
-// update: Route<'PUT', '/users/:id'> - Updates a user
-// destroy: Route<'DELETE', '/users/:id'> - Deletes a user
-// }
-```
-
-Similarly, a `resource('profile')` route map contains 6 routes: `new`, `show`,
-`create`, `edit`, `update`, and `destroy`. There is no `index` route because a
-`resource()` represents a singleton resource, not a collection, so there is no
-collection view.
-
-```tsx
-let routes = resource('profile')
-type Routes = typeof routes
-// {
-// new: Route<'GET', '/profile/new'> - Shows a form to create the profile
-// show: Route<'GET', '/profile'> - Shows the profile
-// create: Route<'POST', '/profile'> - Creates the profile
-// edit: Route<'GET', '/profile/edit'> - Shows a form to edit the profile
-// update: Route<'PUT', '/profile'> - Updates the profile
-// destroy: Route<'DELETE', '/profile'> - Deletes the profile
-// }
-```
-
-Resource route names may be customized using the `names` option when you'd
-prefer not to use the default
-`index`/`new`/`show`/`create`/`edit`/`update`/`destroy` route names.
-
-```tsx
-import { createRouter } from 'remix/fetch-router'
-import { resources, route } from 'remix/fetch-router/routes'
-
-let routes = route({
- users: resources('users', {
- only: ['index', 'show'],
- names: { index: 'list', show: 'view' },
- }),
-})
-type Routes = typeof routes.users
-// {
-// list: Route<'GET', '/users'> - Lists all users
-// view: Route<'GET', '/users/:id'> - Shows a single user
-// }
-```
-
-If you want to use a param name other than `id`, you can use the `param` option.
-
-```tsx
-import { createRouter } from 'remix/fetch-router'
-import { resources, route } from 'remix/fetch-router/routes'
-
-let routes = route({
- users: resources('users', {
- only: ['index', 'show', 'edit', 'update'],
- param: 'userId',
- }),
-})
-type Routes = typeof routes.users
-// {
-// index: Route<'GET', '/users'> - Lists all users
-// show: Route<'GET', '/users/:userId'> - Shows a single user
-// edit: Route<'GET', '/users/:userId/edit'> - Shows a form to edit a user
-// update: Route<'PUT', '/users/:userId'> - Updates a user
-// }
-```
-
-You can use the `exclude` option to exclude routes from being generated.
-
-```tsx
-let routes = resources('users', { exclude: ['edit', 'update', 'destroy'] })
-type Routes = typeof routes
-// {
-// index: Route<'GET', '/users'> - Lists all users
-// new: Route<'GET', '/users/new'> - Shows a form to create a new user
-// show: Route<'GET', '/users/:userId'> - Shows a single user
-// create: Route<'POST', '/users'> - Creates a new user
-// }
-```
-
-## Navigation
-
-- [Routing based on request method](./routing-methods.md)
-- [Basic usage and route maps](./usage.md)
-- [fetch-router overview](./index.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/fetch-router/testing-and-related.md b/docs/agents/remix/fetch-router/testing-and-related.md
deleted file mode 100644
index 16607c5..0000000
--- a/docs/agents/remix/fetch-router/testing-and-related.md
+++ /dev/null
@@ -1,56 +0,0 @@
-# Testing and related work
-
-Source: https://github.com/remix-run/remix/tree/main/packages/fetch-router
-
-## Testing
-
-Testing is straightforward because `fetch-router` uses the standard `fetch()`
-API:
-
-```ts
-import * as assert from 'node:assert/strict'
-import { describe, it } from 'node:test'
-
-describe('blog routes', () => {
- it('creates a new post', async () => {
- let response = await router.fetch('https://api.remix.run/posts', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ title: 'Hello', content: 'World' }),
- })
-
- assert.equal(response.status, 201)
- let post = await response.json()
- assert.equal(post.title, 'Hello')
- })
-
- it('returns 404 for missing posts', async () => {
- let response = await router.fetch('https://api.remix.run/posts/not-found')
- assert.equal(response.status, 404)
- })
-})
-```
-
-No special test harness or mocking required! Just use `fetch()` like you would
-in production.
-
-## Related work
-
-- [@remix-run/response](../response/index.md) - Response helpers for HTML, JSON,
- files, and redirects
-- [@remix-run/headers](../headers/index.md) - A library for working with HTTP
- headers
-- [@remix-run/form-data-parser](../form-data-parser) - A library for parsing
- multipart/form-data requests
-- [@remix-run/route-pattern](../route-pattern) - The pattern matching library
- that powers `fetch-router`
-- [Express](https://expressjs.com/) - The classic Node.js web framework
-
-## License
-
-See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [fetch-router overview](./index.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/fetch-router/usage.md b/docs/agents/remix/fetch-router/usage.md
deleted file mode 100644
index 14fc7b8..0000000
--- a/docs/agents/remix/fetch-router/usage.md
+++ /dev/null
@@ -1,170 +0,0 @@
-# Basic usage and route maps
-
-Source: https://github.com/remix-run/remix/tree/main/packages/fetch-router
-
-The main purpose of the router is to map incoming requests to request handlers
-and middleware. The router uses the `fetch()` API to accept a
-[`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) and return
-a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
-
-The example below is a small site with a home page, an "about" page, and a blog.
-
-```ts
-import { createRouter } from 'remix/fetch-router'
-import { route } from 'remix/fetch-router/routes'
-import { logger } from 'remix/logger-middleware'
-
-// `route()` creates a "route map" that organizes routes by name. The keys
-// of the map may be any name, and may be nested to group related routes.
-let routes = route({
- home: '/',
- about: '/about',
- blog: {
- index: '/blog',
- show: '/blog/:slug',
- },
-})
-
-let router = createRouter({
- // Middleware may be used to run code before and/or after actions run.
- // In this case, the `logger()` middleware logs the request to the console.
- middleware: [logger()],
-})
-
-// Map the routes to a "controller" that defines actions for each route. The
-// structure of the controller mirrors the structure of the route map.
-router.map(routes, {
- home() {
- return new Response('Home')
- },
- about() {
- return new Response('About')
- },
- blog: {
- index() {
- return new Response('Blog')
- },
- show({ params }) {
- // params is a type-safe object with the parameters from the route pattern
- return new Response(`Post ${params.slug}`)
- },
- },
-})
-
-let response = await router.fetch('https://remix.run/blog/hello-remix')
-console.log(await response.text()) // "Post hello-remix"
-```
-
-The route map is an object of the same shape as the object passed into
-`route()`, including nested objects. The leaves of the map are `Route` objects,
-which you can see if you inspect the type of the `routes` variable in your IDE.
-
-```ts
-type Routes = typeof routes
-// {
-// home: Route<'ANY', '/'>
-// about: Route<'ANY', '/about'>
-// blog: {
-// index: Route<'ANY', '/blog'>
-// show: Route<'ANY', '/blog/:slug'>
-// },
-// }
-```
-
-The `routes.home` route is a `Route<'ANY', '/'>`, which means it serves any
-request method (`GET`, `POST`, `PUT`, `DELETE`, etc.) when the URL path is `/`.
-We'll discuss routing based on request method in the routing guide.
-
-## Links and form actions
-
-In addition to describing the structure of your routes, route maps also make it
-easy to generate type-safe links and form actions using the `href()` function on
-a route. The example below is a small site with a home page and a "Contact Us"
-page.
-
-Note: We're using the
-[`createHtmlResponse` helper from `remix/response`](https://github.com/remix-run/remix/tree/main/packages/response/README.md#html-responses)
-below to create `Response`s with `Content-Type: text/html`. We're also using the
-`html` template tag to create safe HTML strings to use in the response body.
-
-```ts
-import { createRouter } from 'remix/fetch-router'
-import { route } from 'remix/fetch-router/routes'
-import { html } from 'remix/html-template'
-import { createHtmlResponse } from 'remix/response/html'
-
-let routes = route({
- home: '/',
- contact: '/contact',
-})
-
-let router = createRouter()
-
-// Register an action for `GET /`
-router.get(routes.home, () => {
- return createHtmlResponse(`
-
-
-
Home
-
- Contact Us
-
-
-
- `)
-})
-
-// Register an action for `GET /contact`
-router.get(routes.contact, () => {
- return createHtmlResponse(`
-
-
-
Contact Us
-
-
-
-
- `)
-})
-
-// Register an action for `POST /contact`
-router.post(routes.contact, ({ formData }) => {
- // POST actions receive a `context` object with a `formData` property that
- // contains the `FormData` from the form submission. It is automatically
- // parsed from the request body and available in all POST actions.
- let message = formData.get('message') as string
- let body = html`
-
-
-
Thanks!
-
-
-
-
- `
-
- return createHtmlResponse(body)
-})
-```
-
-## Navigation
-
-- [fetch-router overview](./index.md)
-- [Routing based on request method](./routing-methods.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/file-storage-s3.md b/docs/agents/remix/file-storage-s3.md
deleted file mode 100644
index ed64e78..0000000
--- a/docs/agents/remix/file-storage-s3.md
+++ /dev/null
@@ -1,44 +0,0 @@
-# file-storage-s3
-
-Source: https://github.com/remix-run/remix/tree/main/packages/file-storage-s3
-
-## README
-
-S3 backend for `remix/file-storage`.
-
-Use this package when you want the `FileStorage` API backed by AWS S3 or an
-S3-compatible provider (MinIO, LocalStack, etc.).
-
-## Installation
-
-```sh
-npm i remix
-```
-
-## Usage
-
-```ts
-import { createS3FileStorage } from 'remix/file-storage-s3'
-
-let storage = createS3FileStorage({
- accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
- bucket: 'my-app-uploads',
- region: 'us-east-1',
-})
-```
-
-Use `endpoint` and `forcePathStyle: true` for non-AWS S3-compatible providers.
-
-## Related packages
-
-- [`file-storage`](https://github.com/remix-run/remix/tree/main/packages/file-storage)
-- [`form-data-parser`](https://github.com/remix-run/remix/tree/main/packages/form-data-parser)
-
-## License
-
-See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Remix package index](./index.md)
diff --git a/docs/agents/remix/file-storage-s3/README.md b/docs/agents/remix/file-storage-s3/README.md
new file mode 100644
index 0000000..afad9fb
--- /dev/null
+++ b/docs/agents/remix/file-storage-s3/README.md
@@ -0,0 +1,55 @@
+# file-storage-s3
+
+S3 backend for
+[`remix/file-storage`](https://github.com/remix-run/remix/tree/main/packages/file-storage).
+Use this package when you want the `FileStorage` API backed by AWS S3 or an
+S3-compatible provider.
+
+## Features
+
+- **S3-Compatible API** - Works with AWS S3 and S3-compatible APIs (e.g. MinIO,
+ LocalStack)
+- **Metadata Preservation** - Preserves `File` metadata (`name`, `type`,
+ `lastModified`)
+- **Runtime-Agnostic Signing** - Uses
+ [`aws4fetch`](https://github.com/mhart/aws4fetch) for SigV4 signing
+
+## Installation
+
+```sh
+npm i remix
+```
+
+## Usage
+
+```ts
+import { createS3FileStorage } from 'remix/file-storage-s3'
+
+let storage = createS3FileStorage({
+ accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
+ secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
+ bucket: 'my-app-uploads',
+ region: 'us-east-1',
+})
+
+await storage.set(
+ 'uploads/hello.txt',
+ new File(['hello world'], 'hello.txt', { type: 'text/plain' }),
+)
+let file = await storage.get('uploads/hello.txt')
+await storage.remove('uploads/hello.txt')
+```
+
+For S3-compatible providers such as MinIO and LocalStack, set `endpoint` and
+`forcePathStyle: true`.
+
+## Related Packages
+
+- [`file-storage`](https://github.com/remix-run/remix/tree/main/packages/file-storage) -
+ Core `FileStorage` interface and filesystem/memory backends
+- [`form-data-parser`](https://github.com/remix-run/remix/tree/main/packages/form-data-parser) -
+ Parses `multipart/form-data` uploads into `FileUpload` objects
+
+## License
+
+See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
diff --git a/docs/agents/remix/file-storage.md b/docs/agents/remix/file-storage/README.md
similarity index 62%
rename from docs/agents/remix/file-storage.md
rename to docs/agents/remix/file-storage/README.md
index 4cf3af1..c144d59 100644
--- a/docs/agents/remix/file-storage.md
+++ b/docs/agents/remix/file-storage/README.md
@@ -1,39 +1,22 @@
# file-storage
-Source: https://github.com/remix-run/remix/tree/main/packages/file-storage
-
-## README
-
-`file-storage` is a key/value interface for storing
-[`File` objects](https://developer.mozilla.org/en-US/docs/Web/API/File) in
-JavaScript.
-
-Handling file uploads and storage is a common requirement in web applications,
-but each storage backend (local disk, AWS S3, Cloudflare R2, etc.) has its own
-API and conventions. This fragmentation makes it difficult to write portable
-code that can easily switch between storage providers or support multiple
-backends simultaneously.
-
-Similar to how `localStorage` allows you to store key/value pairs of strings in
-the browser, `file-storage` allows you to store key/value pairs of files on the
-server with a consistent interface regardless of the underlying storage
-mechanism.
+Key/value storage interfaces for server-side
+[`File` objects](https://developer.mozilla.org/en-US/docs/Web/API/File).
+`file-storage` gives Remix apps one consistent API across local disk and memory
+backends.
## Features
- **Simple API** - Intuitive key/value API (like
[Web Storage](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API),
but for `File`s instead of strings)
-- **Generic Interface** - `FileStorage` interface that works for various large
- object storage backends (can be adapted to AWS S3, Cloudflare R2, etc.)
+- **Multiple Backends** - Built-in filesystem and memory backends
- **Streaming Support** - Stream file content to and from storage
- **Metadata Preservation** - Preserves all `File` metadata including
`file.name`, `file.type`, and `file.lastModified`
## Installation
-Install from [npm](https://www.npmjs.com/):
-
```sh
npm i remix
```
@@ -77,7 +60,3 @@ await storage.remove(key)
## License
See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Remix package index](./index.md)
diff --git a/docs/agents/remix/form-data-middleware.md b/docs/agents/remix/form-data-middleware.md
deleted file mode 100644
index df789f4..0000000
--- a/docs/agents/remix/form-data-middleware.md
+++ /dev/null
@@ -1,99 +0,0 @@
-# form-data-middleware
-
-Source:
-https://github.com/remix-run/remix/tree/main/packages/form-data-middleware
-
-## README
-
-Middleware for parsing
-[`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) from
-incoming request bodies for use with
-[`@remix-run/fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router).
-
-## Installation
-
-```sh
-bun add @remix-run/form-data-middleware
-```
-
-## Usage
-
-Use the `formData()` middleware at the router level to parse `FormData` from the
-request body and make it available on the request context as `context.formData`.
-
-`context.files` will also be available as a map of `File` objects keyed by the
-name of the form field.
-
-```ts
-import { createRouter } from '@remix-run/fetch-router'
-import { formData } from '@remix-run/form-data-middleware'
-
-let router = createRouter({
- middleware: [formData()],
-})
-
-router.post('/users', async (context) => {
- let name = context.formData.get('name')
- let email = context.formData.get('email')
-
- // Handle file uploads
- let avatar = context.files?.get('avatar')
-
- return Response.json({ name, email, hasAvatar: !!avatar })
-})
-```
-
-### Custom File Upload Handler
-
-You can use a custom upload handler to customize how file uploads are handled.
-The return value of the upload handler will be used as the value of the form
-field in the `FormData` object.
-
-```ts
-import { formData } from '@remix-run/form-data-middleware'
-import { writeFile } from 'node:fs/promises'
-
-let router = createRouter({
- middleware: [
- formData({
- async uploadHandler(upload) {
- // Save to disk and return path
- let path = `./uploads/${upload.name}`
- await writeFile(path, Buffer.from(await upload.arrayBuffer()))
- return path
- },
- }),
- ],
-})
-```
-
-### Suppress Parse Errors
-
-Some requests may contain invalid form data that cannot be parsed. You can
-suppress parse errors by setting `suppressErrors` to `true`. In these cases,
-`context.formData` will be an empty `FormData` object.
-
-```ts
-let router = createRouter({
- middleware: [
- formData({
- suppressErrors: true, // Invalid form data won't throw
- }),
- ],
-})
-```
-
-## Related Packages
-
-- [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) -
- Router for the web Fetch API
-- [`form-data-parser`](https://github.com/remix-run/remix/tree/main/packages/form-data-parser) -
- The underlying form data parser
-
-## License
-
-See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Remix package index](./index.md)
diff --git a/docs/agents/remix/form-data-middleware/README.md b/docs/agents/remix/form-data-middleware/README.md
new file mode 100644
index 0000000..89b686f
--- /dev/null
+++ b/docs/agents/remix/form-data-middleware/README.md
@@ -0,0 +1,121 @@
+# form-data-middleware
+
+Form body parsing middleware for Remix. It parses incoming
+[`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) and
+exposes it via `context.get(FormData)`.
+
+## Features
+
+- **Request Form Parsing** - Parses request body form data once per request
+- **File Access** - Uploaded files are available from `context.get(FormData)`
+- **Custom Upload Handling** - Supports pluggable upload handlers for file
+ processing
+- **Error Control** - Optional suppression for malformed form data
+
+## Installation
+
+```sh
+npm i remix
+```
+
+## Usage
+
+Use the `formData()` middleware at the router level to parse `FormData` from the
+request body and make it available on request context via
+`context.get(FormData)`.
+
+Uploaded files are available in the parsed `FormData` object. For a single file
+field, use `formData.get(name)`. For repeated file fields, use
+`formData.getAll(name)`.
+
+```ts
+import { createRouter } from 'remix/fetch-router'
+import { formData } from 'remix/form-data-middleware'
+
+let router = createRouter({
+ middleware: [formData()],
+})
+
+router.post('/users', async (context) => {
+ let formData = context.get(FormData)
+ let name = formData.get('name')
+ let email = formData.get('email')
+
+ // Handle file uploads
+ let avatar = formData.get('avatar')
+
+ return Response.json({ name, email, hasAvatar: avatar instanceof File })
+})
+```
+
+### Custom File Upload Handler
+
+You can use a custom upload handler to customize how file uploads are handled.
+The return value of the upload handler will be used as the value of the form
+field in the `FormData` object.
+
+```ts
+import { formData } from 'remix/form-data-middleware'
+import { writeFile } from 'node:fs/promises'
+
+let router = createRouter({
+ middleware: [
+ formData({
+ async uploadHandler(upload) {
+ // Save to disk and return path
+ let path = `./uploads/${upload.name}`
+ await writeFile(path, Buffer.from(await upload.arrayBuffer()))
+ return path
+ },
+ }),
+ ],
+})
+```
+
+### Limit Multipart Growth
+
+`formData()` forwards multipart limit options to `parseFormData()`, so you can
+cap uploads with `maxHeaderSize`, `maxFiles`, `maxFileSize`, `maxParts`, and
+`maxTotalSize`.
+
+```ts
+let router = createRouter({
+ middleware: [
+ formData({
+ maxFiles: 5,
+ maxFileSize: 10 * 1024 * 1024,
+ maxParts: 25,
+ maxTotalSize: 12 * 1024 * 1024,
+ }),
+ ],
+})
+```
+
+### Suppress Parse Errors
+
+Some requests may contain invalid form data that cannot be parsed. You can
+suppress those malformed-body parse errors by setting `suppressErrors` to
+`true`. In these cases, `context.get(FormData)` will be an empty `FormData`
+object. Multipart limit violations from `maxHeaderSize`, `maxFiles`,
+`maxFileSize`, `maxParts`, or `maxTotalSize` are never suppressed.
+
+```ts
+let router = createRouter({
+ middleware: [
+ formData({
+ suppressErrors: true, // Invalid form data won't throw
+ }),
+ ],
+})
+```
+
+## Related Packages
+
+- [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) -
+ Router for the web Fetch API
+- [`form-data-parser`](https://github.com/remix-run/remix/tree/main/packages/form-data-parser) -
+ The underlying form data parser
+
+## License
+
+See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
diff --git a/docs/agents/remix/form-data-parser.md b/docs/agents/remix/form-data-parser/README.md
similarity index 79%
rename from docs/agents/remix/form-data-parser.md
rename to docs/agents/remix/form-data-parser/README.md
index f80095d..7d592cc 100644
--- a/docs/agents/remix/form-data-parser.md
+++ b/docs/agents/remix/form-data-parser/README.md
@@ -1,9 +1,5 @@
# form-data-parser
-Source: https://github.com/remix-run/remix/tree/main/packages/form-data-parser
-
-## README
-
A streaming `multipart/form-data` parser that solves memory issues with file
uploads in server environments. Built as an enhanced replacement for the native
`request.formData()` API, it enables efficient handling of large file uploads by
@@ -49,10 +45,8 @@ request body stream, allowing you to safely store files and use either a) the
## Installation
-Install from [npm](https://www.npmjs.com/):
-
```sh
-bun add @remix-run/form-data-parser
+npm i remix
```
## Usage
@@ -62,8 +56,8 @@ for fine-grained control of handling file uploads.
```ts
import * as fsp from 'node:fs/promises'
-import type { FileUpload } from '@remix-run/form-data-parser'
-import { parseFormData } from '@remix-run/form-data-parser'
+import type { FileUpload } from 'remix/form-data-parser'
+import { parseFormData } from 'remix/form-data-parser'
// Define how to handle incoming file uploads
async function uploadHandler(fileUpload: FileUpload) {
@@ -98,15 +92,22 @@ async function requestHandler(request: Request) {
}
```
-To limit the maximum size of files that are uploaded, or the maximum number of
-files that may be uploaded in a single request, use the `maxFileSize` and
-`maxFiles` options.
+To validate the resulting `FormData` object with `remix/data-schema`, use the
+`remix/data-schema/form-data` helpers.
+
+To limit the overall shape of multipart requests, use the `maxHeaderSize`,
+`maxFileSize`, `maxFiles`, `maxParts`, and `maxTotalSize` options. By default,
+`parseFormData()` uses `maxFiles = 20`, `maxParts = 1000`, and
+`maxTotalSize = maxFiles * maxFileSize + 1 MiB`.
```ts
import {
MaxFilesExceededError,
MaxFileSizeExceededError,
-} from '@remix-run/form-data-parser'
+ MaxHeaderSizeExceededError,
+ MaxPartsExceededError,
+ MaxTotalSizeExceededError,
+} from 'remix/form-data-parser'
const oneKb = 1024
const oneMb = 1024 * oneKb
@@ -115,12 +116,20 @@ try {
let formData = await parseFormData(request, {
maxFiles: 5,
maxFileSize: 10 * oneMb,
+ maxParts: 25,
+ maxTotalSize: 12 * oneMb,
})
} catch (error) {
if (error instanceof MaxFilesExceededError) {
console.error(`Request may not contain more than 5 files`)
+ } else if (error instanceof MaxHeaderSizeExceededError) {
+ console.error(`Multipart headers may not exceed the configured size limit`)
} else if (error instanceof MaxFileSizeExceededError) {
console.error(`Files may not be larger than 10 MiB`)
+ } else if (error instanceof MaxPartsExceededError) {
+ console.error(`Request may not contain more than 25 multipart parts`)
+ } else if (error instanceof MaxTotalSizeExceededError) {
+ console.error(`Multipart request may not exceed 12 MiB of total content`)
} else {
console.error(`An unknown error occurred:`, error)
}
@@ -133,9 +142,9 @@ are uploaded, this library pairs really well with
for keeping files in various storage backends.
```ts
-import { LocalFileStorage } from '@remix-run/file-storage/local'
-import type { FileUpload } from '@remix-run/form-data-parser'
-import { parseFormData } from '@remix-run/form-data-parser'
+import { LocalFileStorage } from 'remix/file-storage/local'
+import type { FileUpload } from 'remix/form-data-parser'
+import { parseFormData } from 'remix/form-data-parser'
// Set up storage for uploaded files
const fileStorage = new LocalFileStorage('/uploads/user-avatars')
@@ -168,6 +177,9 @@ contains working demos:
## Related Packages
+- [`data-schema`](https://github.com/remix-run/remix/tree/main/packages/data-schema) -
+ Tiny, standards-aligned validation with a `form-data` export for `FormData`
+ and `URLSearchParams`
- [`file-storage`](https://github.com/remix-run/remix/tree/main/packages/file-storage) -
A simple key/value interface for storing `FileUpload` objects you get from the
parser
@@ -177,7 +189,3 @@ contains working demos:
## License
See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Remix package index](./index.md)
diff --git a/docs/agents/remix/fs.md b/docs/agents/remix/fs/README.md
similarity index 79%
rename from docs/agents/remix/fs.md
rename to docs/agents/remix/fs/README.md
index 8e5c38b..03d3817 100644
--- a/docs/agents/remix/fs.md
+++ b/docs/agents/remix/fs/README.md
@@ -1,13 +1,7 @@
# fs
-Source: https://github.com/remix-run/remix/tree/main/packages/fs
-
-## README
-
-Lazy, streaming filesystem utilities for JavaScript.
-
-This package provides utilities for working with files on the local filesystem
-using the
+Lazy, streaming filesystem utilities for JavaScript. This package provides
+utilities for working with files on the local filesystem using the
[`LazyFile`](https://github.com/remix-run/remix/tree/main/packages/lazy-file)/
native [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) API.
@@ -24,10 +18,8 @@ native [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) API.
## Installation
-Install from [npm](https://www.npmjs.com/):
-
```sh
-bun add @remix-run/fs
+npm i remix
```
## Usage
@@ -35,7 +27,7 @@ bun add @remix-run/fs
### Opening Lazy Files
```ts
-import { openLazyFile } from '@remix-run/fs'
+import { openLazyFile } from 'remix/fs'
// Open a file from the filesystem
let lazyFile = openLazyFile('./path/to/file.json')
@@ -54,7 +46,7 @@ let customLazyFile = openLazyFile('./image.jpg', {
### Writing Files
```ts
-import { openLazyFile, writeFile } from '@remix-run/fs'
+import { openLazyFile, writeFile } from 'remix/fs'
// Read a file and write it elsewhere
let lazyFile = openLazyFile('./source.txt')
@@ -77,7 +69,3 @@ await handle.close()
## License
See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Remix package index](./index.md)
diff --git a/docs/agents/remix/headers/README.md b/docs/agents/remix/headers/README.md
new file mode 100644
index 0000000..0be7b37
--- /dev/null
+++ b/docs/agents/remix/headers/README.md
@@ -0,0 +1,618 @@
+# headers
+
+Typed utilities for parsing, manipulating, and serializing HTTP header values.
+`headers` provides focused classes for common HTTP headers.
+
+## Features
+
+- **Header-Specific Classes** - Purpose-built APIs for `Accept`,
+ `Cache-Control`, `Content-Type`, and more
+- **Round-Trip Safety** - Parse from raw values and serialize back with
+ `.toString()`
+- **Typed Operations** - Work with structured values instead of manual string
+ parsing
+
+## Installation
+
+```sh
+npm i remix
+```
+
+## Individual Header Utilities
+
+Each supported header has a class that represents the header value. Use the
+static `from()` method to parse header values. Each class has a `toString()`
+method that returns the header value as a string, which you can either call
+manually, or will be called automatically when the header class is used in a
+context that expects a string.
+
+The following headers are currently supported:
+
+- [Accept](./README.md#accept)
+- [Accept-Encoding](./README.md#accept-encoding)
+- [Accept-Language](./README.md#accept-language)
+- [Cache-Control](./README.md#cache-control)
+- [Content-Disposition](./README.md#content-disposition)
+- [Content-Range](./README.md#content-range)
+- [Content-Type](./README.md#content-type)
+- [Cookie](./README.md#cookie)
+- [If-Match](./README.md#if-match)
+- [If-None-Match](./README.md#if-none-match)
+- [If-Range](./README.md#if-range)
+- [Range](./README.md#range)
+- [Set-Cookie](./README.md#set-cookie)
+- [Vary](./README.md#vary)
+
+### Accept
+
+Parse, manipulate and stringify
+[`Accept` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept).
+
+Implements `Map
`.
+
+```ts
+import { Accept } from 'remix/headers'
+
+// Parse from headers
+let accept = Accept.from(request.headers.get('accept'))
+
+accept.mediaTypes // ['text/html', 'text/*']
+accept.weights // [1, 0.9]
+accept.accepts('text/html') // true
+accept.accepts('text/plain') // true (matches text/*)
+accept.accepts('image/jpeg') // false
+accept.getWeight('text/plain') // 1 (matches text/*)
+accept.getPreferred(['text/html', 'text/plain']) // 'text/html'
+
+// Iterate
+for (let [mediaType, quality] of accept) {
+ // ...
+}
+
+// Modify and set header
+accept.set('application/json', 0.8)
+accept.delete('text/*')
+headers.set('Accept', accept)
+
+// Construct directly
+new Accept('text/html, text/*;q=0.9')
+new Accept({ 'text/html': 1, 'text/*': 0.9 })
+new Accept(['text/html', ['text/*', 0.9]])
+
+// Use class for type safety when setting Headers values
+// via Accept's `.toString()` method
+let headers = new Headers({
+ Accept: new Accept({ 'text/html': 1, 'application/json': 0.8 }),
+})
+headers.set('Accept', new Accept({ 'text/html': 1, 'application/json': 0.8 }))
+```
+
+### Accept-Encoding
+
+Parse, manipulate and stringify
+[`Accept-Encoding` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding).
+
+Implements `Map`.
+
+```ts
+import { AcceptEncoding } from 'remix/headers'
+
+// Parse from headers
+let acceptEncoding = AcceptEncoding.from(request.headers.get('accept-encoding'))
+
+acceptEncoding.encodings // ['gzip', 'deflate']
+acceptEncoding.weights // [1, 0.8]
+acceptEncoding.accepts('gzip') // true
+acceptEncoding.accepts('br') // false
+acceptEncoding.getWeight('gzip') // 1
+acceptEncoding.getPreferred(['gzip', 'deflate', 'br']) // 'gzip'
+
+// Modify and set header
+acceptEncoding.set('br', 1)
+acceptEncoding.delete('deflate')
+headers.set('Accept-Encoding', acceptEncoding)
+
+// Construct directly
+new AcceptEncoding('gzip, deflate;q=0.8')
+new AcceptEncoding({ gzip: 1, deflate: 0.8 })
+
+// Use class for type safety when setting Headers values
+// via AcceptEncoding's `.toString()` method
+let headers = new Headers({
+ 'Accept-Encoding': new AcceptEncoding({ gzip: 1, br: 0.9 }),
+})
+headers.set('Accept-Encoding', new AcceptEncoding({ gzip: 1, br: 0.9 }))
+```
+
+### Accept-Language
+
+Parse, manipulate and stringify
+[`Accept-Language` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language).
+
+Implements `Map`.
+
+```ts
+import { AcceptLanguage } from 'remix/headers'
+
+// Parse from headers
+let acceptLanguage = AcceptLanguage.from(request.headers.get('accept-language'))
+
+acceptLanguage.languages // ['en-us', 'en']
+acceptLanguage.weights // [1, 0.9]
+acceptLanguage.accepts('en-US') // true
+acceptLanguage.accepts('en-GB') // true (matches en)
+acceptLanguage.getWeight('en-GB') // 1 (matches en)
+acceptLanguage.getPreferred(['en-US', 'en-GB', 'fr']) // 'en-US'
+
+// Modify and set header
+acceptLanguage.set('fr', 0.5)
+acceptLanguage.delete('en')
+headers.set('Accept-Language', acceptLanguage)
+
+// Construct directly
+new AcceptLanguage('en-US, en;q=0.9')
+new AcceptLanguage({ 'en-US': 1, en: 0.9 })
+
+// Use class for type safety when setting Headers values
+// via AcceptLanguage's `.toString()` method
+let headers = new Headers({
+ 'Accept-Language': new AcceptLanguage({ 'en-US': 1, fr: 0.5 }),
+})
+headers.set('Accept-Language', new AcceptLanguage({ 'en-US': 1, fr: 0.5 }))
+```
+
+### Cache-Control
+
+Parse, manipulate and stringify
+[`Cache-Control` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control).
+
+```ts
+import { CacheControl } from 'remix/headers'
+
+// Parse from headers
+let cacheControl = CacheControl.from(response.headers.get('cache-control'))
+
+cacheControl.public // true
+cacheControl.maxAge // 3600
+cacheControl.sMaxage // 7200
+cacheControl.noCache // undefined
+cacheControl.noStore // undefined
+cacheControl.noTransform // undefined
+cacheControl.mustRevalidate // undefined
+cacheControl.immutable // undefined
+
+// Modify and set header
+cacheControl.maxAge = 7200
+cacheControl.immutable = true
+headers.set('Cache-Control', cacheControl)
+
+// Construct directly
+new CacheControl('public, max-age=3600')
+new CacheControl({ public: true, maxAge: 3600 })
+
+// Use class for type safety when setting Headers values
+// via CacheControl's `.toString()` method
+let headers = new Headers({
+ 'Cache-Control': new CacheControl({ public: true, maxAge: 3600 }),
+})
+headers.set('Cache-Control', new CacheControl({ public: true, maxAge: 3600 }))
+```
+
+### Content-Disposition
+
+Parse, manipulate and stringify
+[`Content-Disposition` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition).
+
+```ts
+import { ContentDisposition } from 'remix/headers'
+
+// Parse from headers
+let contentDisposition = ContentDisposition.from(
+ response.headers.get('content-disposition'),
+)
+
+contentDisposition.type // 'attachment'
+contentDisposition.filename // 'example.pdf'
+contentDisposition.filenameSplat // "UTF-8''%E4%BE%8B%E5%AD%90.pdf"
+contentDisposition.preferredFilename // '例子.pdf' (decoded from filename*)
+
+// Modify and set header
+contentDisposition.filename = 'download.pdf'
+headers.set('Content-Disposition', contentDisposition)
+
+// Construct directly
+new ContentDisposition('attachment; filename="example.pdf"')
+new ContentDisposition({ type: 'attachment', filename: 'example.pdf' })
+
+// Use class for type safety when setting Headers values
+// via ContentDisposition's `.toString()` method
+let headers = new Headers({
+ 'Content-Disposition': new ContentDisposition({
+ type: 'attachment',
+ filename: 'example.pdf',
+ }),
+})
+headers.set(
+ 'Content-Disposition',
+ new ContentDisposition({ type: 'attachment', filename: 'example.pdf' }),
+)
+```
+
+### Content-Range
+
+Parse, manipulate and stringify
+[`Content-Range` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range).
+
+```ts
+import { ContentRange } from 'remix/headers'
+
+// Parse from headers
+let contentRange = ContentRange.from(response.headers.get('content-range'))
+
+contentRange.unit // "bytes"
+contentRange.start // 200
+contentRange.end // 1000
+contentRange.size // 67589
+
+// Unsatisfied range
+let unsatisfied = ContentRange.from('bytes */67589')
+unsatisfied.start // null
+unsatisfied.end // null
+unsatisfied.size // 67589
+
+// Construct directly
+new ContentRange({ unit: 'bytes', start: 0, end: 499, size: 1000 })
+
+// Use class for type safety when setting Headers values
+// via ContentRange's `.toString()` method
+let headers = new Headers({
+ 'Content-Range': new ContentRange({
+ unit: 'bytes',
+ start: 0,
+ end: 499,
+ size: 1000,
+ }),
+})
+headers.set(
+ 'Content-Range',
+ new ContentRange({ unit: 'bytes', start: 0, end: 499, size: 1000 }),
+)
+```
+
+### Content-Type
+
+Parse, manipulate and stringify
+[`Content-Type` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type).
+
+```ts
+import { ContentType } from 'remix/headers'
+
+// Parse from headers
+let contentType = ContentType.from(request.headers.get('content-type'))
+
+contentType.mediaType // "text/html"
+contentType.charset // "utf-8"
+contentType.boundary // undefined (or boundary string for multipart)
+
+// Modify and set header
+contentType.charset = 'iso-8859-1'
+headers.set('Content-Type', contentType)
+
+// Construct directly
+new ContentType('text/html; charset=utf-8')
+new ContentType({ mediaType: 'text/html', charset: 'utf-8' })
+
+// Use class for type safety when setting Headers values
+// via ContentType's `.toString()` method
+let headers = new Headers({
+ 'Content-Type': new ContentType({ mediaType: 'text/html', charset: 'utf-8' }),
+})
+headers.set(
+ 'Content-Type',
+ new ContentType({ mediaType: 'text/html', charset: 'utf-8' }),
+)
+```
+
+### Cookie
+
+Parse, manipulate and stringify
+[`Cookie` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie).
+
+Implements `Map`.
+
+```ts
+import { Cookie } from 'remix/headers'
+
+// Parse from headers
+let cookie = Cookie.from(request.headers.get('cookie'))
+
+cookie.get('session_id') // 'abc123'
+cookie.get('theme') // 'dark'
+cookie.has('session_id') // true
+cookie.size // 2
+
+// Iterate
+for (let [name, value] of cookie) {
+ // ...
+}
+
+// Modify and set header
+cookie.set('theme', 'light')
+cookie.delete('session_id')
+headers.set('Cookie', cookie)
+
+// Construct directly
+new Cookie('session_id=abc123; theme=dark')
+new Cookie({ session_id: 'abc123', theme: 'dark' })
+new Cookie([
+ ['session_id', 'abc123'],
+ ['theme', 'dark'],
+])
+
+// Use class for type safety when setting Headers values
+// via Cookie's `.toString()` method
+let headers = new Headers({
+ Cookie: new Cookie({ session_id: 'abc123', theme: 'dark' }),
+})
+headers.set('Cookie', new Cookie({ session_id: 'abc123', theme: 'dark' }))
+```
+
+### If-Match
+
+Parse, manipulate and stringify
+[`If-Match` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match).
+
+Implements `Set`.
+
+```ts
+import { IfMatch } from 'remix/headers'
+
+// Parse from headers
+let ifMatch = IfMatch.from(request.headers.get('if-match'))
+
+ifMatch.tags // ['"67ab43"', '"54ed21"']
+ifMatch.has('"67ab43"') // true
+ifMatch.matches('"67ab43"') // true (checks precondition)
+ifMatch.matches('"abc123"') // false
+
+// Note: Uses strong comparison only (weak ETags never match)
+let weak = IfMatch.from('W/"67ab43"')
+weak.matches('W/"67ab43"') // false
+
+// Modify and set header
+ifMatch.add('"newetag"')
+ifMatch.delete('"67ab43"')
+headers.set('If-Match', ifMatch)
+
+// Construct directly
+new IfMatch(['abc123', 'def456'])
+
+// Use class for type safety when setting Headers values
+// via IfMatch's `.toString()` method
+let headers = new Headers({
+ 'If-Match': new IfMatch(['"abc123"', '"def456"']),
+})
+headers.set('If-Match', new IfMatch(['"abc123"', '"def456"']))
+```
+
+### If-None-Match
+
+Parse, manipulate and stringify
+[`If-None-Match` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match).
+
+Implements `Set`.
+
+```ts
+import { IfNoneMatch } from 'remix/headers'
+
+// Parse from headers
+let ifNoneMatch = IfNoneMatch.from(request.headers.get('if-none-match'))
+
+ifNoneMatch.tags // ['"67ab43"', '"54ed21"']
+ifNoneMatch.has('"67ab43"') // true
+ifNoneMatch.matches('"67ab43"') // true
+
+// Supports weak comparison (unlike If-Match)
+let weak = IfNoneMatch.from('W/"67ab43"')
+weak.matches('W/"67ab43"') // true
+
+// Modify and set header
+ifNoneMatch.add('"newetag"')
+ifNoneMatch.delete('"67ab43"')
+headers.set('If-None-Match', ifNoneMatch)
+
+// Construct directly
+new IfNoneMatch(['abc123'])
+
+// Use class for type safety when setting Headers values
+// via IfNoneMatch's `.toString()` method
+let headers = new Headers({
+ 'If-None-Match': new IfNoneMatch(['"abc123"']),
+})
+headers.set('If-None-Match', new IfNoneMatch(['"abc123"']))
+```
+
+### If-Range
+
+Parse, manipulate and stringify
+[`If-Range` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range).
+
+```ts
+import { IfRange } from 'remix/headers'
+
+// Parse from headers
+let ifRange = IfRange.from(request.headers.get('if-range'))
+
+// With HTTP date
+ifRange.matches({ lastModified: 1609459200000 }) // true
+ifRange.matches({ lastModified: new Date('2021-01-01') }) // true
+
+// With ETag
+let etagHeader = IfRange.from('"67ab43"')
+etagHeader.matches({ etag: '"67ab43"' }) // true
+
+// Empty/null returns empty instance (range proceeds unconditionally)
+let empty = IfRange.from(null)
+empty.matches({ etag: '"any"' }) // true
+
+// Construct directly
+new IfRange('"abc123"')
+
+// Use class for type safety when setting Headers values
+// via IfRange's `.toString()` method
+let headers = new Headers({
+ 'If-Range': new IfRange('"abc123"'),
+})
+headers.set('If-Range', new IfRange('"abc123"'))
+```
+
+### Range
+
+Parse, manipulate and stringify
+[`Range` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range).
+
+```ts
+import { Range } from 'remix/headers'
+
+// Parse from headers
+let range = Range.from(request.headers.get('range'))
+
+range.unit // "bytes"
+range.ranges // [{ start: 200, end: 1000 }]
+range.canSatisfy(2000) // true
+range.canSatisfy(500) // false
+range.normalize(2000) // [{ start: 200, end: 1000 }]
+
+// Multiple ranges
+let multi = Range.from('bytes=0-499, 1000-1499')
+multi.ranges.length // 2
+
+// Suffix range (last N bytes)
+let suffix = Range.from('bytes=-500')
+suffix.normalize(2000) // [{ start: 1500, end: 1999 }]
+
+// Construct directly
+new Range({ unit: 'bytes', ranges: [{ start: 0, end: 999 }] })
+
+// Use class for type safety when setting Headers values
+// via Range's `.toString()` method
+let headers = new Headers({
+ Range: new Range({ unit: 'bytes', ranges: [{ start: 0, end: 999 }] }),
+})
+headers.set(
+ 'Range',
+ new Range({ unit: 'bytes', ranges: [{ start: 0, end: 999 }] }),
+)
+```
+
+### Set-Cookie
+
+Parse, manipulate and stringify
+[`Set-Cookie` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie).
+
+```ts
+import { SetCookie } from 'remix/headers'
+
+// Parse from headers
+let setCookie = SetCookie.from(response.headers.get('set-cookie'))
+
+setCookie.name // "session_id"
+setCookie.value // "abc"
+setCookie.path // "/"
+setCookie.httpOnly // true
+setCookie.secure // true
+setCookie.domain // undefined
+setCookie.maxAge // undefined
+setCookie.expires // undefined
+setCookie.sameSite // undefined
+
+// Modify and set header
+setCookie.maxAge = 3600
+setCookie.sameSite = 'Strict'
+headers.set('Set-Cookie', setCookie)
+
+// Construct directly
+new SetCookie('session_id=abc; Path=/; HttpOnly; Secure')
+new SetCookie({
+ name: 'session_id',
+ value: 'abc',
+ path: '/',
+ httpOnly: true,
+ secure: true,
+})
+
+// Use class for type safety when setting Headers values
+// via SetCookie's `.toString()` method
+let headers = new Headers({
+ 'Set-Cookie': new SetCookie({
+ name: 'session_id',
+ value: 'abc',
+ httpOnly: true,
+ }),
+})
+headers.set(
+ 'Set-Cookie',
+ new SetCookie({ name: 'session_id', value: 'abc', httpOnly: true }),
+)
+```
+
+### Vary
+
+Parse, manipulate and stringify
+[`Vary` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary).
+
+Implements `Set`.
+
+```ts
+import { Vary } from 'remix/headers'
+
+// Parse from headers
+let vary = Vary.from(response.headers.get('vary'))
+
+vary.headerNames // ['accept-encoding', 'accept-language']
+vary.has('Accept-Encoding') // true (case-insensitive)
+vary.size // 2
+
+// Modify and set header
+vary.add('User-Agent')
+vary.delete('Accept-Language')
+headers.set('Vary', vary)
+
+// Construct directly
+new Vary('Accept-Encoding, Accept-Language')
+new Vary(['Accept-Encoding', 'Accept-Language'])
+new Vary({ headerNames: ['Accept-Encoding', 'Accept-Language'] })
+
+// Use class for type safety when setting Headers values
+// via Vary's `.toString()` method
+let headers = new Headers({
+ Vary: new Vary(['Accept-Encoding', 'Accept-Language']),
+})
+headers.set('Vary', new Vary(['Accept-Encoding', 'Accept-Language']))
+```
+
+## Raw Headers
+
+Parse and stringify raw HTTP header strings.
+
+```ts
+import { parse, stringify } from 'remix/headers'
+
+let headers = parse('Content-Type: text/html\r\nCache-Control: no-cache')
+headers.get('content-type') // 'text/html'
+headers.get('cache-control') // 'no-cache'
+
+stringify(headers)
+// 'Content-Type: text/html\r\nCache-Control: no-cache'
+```
+
+## Related Packages
+
+- [`fetch-proxy`](https://github.com/remix-run/remix/tree/main/packages/fetch-proxy) -
+ Build HTTP proxy servers using the web fetch API
+- [`node-fetch-server`](https://github.com/remix-run/remix/tree/main/packages/node-fetch-server) -
+ Build HTTP servers on Node.js using the web fetch API
+
+## License
+
+See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
diff --git a/docs/agents/remix/headers/accept-headers.md b/docs/agents/remix/headers/accept-headers.md
deleted file mode 100644
index bf2b9a0..0000000
--- a/docs/agents/remix/headers/accept-headers.md
+++ /dev/null
@@ -1,131 +0,0 @@
-# Accept headers
-
-Source: https://github.com/remix-run/remix/tree/main/packages/headers
-
-Each supported header has a class that represents the header value. Use the
-static `from()` method to parse header values. Each class has a `toString()`
-method that returns the header value as a string.
-
-## Accept
-
-Parse, manipulate and stringify
-[`Accept` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept).
-
-Implements `Map`.
-
-```ts
-import { Accept } from '@remix-run/headers'
-
-// Parse from headers
-let accept = Accept.from(request.headers.get('accept'))
-
-accept.mediaTypes // ['text/html', 'text/*']
-accept.weights // [1, 0.9]
-accept.accepts('text/html') // true
-accept.accepts('text/plain') // true (matches text/*)
-accept.accepts('image/jpeg') // false
-accept.getWeight('text/plain') // 1 (matches text/*)
-accept.getPreferred(['text/html', 'text/plain']) // 'text/html'
-
-// Iterate
-for (let [mediaType, quality] of accept) {
- // ...
-}
-
-// Modify and set header
-accept.set('application/json', 0.8)
-accept.delete('text/*')
-headers.set('Accept', accept)
-
-// Construct directly
-new Accept('text/html, text/*;q=0.9')
-new Accept({ 'text/html': 1, 'text/*': 0.9 })
-new Accept(['text/html', ['text/*', 0.9]])
-
-// Use class for type safety when setting Headers values
-// via Accept's `.toString()` method
-let headers = new Headers({
- Accept: new Accept({ 'text/html': 1, 'application/json': 0.8 }),
-})
-headers.set('Accept', new Accept({ 'text/html': 1, 'application/json': 0.8 }))
-```
-
-## Accept-Encoding
-
-Parse, manipulate and stringify
-[`Accept-Encoding` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding).
-
-Implements `Map`.
-
-```ts
-import { AcceptEncoding } from '@remix-run/headers'
-
-// Parse from headers
-let acceptEncoding = AcceptEncoding.from(request.headers.get('accept-encoding'))
-
-acceptEncoding.encodings // ['gzip', 'deflate']
-acceptEncoding.weights // [1, 0.8]
-acceptEncoding.accepts('gzip') // true
-acceptEncoding.accepts('br') // false
-acceptEncoding.getWeight('gzip') // 1
-acceptEncoding.getPreferred(['gzip', 'deflate', 'br']) // 'gzip'
-
-// Modify and set header
-acceptEncoding.set('br', 1)
-acceptEncoding.delete('deflate')
-headers.set('Accept-Encoding', acceptEncoding)
-
-// Construct directly
-new AcceptEncoding('gzip, deflate;q=0.8')
-new AcceptEncoding({ gzip: 1, deflate: 0.8 })
-
-// Use class for type safety when setting Headers values
-// via AcceptEncoding's `.toString()` method
-let headers = new Headers({
- 'Accept-Encoding': new AcceptEncoding({ gzip: 1, br: 0.9 }),
-})
-headers.set('Accept-Encoding', new AcceptEncoding({ gzip: 1, br: 0.9 }))
-```
-
-## Accept-Language
-
-Parse, manipulate and stringify
-[`Accept-Language` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language).
-
-Implements `Map`.
-
-```ts
-import { AcceptLanguage } from '@remix-run/headers'
-
-// Parse from headers
-let acceptLanguage = AcceptLanguage.from(request.headers.get('accept-language'))
-
-acceptLanguage.languages // ['en-us', 'en']
-acceptLanguage.weights // [1, 0.9]
-acceptLanguage.accepts('en-US') // true
-acceptLanguage.accepts('en-GB') // true (matches en)
-acceptLanguage.getWeight('en-GB') // 1 (matches en)
-acceptLanguage.getPreferred(['en-US', 'en-GB', 'fr']) // 'en-US'
-
-// Modify and set header
-acceptLanguage.set('fr', 0.5)
-acceptLanguage.delete('en')
-headers.set('Accept-Language', acceptLanguage)
-
-// Construct directly
-new AcceptLanguage('en-US, en;q=0.9')
-new AcceptLanguage({ 'en-US': 1, en: 0.9 })
-
-// Use class for type safety when setting Headers values
-// via AcceptLanguage's `.toString()` method
-let headers = new Headers({
- 'Accept-Language': new AcceptLanguage({ 'en-US': 1, fr: 0.5 }),
-})
-headers.set('Accept-Language', new AcceptLanguage({ 'en-US': 1, fr: 0.5 }))
-```
-
-## Navigation
-
-- [Headers overview](./index.md)
-- [Content and cache headers](./content-headers.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/headers/conditional-headers.md b/docs/agents/remix/headers/conditional-headers.md
deleted file mode 100644
index 1dd4cbf..0000000
--- a/docs/agents/remix/headers/conditional-headers.md
+++ /dev/null
@@ -1,192 +0,0 @@
-# Conditionals and ranges
-
-Source: https://github.com/remix-run/remix/tree/main/packages/headers
-
-## If-Match
-
-Parse, manipulate and stringify
-[`If-Match` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match).
-
-Implements `Set`.
-
-```ts
-import { IfMatch } from '@remix-run/headers'
-
-// Parse from headers
-let ifMatch = IfMatch.from(request.headers.get('if-match'))
-
-ifMatch.tags // ['"67ab43"', '"54ed21"']
-ifMatch.has('"67ab43"') // true
-ifMatch.matches('"67ab43"') // true (checks precondition)
-ifMatch.matches('"abc123"') // false
-
-// Note: Uses strong comparison only (weak ETags never match)
-let weak = IfMatch.from('W/"67ab43"')
-weak.matches('W/"67ab43"') // false
-
-// Modify and set header
-ifMatch.add('"newetag"')
-ifMatch.delete('"67ab43"')
-headers.set('If-Match', ifMatch)
-
-// Construct directly
-new IfMatch(['abc123', 'def456'])
-
-// Use class for type safety when setting Headers values
-// via IfMatch's `.toString()` method
-let headers = new Headers({
- 'If-Match': new IfMatch(['"abc123"', '"def456"']),
-})
-headers.set('If-Match', new IfMatch(['"abc123"', '"def456"']))
-```
-
-## If-None-Match
-
-Parse, manipulate and stringify
-[`If-None-Match` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match).
-
-Implements `Set`.
-
-```ts
-import { IfNoneMatch } from '@remix-run/headers'
-
-// Parse from headers
-let ifNoneMatch = IfNoneMatch.from(request.headers.get('if-none-match'))
-
-ifNoneMatch.tags // ['"67ab43"', '"54ed21"']
-ifNoneMatch.has('"67ab43"') // true
-ifNoneMatch.matches('"67ab43"') // true
-
-// Supports weak comparison (unlike If-Match)
-let weak = IfNoneMatch.from('W/"67ab43"')
-weak.matches('W/"67ab43"') // true
-
-// Modify and set header
-ifNoneMatch.add('"newetag"')
-ifNoneMatch.delete('"67ab43"')
-headers.set('If-None-Match', ifNoneMatch)
-
-// Construct directly
-new IfNoneMatch(['abc123'])
-
-// Use class for type safety when setting Headers values
-// via IfNoneMatch's `.toString()` method
-let headers = new Headers({
- 'If-None-Match': new IfNoneMatch(['"abc123"']),
-})
-headers.set('If-None-Match', new IfNoneMatch(['"abc123"']))
-```
-
-## If-Range
-
-Parse, manipulate and stringify
-[`If-Range` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range).
-
-```ts
-import { IfRange } from '@remix-run/headers'
-
-// Parse from headers
-let ifRange = IfRange.from(request.headers.get('if-range'))
-
-// With HTTP date
-ifRange.matches({ lastModified: 1609459200000 }) // true
-ifRange.matches({ lastModified: new Date('2021-01-01') }) // true
-
-// With ETag
-let etagHeader = IfRange.from('"67ab43"')
-etagHeader.matches({ etag: '"67ab43"' }) // true
-
-// Empty/null returns empty instance (range proceeds unconditionally)
-let empty = IfRange.from(null)
-empty.matches({ etag: '"any"' }) // true
-
-// Construct directly
-new IfRange('"abc123"')
-
-// Use class for type safety when setting Headers values
-// via IfRange's `.toString()` method
-let headers = new Headers({
- 'If-Range': new IfRange('"abc123"'),
-})
-headers.set('If-Range', new IfRange('"abc123"'))
-```
-
-## Range
-
-Parse, manipulate and stringify
-[`Range` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range).
-
-```ts
-import { Range } from '@remix-run/headers'
-
-// Parse from headers
-let range = Range.from(request.headers.get('range'))
-
-range.unit // "bytes"
-range.ranges // [{ start: 200, end: 1000 }]
-range.canSatisfy(2000) // true
-range.canSatisfy(500) // false
-range.normalize(2000) // [{ start: 200, end: 1000 }]
-
-// Multiple ranges
-let multi = Range.from('bytes=0-499, 1000-1499')
-multi.ranges.length // 2
-
-// Suffix range (last N bytes)
-let suffix = Range.from('bytes=-500')
-suffix.normalize(2000) // [{ start: 1500, end: 1999 }]
-
-// Construct directly
-new Range({ unit: 'bytes', ranges: [{ start: 0, end: 999 }] })
-
-// Use class for type safety when setting Headers values
-// via Range's `.toString()` method
-let headers = new Headers({
- Range: new Range({ unit: 'bytes', ranges: [{ start: 0, end: 999 }] }),
-})
-headers.set(
- 'Range',
- new Range({ unit: 'bytes', ranges: [{ start: 0, end: 999 }] }),
-)
-```
-
-## Vary
-
-Parse, manipulate and stringify
-[`Vary` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary).
-
-Implements `Set`.
-
-```ts
-import { Vary } from '@remix-run/headers'
-
-// Parse from headers
-let vary = Vary.from(response.headers.get('vary'))
-
-vary.headerNames // ['accept-encoding', 'accept-language']
-vary.has('Accept-Encoding') // true (case-insensitive)
-vary.size // 2
-
-// Modify and set header
-vary.add('User-Agent')
-vary.delete('Accept-Language')
-headers.set('Vary', vary)
-
-// Construct directly
-new Vary('Accept-Encoding, Accept-Language')
-new Vary(['Accept-Encoding', 'Accept-Language'])
-new Vary({ headerNames: ['Accept-Encoding', 'Accept-Language'] })
-
-// Use class for type safety when setting Headers values
-// via Vary's `.toString()` method
-let headers = new Headers({
- Vary: new Vary(['Accept-Encoding', 'Accept-Language']),
-})
-headers.set('Vary', new Vary(['Accept-Encoding', 'Accept-Language']))
-```
-
-## Navigation
-
-- [Headers overview](./index.md)
-- [Cookie headers](./cookie-headers.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/headers/content-headers.md b/docs/agents/remix/headers/content-headers.md
deleted file mode 100644
index f007953..0000000
--- a/docs/agents/remix/headers/content-headers.md
+++ /dev/null
@@ -1,161 +0,0 @@
-# Content and cache headers
-
-Source: https://github.com/remix-run/remix/tree/main/packages/headers
-
-## Cache-Control
-
-Parse, manipulate and stringify
-[`Cache-Control` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control).
-
-```ts
-import { CacheControl } from '@remix-run/headers'
-
-// Parse from headers
-let cacheControl = CacheControl.from(response.headers.get('cache-control'))
-
-cacheControl.public // true
-cacheControl.maxAge // 3600
-cacheControl.sMaxage // 7200
-cacheControl.noCache // undefined
-cacheControl.noStore // undefined
-cacheControl.noTransform // undefined
-cacheControl.mustRevalidate // undefined
-cacheControl.immutable // undefined
-
-// Modify and set header
-cacheControl.maxAge = 7200
-cacheControl.immutable = true
-headers.set('Cache-Control', cacheControl)
-
-// Construct directly
-new CacheControl('public, max-age=3600')
-new CacheControl({ public: true, maxAge: 3600 })
-
-// Use class for type safety when setting Headers values
-// via CacheControl's `.toString()` method
-let headers = new Headers({
- 'Cache-Control': new CacheControl({ public: true, maxAge: 3600 }),
-})
-headers.set('Cache-Control', new CacheControl({ public: true, maxAge: 3600 }))
-```
-
-## Content-Disposition
-
-Parse, manipulate and stringify
-[`Content-Disposition` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition).
-
-```ts
-import { ContentDisposition } from '@remix-run/headers'
-
-// Parse from headers
-let contentDisposition = ContentDisposition.from(
- response.headers.get('content-disposition'),
-)
-
-contentDisposition.type // 'attachment'
-contentDisposition.filename // 'example.pdf'
-contentDisposition.filenameSplat // "UTF-8''example.pdf"
-contentDisposition.preferredFilename // 'example.pdf' (decoded from filename*)
-
-// Modify and set header
-contentDisposition.filename = 'download.pdf'
-headers.set('Content-Disposition', contentDisposition)
-
-// Construct directly
-new ContentDisposition('attachment; filename="example.pdf"')
-new ContentDisposition({ type: 'attachment', filename: 'example.pdf' })
-
-// Use class for type safety when setting Headers values
-// via ContentDisposition's `.toString()` method
-let headers = new Headers({
- 'Content-Disposition': new ContentDisposition({
- type: 'attachment',
- filename: 'example.pdf',
- }),
-})
-headers.set(
- 'Content-Disposition',
- new ContentDisposition({ type: 'attachment', filename: 'example.pdf' }),
-)
-```
-
-## Content-Range
-
-Parse, manipulate and stringify
-[`Content-Range` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range).
-
-```ts
-import { ContentRange } from '@remix-run/headers'
-
-// Parse from headers
-let contentRange = ContentRange.from(response.headers.get('content-range'))
-
-contentRange.unit // "bytes"
-contentRange.start // 200
-contentRange.end // 1000
-contentRange.size // 67589
-
-// Unsatisfied range
-let unsatisfied = ContentRange.from('bytes */67589')
-unsatisfied.start // null
-unsatisfied.end // null
-unsatisfied.size // 67589
-
-// Construct directly
-new ContentRange({ unit: 'bytes', start: 0, end: 499, size: 1000 })
-
-// Use class for type safety when setting Headers values
-// via ContentRange's `.toString()` method
-let headers = new Headers({
- 'Content-Range': new ContentRange({
- unit: 'bytes',
- start: 0,
- end: 499,
- size: 1000,
- }),
-})
-headers.set(
- 'Content-Range',
- new ContentRange({ unit: 'bytes', start: 0, end: 499, size: 1000 }),
-)
-```
-
-## Content-Type
-
-Parse, manipulate and stringify
-[`Content-Type` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type).
-
-```ts
-import { ContentType } from '@remix-run/headers'
-
-// Parse from headers
-let contentType = ContentType.from(request.headers.get('content-type'))
-
-contentType.mediaType // "text/html"
-contentType.charset // "utf-8"
-contentType.boundary // undefined (or boundary string for multipart)
-
-// Modify and set header
-contentType.charset = 'iso-8859-1'
-headers.set('Content-Type', contentType)
-
-// Construct directly
-new ContentType('text/html; charset=utf-8')
-new ContentType({ mediaType: 'text/html', charset: 'utf-8' })
-
-// Use class for type safety when setting Headers values
-// via ContentType's `.toString()` method
-let headers = new Headers({
- 'Content-Type': new ContentType({ mediaType: 'text/html', charset: 'utf-8' }),
-})
-headers.set(
- 'Content-Type',
- new ContentType({ mediaType: 'text/html', charset: 'utf-8' }),
-)
-```
-
-## Navigation
-
-- [Headers overview](./index.md)
-- [Accept headers](./accept-headers.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/headers/cookie-headers.md b/docs/agents/remix/headers/cookie-headers.md
deleted file mode 100644
index 65e4fa3..0000000
--- a/docs/agents/remix/headers/cookie-headers.md
+++ /dev/null
@@ -1,104 +0,0 @@
-# Cookie headers
-
-Source: https://github.com/remix-run/remix/tree/main/packages/headers
-
-## Cookie
-
-Parse, manipulate and stringify
-[`Cookie` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie).
-
-Implements `Map`.
-
-```ts
-import { Cookie } from '@remix-run/headers'
-
-// Parse from headers
-let cookie = Cookie.from(request.headers.get('cookie'))
-
-cookie.get('session_id') // 'abc123'
-cookie.get('theme') // 'dark'
-cookie.has('session_id') // true
-cookie.size // 2
-
-// Iterate
-for (let [name, value] of cookie) {
- // ...
-}
-
-// Modify and set header
-cookie.set('theme', 'light')
-cookie.delete('session_id')
-headers.set('Cookie', cookie)
-
-// Construct directly
-new Cookie('session_id=abc123; theme=dark')
-new Cookie({ session_id: 'abc123', theme: 'dark' })
-new Cookie([
- ['session_id', 'abc123'],
- ['theme', 'dark'],
-])
-
-// Use class for type safety when setting Headers values
-// via Cookie's `.toString()` method
-let headers = new Headers({
- Cookie: new Cookie({ session_id: 'abc123', theme: 'dark' }),
-})
-headers.set('Cookie', new Cookie({ session_id: 'abc123', theme: 'dark' }))
-```
-
-## Set-Cookie
-
-Parse, manipulate and stringify
-[`Set-Cookie` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie).
-
-```ts
-import { SetCookie } from '@remix-run/headers'
-
-// Parse from headers
-let setCookie = SetCookie.from(response.headers.get('set-cookie'))
-
-setCookie.name // "session_id"
-setCookie.value // "abc"
-setCookie.path // "/"
-setCookie.httpOnly // true
-setCookie.secure // true
-setCookie.domain // undefined
-setCookie.maxAge // undefined
-setCookie.expires // undefined
-setCookie.sameSite // undefined
-
-// Modify and set header
-setCookie.maxAge = 3600
-setCookie.sameSite = 'Strict'
-headers.set('Set-Cookie', setCookie)
-
-// Construct directly
-new SetCookie('session_id=abc; Path=/; HttpOnly; Secure')
-new SetCookie({
- name: 'session_id',
- value: 'abc',
- path: '/',
- httpOnly: true,
- secure: true,
-})
-
-// Use class for type safety when setting Headers values
-// via SetCookie's `.toString()` method
-let headers = new Headers({
- 'Set-Cookie': new SetCookie({
- name: 'session_id',
- value: 'abc',
- httpOnly: true,
- }),
-})
-headers.set(
- 'Set-Cookie',
- new SetCookie({ name: 'session_id', value: 'abc', httpOnly: true }),
-)
-```
-
-## Navigation
-
-- [Headers overview](./index.md)
-- [Conditionals and ranges](./conditional-headers.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/headers/index.md b/docs/agents/remix/headers/index.md
deleted file mode 100644
index ad5586d..0000000
--- a/docs/agents/remix/headers/index.md
+++ /dev/null
@@ -1,30 +0,0 @@
-# headers
-
-Source: https://github.com/remix-run/remix/tree/main/packages/headers
-
-## Overview
-
-Utilities for parsing, manipulating and stringifying HTTP header values.
-
-HTTP headers contain critical information - from content negotiation and caching
-directives to authentication tokens and file metadata. While the native
-`Headers` API provides a basic string-based interface, it leaves the
-complexities of parsing specific header formats entirely up to you.
-
-## Installation
-
-```sh
-bun add @remix-run/headers
-```
-
-## Header utilities
-
-- Accept headers: [accept-headers](./accept-headers.md)
-- Content and cache headers: [content-headers](./content-headers.md)
-- Cookies: [cookie-headers](./cookie-headers.md)
-- Conditionals and ranges: [conditional-headers](./conditional-headers.md)
-- Raw header parsing: [raw-headers](./raw-headers.md)
-
-## Navigation
-
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/headers/raw-headers.md b/docs/agents/remix/headers/raw-headers.md
deleted file mode 100644
index eeea369..0000000
--- a/docs/agents/remix/headers/raw-headers.md
+++ /dev/null
@@ -1,34 +0,0 @@
-# Raw header parsing
-
-Source: https://github.com/remix-run/remix/tree/main/packages/headers
-
-## Raw headers
-
-Parse and stringify raw HTTP header strings.
-
-```ts
-import { parse, stringify } from '@remix-run/headers'
-
-let headers = parse('Content-Type: text/html\\r\\nCache-Control: no-cache')
-headers.get('content-type') // 'text/html'
-headers.get('cache-control') // 'no-cache'
-
-stringify(headers)
-// 'Content-Type: text/html\\r\\nCache-Control: no-cache'
-```
-
-## Related packages
-
-- [`fetch-proxy`](https://github.com/remix-run/remix/tree/main/packages/fetch-proxy) -
- Build HTTP proxy servers using the web fetch API
-- [`node-fetch-server`](https://github.com/remix-run/remix/tree/main/packages/node-fetch-server) -
- Build HTTP servers on Node.js using the web fetch API
-
-## License
-
-See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Headers overview](./index.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/html-template.md b/docs/agents/remix/html-template/README.md
similarity index 69%
rename from docs/agents/remix/html-template.md
rename to docs/agents/remix/html-template/README.md
index b9cd9f7..d4aef15 100644
--- a/docs/agents/remix/html-template.md
+++ b/docs/agents/remix/html-template/README.md
@@ -1,19 +1,8 @@
# html-template
-Source: https://github.com/remix-run/remix/tree/main/packages/html-template
-
-## README
-
-Safe HTML template tag with auto-escaping for JavaScript.
-
-Building HTML strings with user input is dangerous. Without proper escaping, you
-risk XSS (cross-site scripting) vulnerabilities where malicious code can be
-injected into your pages. Manual escaping is error-prone and easy to forget,
-especially when composing HTML from multiple sources.
-
-`html-template` provides a tagged template literal for safely constructing HTML
-strings with automatic escaping of interpolated values to prevent XSS
-vulnerabilities.
+Safe HTML template literals for Remix. `html-template` automatically escapes
+interpolated values to prevent XSS while still supporting explicit trusted HTML
+insertion.
## Features
@@ -28,16 +17,14 @@ vulnerabilities.
## Installation
-Install from [npm](https://www.npmjs.com/):
-
```sh
-bun add @remix-run/html-template
+npm i remix
```
## Usage
```ts
-import { html } from '@remix-run/html-template'
+import { html } from 'remix/html-template'
let userInput = ''
let greeting = html`Hello ${userInput}!
`
@@ -52,7 +39,7 @@ attacks.
If you have trusted HTML that should not be escaped, use `html.raw`:
```ts
-import { html } from '@remix-run/html-template'
+import { html } from 'remix/html-template'
let trustedIcon = ''
let button = html.raw``
@@ -69,7 +56,7 @@ input.
SafeHtml values can be nested without double-escaping:
```ts
-import { html } from '@remix-run/html-template'
+import { html } from 'remix/html-template'
let title = html`My Title
`
let content = html`Some content with ${userInput}
`
@@ -89,7 +76,7 @@ let page = html`
You can interpolate arrays of values, which will be flattened and joined:
```ts
-import { html } from '@remix-run/html-template'
+import { html } from 'remix/html-template'
let items = ['Apple', 'Banana', 'Cherry']
let list = html`
@@ -104,7 +91,7 @@ let list = html`
Use `null` or `undefined` to render nothing:
```ts
-import { html } from '@remix-run/html-template'
+import { html } from 'remix/html-template'
let showError = false
let errorMessage = 'Something went wrong'
@@ -121,7 +108,3 @@ let page = html`
## License
See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Remix package index](./index.md)
diff --git a/docs/agents/remix/index.md b/docs/agents/remix/index.md
index 4e2588d..c83cdd6 100644
--- a/docs/agents/remix/index.md
+++ b/docs/agents/remix/index.md
@@ -1,183 +1,50 @@
-# Remix packages
-
-Docs for every package in https://github.com/remix-run/remix/tree/main/packages.
-
-## Table of contents
-
-- [Start here](#start-here)
-- [UI and components](#ui-and-components)
-- [Routing and requests](#routing-and-requests)
-- [Data and SQL](#data-and-sql)
-- [Sessions and cookies](#sessions-and-cookies)
-- [Responses and headers](#responses-and-headers)
-- [Uploads and parsing](#uploads-and-parsing)
-- [Files and storage](#files-and-storage)
-- [Middleware and utilities](#middleware-and-utilities)
-- [Package map](#package-map)
-- [Update instructions](#update-instructions)
-
-## Start here
-
-- Building UI with Remix Component: [component](./component/index.md)
-- Routing and request handling: [fetch-router](./fetch-router/index.md) +
- [route-pattern](./route-pattern.md)
-- Sessions and cookies: [session](./session/index.md) +
- [session-middleware](./session-middleware.md) + [cookie](./cookie.md)
-- Responses, headers, and HTML safety: [response](./response/index.md) +
- [headers](./headers/index.md) + [html-template](./html-template.md)
-- Data validation and SQL tables: [data-schema](./data-schema.md) +
- [data-table](./data-table.md)
-- File upload pipelines: [form-data-middleware](./form-data-middleware.md) +
- [form-data-parser](./form-data-parser.md) +
- [multipart-parser](./multipart-parser/index.md)
-- File storage and streaming: [file-storage](./file-storage.md) +
- [file-storage-s3](./file-storage-s3.md) + [lazy-file](./lazy-file.md) +
- [fs](./fs.md)
-- Static assets and compression: [static-middleware](./static-middleware.md) +
- [compression-middleware](./compression-middleware/index.md)
-
-## epicflare adoption snapshot
-
-- Primary runtime packages in active use:
- - `remix/component`
- - `remix/fetch-router`
- - `remix/data-schema`
- - `remix/data-table`
-- D1 integration uses `remix/data-table` with a repository adapter
- (`worker/d1-data-table-adapter.ts`) instead of `remix/data-table-sqlite`.
-- Package coverage audit against installed `remix@3.0.0-alpha.3` top-level
- exports: no missing Remix package docs in this index.
-
-## UI and components
-
-- [component](./component/index.md)
- - [Getting started](./component/getting-started.md)
- - [Components](./component/components.md)
- - [Styling basics](./component/styling-basics.md)
- - [Animate basics](./component/animate-basics.md)
- - [Testing](./component/testing.md)
-- [interaction](./interaction/index.md)
- - [Event listeners and interactions](./interaction/listeners.md)
- - [Containers and disposal](./interaction/containers-and-disposal.md)
- - [Custom interactions and typed targets](./interaction/custom-interactions.md)
-
-## Routing and requests
-
-- [fetch-router](./fetch-router/index.md)
- - [Basic usage and route maps](./fetch-router/usage.md)
- - [Routing based on request method](./fetch-router/routing-methods.md)
- - [Resource-based routes](./fetch-router/routing-resources.md)
- - [Middleware and request context](./fetch-router/middleware.md)
-- [route-pattern](./route-pattern.md)
-- [node-fetch-server](./node-fetch-server/index.md)
- - [Quick start](./node-fetch-server/quick-start.md)
- - [Advanced usage](./node-fetch-server/advanced-usage.md)
- - [Migration from Express](./node-fetch-server/migration.md)
- - [Demos and benchmark](./node-fetch-server/demos-and-benchmark.md)
-- [fetch-proxy](./fetch-proxy.md)
-
-## Data and SQL
-
-- [data-schema](./data-schema.md)
-- [data-table](./data-table.md)
-- [data-table-postgres](./data-table-postgres.md)
-- [data-table-mysql](./data-table-mysql.md)
-- [data-table-sqlite](./data-table-sqlite.md)
-
-## Sessions and cookies
-
-- [session](./session/index.md)
- - [Flash data and security](./session/flash-and-security.md)
- - [Storage strategies](./session/storage-strategies.md)
- - [Related packages](./session/related.md)
-- [session-middleware](./session-middleware.md)
-- [session-storage-memcache](./session-storage-memcache.md)
-- [session-storage-redis](./session-storage-redis.md)
-- [cookie](./cookie.md)
-
-## Responses and headers
-
-- [response](./response/index.md)
- - [File responses](./response/file-responses.md)
- - [HTML responses](./response/html-responses.md)
- - [Redirect responses](./response/redirect-responses.md)
- - [Compressed responses](./response/compress-responses.md)
- - [Related packages](./response/related.md)
-- [headers](./headers/index.md)
- - [Accept headers](./headers/accept-headers.md)
- - [Content and cache headers](./headers/content-headers.md)
- - [Cookie headers](./headers/cookie-headers.md)
- - [Conditionals and ranges](./headers/conditional-headers.md)
- - [Raw header parsing](./headers/raw-headers.md)
-- [html-template](./html-template.md)
-
-## Uploads and parsing
-
-- [form-data-middleware](./form-data-middleware.md)
-- [form-data-parser](./form-data-parser.md)
-- [multipart-parser](./multipart-parser/index.md)
- - [Limits and Node bindings](./multipart-parser/limits-and-node.md)
- - [Low-level APIs](./multipart-parser/low-level.md)
- - [Benchmarks and related packages](./multipart-parser/benchmarks.md)
-
-## Files and storage
-
-- [file-storage](./file-storage.md)
-- [file-storage-s3](./file-storage-s3.md)
-- [lazy-file](./lazy-file.md)
-- [fs](./fs.md)
-- [tar-parser](./tar-parser.md)
-
-## Middleware and utilities
-
-- [compression-middleware](./compression-middleware/index.md)
- - [Options and configuration](./compression-middleware/options.md)
-- [static-middleware](./static-middleware.md)
-- [logger-middleware](./logger-middleware.md)
-- [method-override-middleware](./method-override-middleware.md)
-- [async-context-middleware](./async-context-middleware.md)
-- [mime](./mime.md)
-- [remix](./remix.md)
-
-## Package map
-
-| Package | Focus | Docs |
-| -------------------------- | ------------------------------------------ | ------------------------------------------------------------- |
-| async-context-middleware | AsyncLocalStorage context for fetch-router | [async-context-middleware](./async-context-middleware.md) |
-| component | Remix Component UI system | [component](./component/index.md) |
-| compression-middleware | Response compression for fetch-router | [compression-middleware](./compression-middleware/index.md) |
-| cookie | Cookie parsing, signing, and serialization | [cookie](./cookie.md) |
-| data-schema | Runtime validation and schema parsing | [data-schema](./data-schema.md) |
-| data-table | Typed SQL query toolkit | [data-table](./data-table.md) |
-| data-table-mysql | MySQL adapter for data-table | [data-table-mysql](./data-table-mysql.md) |
-| data-table-postgres | Postgres adapter for data-table | [data-table-postgres](./data-table-postgres.md) |
-| data-table-sqlite | SQLite adapter for data-table | [data-table-sqlite](./data-table-sqlite.md) |
-| fetch-proxy | Fetch-based HTTP proxy | [fetch-proxy](./fetch-proxy.md) |
-| fetch-router | Fetch-based router and middleware | [fetch-router](./fetch-router/index.md) |
-| file-storage | Storage abstraction for files | [file-storage](./file-storage.md) |
-| file-storage-s3 | S3 backend for file-storage | [file-storage-s3](./file-storage-s3.md) |
-| form-data-middleware | Request FormData middleware | [form-data-middleware](./form-data-middleware.md) |
-| form-data-parser | Streaming multipart/form-data parser | [form-data-parser](./form-data-parser.md) |
-| fs | Lazy file system utilities | [fs](./fs.md) |
-| headers | Header parsing and helpers | [headers](./headers/index.md) |
-| html-template | Safe HTML template tag | [html-template](./html-template.md) |
-| interaction | Event helpers and interactions | [interaction](./interaction/index.md) |
-| lazy-file | Streaming File/Blob implementation | [lazy-file](./lazy-file.md) |
-| logger-middleware | Request/response logging | [logger-middleware](./logger-middleware.md) |
-| method-override-middleware | HTML form method override | [method-override-middleware](./method-override-middleware.md) |
-| mime | MIME type utilities | [mime](./mime.md) |
-| multipart-parser | Streaming multipart parser | [multipart-parser](./multipart-parser/index.md) |
-| node-fetch-server | Fetch-based Node server | [node-fetch-server](./node-fetch-server/index.md) |
-| remix | Remix framework package | [remix](./remix.md) |
-| response | Response helpers | [response](./response/index.md) |
-| route-pattern | URL matching and href generation | [route-pattern](./route-pattern.md) |
-| session | Session management and storage | [session](./session/index.md) |
-| session-middleware | Session middleware for fetch-router | [session-middleware](./session-middleware.md) |
-| session-storage-memcache | Memcache storage adapter for sessions | [session-storage-memcache](./session-storage-memcache.md) |
-| session-storage-redis | Redis storage adapter for sessions | [session-storage-redis](./session-storage-redis.md) |
-| static-middleware | Static file middleware | [static-middleware](./static-middleware.md) |
-| tar-parser | Streaming tar parser | [tar-parser](./tar-parser.md) |
-
-## Update instructions
-
-See [update](./update.md) for how to sync this documentation from upstream.
+# Remix package docs
+
+Synced from `/tmp/remix-alpha4/packages` for `remix@3.0.0-alpha.4`.
+
+## Packages
+
+- [async-context-middleware](./async-context-middleware/README.md)
+- [auth](./auth/README.md)
+- [auth-middleware](./auth-middleware/README.md)
+- [component](./component/README.md) - 15 upstream docs files
+- [compression-middleware](./compression-middleware/README.md)
+- [cookie](./cookie/README.md)
+- [cop-middleware](./cop-middleware/README.md)
+- [cors-middleware](./cors-middleware/README.md)
+- [csrf-middleware](./csrf-middleware/README.md)
+- [data-schema](./data-schema/README.md)
+- [data-table](./data-table/README.md)
+- [data-table-mysql](./data-table-mysql/README.md)
+- [data-table-postgres](./data-table-postgres/README.md)
+- [data-table-sqlite](./data-table-sqlite/README.md)
+- [fetch-proxy](./fetch-proxy/README.md)
+- [fetch-router](./fetch-router/README.md)
+- [file-storage](./file-storage/README.md)
+- [file-storage-s3](./file-storage-s3/README.md)
+- [form-data-middleware](./form-data-middleware/README.md)
+- [form-data-parser](./form-data-parser/README.md)
+- [fs](./fs/README.md)
+- [headers](./headers/README.md)
+- [html-template](./html-template/README.md)
+- [lazy-file](./lazy-file/README.md)
+- [logger-middleware](./logger-middleware/README.md)
+- [method-override-middleware](./method-override-middleware/README.md)
+- [mime](./mime/README.md)
+- [multipart-parser](./multipart-parser/README.md)
+- [node-fetch-server](./node-fetch-server/README.md)
+- [remix](./remix/README.md)
+- [response](./response/README.md)
+- [route-pattern](./route-pattern/README.md)
+- [session](./session/README.md)
+- [session-middleware](./session-middleware/README.md)
+- [session-storage-memcache](./session-storage-memcache/README.md)
+- [session-storage-redis](./session-storage-redis/README.md)
+- [static-middleware](./static-middleware/README.md)
+- [tar-parser](./tar-parser/README.md)
+
+## Notes
+
+- Each package lives in its own directory with the upstream `README.md`.
+- Packages that ship extra markdown keep those files under `./
/docs/`.
+- See `update.md` for the local sync procedure.
diff --git a/docs/agents/remix/interaction/containers-and-disposal.md b/docs/agents/remix/interaction/containers-and-disposal.md
deleted file mode 100644
index 10c1116..0000000
--- a/docs/agents/remix/interaction/containers-and-disposal.md
+++ /dev/null
@@ -1,86 +0,0 @@
-# Containers and disposal
-
-Source: https://github.com/remix-run/remix/tree/main/packages/interaction
-
-## Updating listeners efficiently
-
-Use `createContainer` when you need to update listeners in place (e.g., in a
-component system). The container diffs and updates existing bindings without
-unnecessary `removeEventListener`/`addEventListener` churn.
-
-```ts
-import { createContainer } from '@remix-run/interaction'
-
-let container = createContainer(form)
-
-let formData = new FormData()
-
-container.set({
- change(event) {
- formData = new FormData(event.currentTarget)
- },
- async submit(event, signal) {
- event.preventDefault()
- await fetch('/save', { method: 'POST', body: formData, signal })
- },
-})
-
-// later - only the minimal necessary changes are rebound
-container.set({
- change(event) {
- console.log('different listener')
- },
- submit(event, signal) {
- console.log('different listener')
- },
-})
-```
-
-## Disposing listeners
-
-`on` returns a dispose function. Containers expose `dispose()`. You can also
-pass an external `AbortSignal`.
-
-```ts
-import { on, createContainer } from '@remix-run/interaction'
-
-// Using the function returned from on()
-let dispose = on(button, { click: () => {} })
-dispose()
-
-// Containers
-let container = createContainer(window)
-container.set({ resize: () => {} })
-container.dispose()
-
-// Use a signal
-let eventsController = new AbortController()
-let container = createContainer(window, {
- signal: eventsController.signal,
-})
-container.set({ resize: () => {} })
-eventsController.abort()
-```
-
-## Stop propagation semantics
-
-All DOM semantics are preserved.
-
-```ts
-on(button, {
- click: [
- (event) => {
- event.stopImmediatePropagation()
- },
- () => {
- // not called
- },
- ],
-})
-```
-
-## Navigation
-
-- [Event listeners and interactions](./listeners.md)
-- [interaction overview](./index.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/interaction/custom-interactions.md b/docs/agents/remix/interaction/custom-interactions.md
deleted file mode 100644
index c47f854..0000000
--- a/docs/agents/remix/interaction/custom-interactions.md
+++ /dev/null
@@ -1,120 +0,0 @@
-# Custom interactions and typed targets
-
-Source: https://github.com/remix-run/remix/tree/main/packages/interaction
-
-## Custom interactions
-
-Define semantic interactions that can dispatch custom events and be reused
-declaratively.
-
-```ts
-import { defineInteraction, on, type Interaction } from '@remix-run/interaction'
-
-// Provide type safety for consumers
-declare global {
- interface HTMLElementEventMap {
- [keydownEnter]: KeyboardEvent
- }
-}
-
-function KeydownEnter(handle: Interaction) {
- if (!(handle.target instanceof HTMLElement)) return
-
- handle.on(handle.target, {
- keydown(event) {
- if (event.key === 'Enter') {
- handle.target.dispatchEvent(
- new KeyboardEvent(keydownEnter, { key: 'Enter' }),
- )
- }
- },
- })
-}
-
-// define the interaction type and setup function
-const keydownEnter = defineInteraction('keydown:enter', KeydownEnter)
-
-// usage
-let button = document.createElement('button')
-on(button, {
- [keydownEnter](event) {
- console.log('Enter key pressed')
- },
-})
-```
-
-Notes:
-
-- An interaction is initialized at most once per target, even if multiple
- listeners bind the same interaction type.
-
-## Typed event targets
-
-Use `TypedEventTarget` to get type-safe `addEventListener` and
-integrate with this library's `on` helpers.
-
-```ts
-import { TypedEventTarget, on } from '@remix-run/interaction'
-
-interface DrummerEventMap {
- kick: DrummerEvent
- snare: DrummerEvent
- hat: DrummerEvent
-}
-
-class DrummerEvent extends Event {
- constructor(type: keyof DrummerEventMap) {
- super(type)
- }
-}
-
-class Drummer extends TypedEventTarget {
- kick() {
- // ...
- this.dispatchEvent(new DrummerEvent('kick'))
- }
-}
-
-let drummer = new Drummer()
-
-// native API is NOT typed
-drummer.addEventListener('kick', (event) => {
- // event is DrummerEvent
-})
-
-// type safe with on()
-on(drummer, {
- kick: (event) => {
- // event is Dispatched
- },
-})
-```
-
-## Demos
-
-The
-[`demos` directory](https://github.com/remix-run/remix/tree/main/packages/interaction/demos)
-contains working demos:
-
-- [`demos/async`](https://github.com/remix-run/remix/tree/main/packages/interaction/demos/async) -
- Async listeners with abort signal
-- [`demos/basic`](https://github.com/remix-run/remix/tree/main/packages/interaction/demos/basic) -
- Basic event handling
-- [`demos/form`](https://github.com/remix-run/remix/tree/main/packages/interaction/demos/form) -
- Form event handling
-- [`demos/keys`](https://github.com/remix-run/remix/tree/main/packages/interaction/demos/keys) -
- Keyboard interactions
-- [`demos/popover`](https://github.com/remix-run/remix/tree/main/packages/interaction/demos/popover) -
- Popover interactions
-- [`demos/press`](https://github.com/remix-run/remix/tree/main/packages/interaction/demos/press) -
- Press and long press interactions
-
-## License
-
-See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [interaction overview](./index.md)
-- [Event listeners and interactions](./listeners.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/interaction/index.md b/docs/agents/remix/interaction/index.md
deleted file mode 100644
index 1d5d08b..0000000
--- a/docs/agents/remix/interaction/index.md
+++ /dev/null
@@ -1,45 +0,0 @@
-# interaction
-
-Source: https://github.com/remix-run/remix/tree/main/packages/interaction
-
-## Overview
-
-Enhanced events and custom interactions for any
-[EventTarget](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget).
-
-## Features
-
-- **Declarative Bindings** - Event bindings with plain objects
-- **Semantic Interactions** - Reusable "interactions" like `longPress` and
- `arrowDown`
-- **Async Support** - Listeners with reentry protection via
- [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal)
-- **Type Safety** - Type-safe listeners and custom `EventTarget` subclasses with
- `TypedEventTarget`
-
-## Installation
-
-```sh
-bun add @remix-run/interaction
-```
-
-## Quick start
-
-```ts
-import { on } from '@remix-run/interaction'
-
-let inputElement = document.createElement('input')
-
-on(inputElement, {
- input: (event, signal) => {
- console.log('current value', event.currentTarget.value)
- },
-})
-```
-
-## Navigation
-
-- [Event listeners and interactions](./listeners.md)
-- [Containers and disposal](./containers-and-disposal.md)
-- [Custom interactions and typed targets](./custom-interactions.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/interaction/listeners.md b/docs/agents/remix/interaction/listeners.md
deleted file mode 100644
index 970cdfb..0000000
--- a/docs/agents/remix/interaction/listeners.md
+++ /dev/null
@@ -1,138 +0,0 @@
-# Event listeners and interactions
-
-Source: https://github.com/remix-run/remix/tree/main/packages/interaction
-
-## Adding event listeners
-
-Use `on(target, listeners)` to add one or more listeners. Each listener receives
-`(event, signal)` where `signal` is aborted on reentry.
-
-```ts
-import { on } from '@remix-run/interaction'
-
-let inputElement = document.createElement('input')
-
-on(inputElement, {
- input: (event, signal) => {
- console.log('current value', event.currentTarget.value)
- },
-})
-```
-
-Listeners can be arrays. They run in order and preserve normal DOM semantics
-(including `stopImmediatePropagation`).
-
-```ts
-import { on } from '@remix-run/interaction'
-
-on(inputElement, {
- input: [
- (event) => {
- console.log('first')
- },
- {
- capture: true,
- listener(event) {
- // capture phase
- },
- },
- {
- once: true,
- listener(event) {
- console.log('only once')
- },
- },
- ],
-})
-```
-
-## Built-in interactions
-
-Builtin interactions are higher-level, semantic event types (e.g., `press`,
-`longPress`, arrow keys) exported as string constants. Consume them just like
-native events by using computed keys in your listener map. When you bind one,
-the necessary underlying host events are set up automatically.
-
-```tsx
-import { on } from '@remix-run/interaction'
-import { press, longPress } from '@remix-run/interaction/press'
-
-on(listItem, {
- [press](event) {
- navigateTo(listItem.href)
- },
-
- [longPress](event) {
- event.preventDefault() // prevents `press`
- showActions()
- },
-})
-```
-
-Import builtins from their modules (for example, `@remix-run/interaction/press`,
-`@remix-run/interaction/keys`). Some interactions may coordinate with others
-(for example, calling `event.preventDefault()` in one listener can prevent a
-related interaction from firing).
-
-## Async listeners and reentry protection
-
-The `signal` is aborted when the same listener is re-entered (for example, a
-user types quickly and triggers `input` repeatedly). Pass it to async APIs or
-check it manually to avoid stale work.
-
-```ts
-on(inputElement, {
- async input(event, signal) {
- showSearchSpinner()
-
- // Abortable fetch
- let res = await fetch(`/search?q=${event.currentTarget.value}`, { signal })
- let results = await res.json()
- updateResults(results)
- },
-})
-```
-
-For APIs that don't accept a signal:
-
-```ts
-on(inputElement, {
- async input(event, signal) {
- showSearchSpinner()
- let results = await someSearch(event.currentTarget.value)
- if (signal.aborted) return
- updateResults(results)
- },
-})
-```
-
-## Event listener options
-
-All DOM
-[`AddEventListenerOptions`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#options)
-are supported via descriptors:
-
-```ts
-import { on } from '@remix-run/interaction'
-
-on(button, {
- click: {
- capture: true,
- listener(event) {
- console.log('capture phase')
- },
- },
- focus: {
- once: true,
- listener(event) {
- console.log('focused once')
- },
- },
-})
-```
-
-## Navigation
-
-- [Containers and disposal](./containers-and-disposal.md)
-- [interaction overview](./index.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/lazy-file.md b/docs/agents/remix/lazy-file/README.md
similarity index 90%
rename from docs/agents/remix/lazy-file.md
rename to docs/agents/remix/lazy-file/README.md
index d4162c3..f9beb5d 100644
--- a/docs/agents/remix/lazy-file.md
+++ b/docs/agents/remix/lazy-file/README.md
@@ -1,10 +1,6 @@
# lazy-file
-Source: https://github.com/remix-run/remix/tree/main/packages/lazy-file
-
-## README
-
-`lazy-file` is a lazy, streaming `Blob`/`File` implementation for JavaScript.
+A lazy, streaming `Blob`/`File` implementation for JavaScript.
It allows you to easily create
[Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob)-like and
@@ -29,7 +25,7 @@ they are streamed to avoid buffering.
[`Blob.slice()`](https://developer.mozilla.org/en-US/docs/Web/API/Blob/slice),
even on streaming content
-## The Problem
+## Why You Need This
JavaScript's [File API](https://developer.mozilla.org/en-US/docs/Web/API/File)
is useful, but it's not a great fit for streaming server environments where you
@@ -56,10 +52,8 @@ All other `File` functionality works as you'd expect.
## Installation
-Install from [npm](https://www.npmjs.com/):
-
```sh
-bun add @remix-run/lazy-file
+npm i remix
```
## Usage
@@ -68,7 +62,7 @@ The low-level API can be used to create a `LazyFile` that streams content from
anywhere:
```ts
-import { type LazyContent, LazyFile } from '@remix-run/lazy-file'
+import { type LazyContent, LazyFile } from 'remix/lazy-file'
let content: LazyContent = {
// The total length of this file in bytes.
@@ -101,7 +95,7 @@ Use `.stream()` to get a `ReadableStream` for `Response` and other streaming
APIs:
```ts
-import { openLazyFile } from '@remix-run/fs'
+import { openLazyFile } from 'remix/fs'
let lazyFile = openLazyFile('./large-video.mp4')
@@ -140,7 +134,3 @@ formData.append('document', realFile)
## License
See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Remix package index](./index.md)
diff --git a/docs/agents/remix/logger-middleware.md b/docs/agents/remix/logger-middleware/README.md
similarity index 78%
rename from docs/agents/remix/logger-middleware.md
rename to docs/agents/remix/logger-middleware/README.md
index 0455555..8943724 100644
--- a/docs/agents/remix/logger-middleware.md
+++ b/docs/agents/remix/logger-middleware/README.md
@@ -1,25 +1,26 @@
# logger-middleware
-Source: https://github.com/remix-run/remix/tree/main/packages/logger-middleware
+HTTP request/response logging middleware for Remix. It logs request metadata and
+response details with configurable output formats.
-## README
+## Features
-Middleware for logging HTTP requests and responses for use with
-[`@remix-run/fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router).
-
-Logs information about HTTP requests and responses with customizable formatting.
+- **Request/Response Logging** - Logs method, path, status, and response
+ metadata
+- **Token-Based Formatting** - Customize log output with built-in placeholders
+- **Structured Timing Data** - Includes request duration and timestamps
## Installation
```sh
-bun add @remix-run/logger-middleware
+npm i remix
```
## Usage
```ts
-import { createRouter } from '@remix-run/fetch-router'
-import { logger } from '@remix-run/logger-middleware'
+import { createRouter } from 'remix/fetch-router'
+import { logger } from 'remix/logger-middleware'
let router = createRouter({
middleware: [logger()],
@@ -33,7 +34,7 @@ let router = createRouter({
You can use the `format` option to customize the log format. The following
tokens are available:
-- `%date` - Date and time in Apache/nginx format (dd/Mon/yyyy:HH:mm:ss +/-zzzz)
+- `%date` - Date and time in Apache/nginx format (dd/Mon/yyyy:HH:mm:ss ±zzzz)
- `%dateISO` - Date and time in ISO format
- `%duration` - Request duration in milliseconds
- `%contentLength` - Response Content-Length header
@@ -104,7 +105,3 @@ let router = createRouter({
## License
See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Remix package index](./index.md)
diff --git a/docs/agents/remix/method-override-middleware.md b/docs/agents/remix/method-override-middleware/README.md
similarity index 70%
rename from docs/agents/remix/method-override-middleware.md
rename to docs/agents/remix/method-override-middleware/README.md
index 86fed5f..56bd1e8 100644
--- a/docs/agents/remix/method-override-middleware.md
+++ b/docs/agents/remix/method-override-middleware/README.md
@@ -1,20 +1,19 @@
# method-override-middleware
-Source:
-https://github.com/remix-run/remix/tree/main/packages/method-override-middleware
+Method override middleware for Remix. It allows HTML forms to simulate `PUT`,
+`PATCH`, and `DELETE` requests using a hidden form field.
-## README
+## Features
-Middleware for overriding HTTP request methods from form data for use with
-[`@remix-run/fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router).
-
-Allows HTML forms (which only support GET and POST) to submit with other HTTP
-methods like PUT, PATCH, or DELETE by including a special form field.
+- **Form Method Overrides** - Translate posted form fields into request methods
+- **HTML Form Friendly** - Supports REST-style routes from standard browser
+ forms
+- **Configurable Field Name** - Choose a custom override field key
## Installation
```sh
-bun add @remix-run/method-override-middleware
+npm i remix
```
## Usage
@@ -26,9 +25,9 @@ override field. This is useful for simulating RESTful API request methods like
PUT and DELETE using HTML forms.
```ts
-import { createRouter } from '@remix-run/fetch-router'
-import { formData } from '@remix-run/form-data-middleware'
-import { methodOverride } from '@remix-run/method-override-middleware'
+import { createRouter } from 'remix/fetch-router'
+import { formData } from 'remix/form-data-middleware'
+import { methodOverride } from 'remix/method-override-middleware'
let router = createRouter({
// methodOverride must come AFTER formData middleware
@@ -79,7 +78,3 @@ let router = createRouter({
## License
See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Remix package index](./index.md)
diff --git a/docs/agents/remix/mime.md b/docs/agents/remix/mime/README.md
similarity index 75%
rename from docs/agents/remix/mime.md
rename to docs/agents/remix/mime/README.md
index 81ce6fd..151a1e4 100644
--- a/docs/agents/remix/mime.md
+++ b/docs/agents/remix/mime/README.md
@@ -1,18 +1,20 @@
-# @remix-run/mime
+# mime
-Source: https://github.com/remix-run/remix/tree/main/packages/mime
+MIME type detection and content-type helpers for Remix. This package maps
+extensions to MIME types and provides utilities for charset and compressibility
+checks.
-## README
+## Features
-Utilities for working with MIME types.
-
-Data used for these utilities is generated at build time from
-[mime-db](https://github.com/jshttp/mime-db).
+- **MIME Detection** - Detect MIME types from extensions and filenames
+- **Content-Type Helpers** - Build `Content-Type` values with charset handling
+- **Compression Signals** - Check whether a media type is likely compressible
+- **Generated Data** - Built from [mime-db](https://github.com/jshttp/mime-db)
## Installation
-```bash
-bun add @remix-run/mime
+```sh
+npm i remix
```
## Usage
@@ -22,7 +24,7 @@ bun add @remix-run/mime
Detects the MIME type for a given file extension or filename.
```ts
-import { detectMimeType } from '@remix-run/mime'
+import { detectMimeType } from 'remix/mime'
detectMimeType('txt') // 'text/plain'
detectMimeType('.txt') // 'text/plain'
@@ -38,7 +40,7 @@ including `charset` for text-based types. See
[`mimeTypeToContentType`](#mimetypetocontenttypemimetype) for charset logic.
```ts
-import { detectContentType } from '@remix-run/mime'
+import { detectContentType } from 'remix/mime'
detectContentType('css') // 'text/css; charset=utf-8'
detectContentType('.json') // 'application/json; charset=utf-8'
@@ -51,7 +53,7 @@ detectContentType('path/to/file.unknown') // undefined
Checks if a MIME type is known to be compressible.
```ts
-import { isCompressibleMimeType } from '@remix-run/mime'
+import { isCompressibleMimeType } from 'remix/mime'
isCompressibleMimeType('text/html') // true
isCompressibleMimeType('application/json') // true
@@ -62,7 +64,7 @@ isCompressibleMimeType('video/mp4') // false
For convenience, the function also accepts a full Content-Type header value:
```ts
-import { isCompressibleMimeType } from '@remix-run/mime'
+import { isCompressibleMimeType } from 'remix/mime'
isCompressibleMimeType('text/html; charset=utf-8') // true
isCompressibleMimeType('application/json; charset=utf-8') // true
@@ -78,7 +80,7 @@ declarations), `application/json`, `application/javascript`, and all `+json`
suffixed types. All other types are returned unchanged.
```ts
-import { mimeTypeToContentType } from '@remix-run/mime'
+import { mimeTypeToContentType } from 'remix/mime'
mimeTypeToContentType('text/css') // 'text/css; charset=utf-8'
mimeTypeToContentType('application/json') // 'application/json; charset=utf-8'
@@ -91,7 +93,7 @@ mimeTypeToContentType('image/png') // 'image/png'
Registers or overrides a MIME type for one or more file extensions.
```ts
-import { defineMimeType } from '@remix-run/mime'
+import { defineMimeType } from 'remix/mime'
defineMimeType({
extensions: ['myformat'],
@@ -114,7 +116,3 @@ defineMimeType({
## License
MIT
-
-## Navigation
-
-- [Remix package index](./index.md)
diff --git a/docs/agents/remix/multipart-parser/README.md b/docs/agents/remix/multipart-parser/README.md
new file mode 100644
index 0000000..682a18a
--- /dev/null
+++ b/docs/agents/remix/multipart-parser/README.md
@@ -0,0 +1,279 @@
+# multipart-parser
+
+Fast streaming multipart parsing for JavaScript. `multipart-parser` processes
+multipart bodies incrementally so large uploads can be handled without buffering
+the entire multipart payload in memory.
+
+## Features
+
+- **File Upload Parsing** - Parse file uploads (`multipart/form-data`) with
+ automatic field and file detection
+- **Full Multipart Support** - Support for all `multipart/*` content types
+ (mixed, alternative, related, etc.)
+- **Convenient API** - `MultipartPart` API with `arrayBuffer`, `bytes`, `text`,
+ `size`, and metadata access
+- **Built-in Limits** - Header, per-part, part-count, and aggregate-size limits
+ to prevent abuse
+- **Node.js Support** - First-class Node.js support with native
+ `http.IncomingMessage` compatibility
+- **Runtime Demos** -
+ [Demos for every major runtime](https://github.com/remix-run/remix/tree/main/packages/multipart-parser/demos)
+
+## Installation
+
+```sh
+npm i remix
+```
+
+## Usage
+
+The most common use case for `multipart-parser` is handling file uploads when
+you're building a web server. For this case, the `parseMultipartRequest`
+function is your friend. It automatically validates the request is
+`multipart/form-data`, extracts the multipart boundary from the `Content-Type`
+header, parses all fields and files in the `request.body` stream, and gives each
+one to you as a `MultipartPart` object with a rich API for accessing its
+metadata and content.
+
+```ts
+import {
+ MultipartParseError,
+ parseMultipartRequest,
+} from 'remix/multipart-parser'
+
+async function handleRequest(request: Request): void {
+ try {
+ for await (let part of parseMultipartRequest(request)) {
+ if (part.isFile) {
+ // Access file data in multiple formats
+ let buffer = part.arrayBuffer // ArrayBuffer
+ console.log(
+ `File received: ${part.filename} (${buffer.byteLength} bytes)`,
+ )
+ console.log(`Content type: ${part.mediaType}`)
+ console.log(`Field name: ${part.name}`)
+
+ // Save to disk, upload to cloud storage, etc.
+ await saveFile(part.filename, part.bytes)
+ } else {
+ let text = part.text // string
+ console.log(`Field received: ${part.name} = ${JSON.stringify(text)}`)
+ }
+ }
+ } catch (error) {
+ if (error instanceof MultipartParseError) {
+ console.error('Failed to parse multipart request:', error.message)
+ } else {
+ console.error('An unexpected error occurred:', error)
+ }
+ }
+}
+```
+
+## Size Limits
+
+A common use case when handling file uploads is limiting the overall shape of
+incoming multipart bodies so malicious clients cannot force unbounded growth in
+memory. Use `maxFileSize` to limit each part, `maxParts` to limit how many parts
+are accepted, and `maxTotalSize` to limit aggregate part content across the
+entire request. `multipart-parser` applies finite defaults for each of these
+limits.
+
+```ts
+import {
+ MultipartParseError,
+ MaxFileSizeExceededError,
+ MaxPartsExceededError,
+ MaxTotalSizeExceededError,
+ parseMultipartRequest,
+} from 'remix/multipart-parser/node'
+
+const oneMb = Math.pow(2, 20)
+const limits = {
+ maxFileSize: 10 * oneMb,
+ maxParts: 100,
+ maxTotalSize: 25 * oneMb,
+}
+
+async function handleRequest(request: Request): Promise {
+ try {
+ for await (let part of parseMultipartRequest(request, limits)) {
+ // ...
+ }
+ } catch (error) {
+ if (error instanceof MaxFileSizeExceededError) {
+ return new Response('File size limit exceeded', { status: 413 })
+ } else if (error instanceof MaxPartsExceededError) {
+ return new Response('Too many multipart parts', { status: 413 })
+ } else if (error instanceof MaxTotalSizeExceededError) {
+ return new Response('Multipart request is too large', { status: 413 })
+ } else if (error instanceof MultipartParseError) {
+ return new Response('Failed to parse multipart request', { status: 400 })
+ } else {
+ console.error(error)
+ return new Response('Internal Server Error', { status: 500 })
+ }
+ }
+}
+```
+
+## Node.js Bindings
+
+The main module (`import {} from 'remix/multipart-parser'`) assumes you're
+working with
+[the fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)
+(`Request`, `ReadableStream`, etc). Support for these interfaces was added to
+Node.js by the [undici](https://github.com/nodejs/undici) project in
+[version 16.5.0](https://nodejs.org/en/blog/release/v16.5.0).
+
+If however you're building a server for Node.js that relies on node-specific
+APIs like `http.IncomingMessage`, `stream.Readable`, and `buffer.Buffer` (ala
+Express or `http.createServer`), `multipart-parser` ships with an additional
+module that works directly with these APIs.
+
+```ts
+import * as http from 'node:http'
+import {
+ MultipartParseError,
+ parseMultipartRequest,
+} from 'remix/multipart-parser/node'
+
+let server = http.createServer(async (req, res) => {
+ try {
+ for await (let part of parseMultipartRequest(req)) {
+ // ...
+ }
+ } catch (error) {
+ if (error instanceof MultipartParseError) {
+ console.error('Failed to parse multipart request:', error.message)
+ } else {
+ console.error('An unexpected error occurred:', error)
+ }
+ }
+})
+
+server.listen(8080)
+```
+
+## Low-level API
+
+If you're working directly with multipart boundaries and buffers/streams of
+multipart data that are not necessarily part of a request, `multipart-parser`
+provides a low-level `parseMultipart()` API that you can use directly:
+
+```ts
+import { parseMultipart } from 'remix/multipart-parser'
+
+let message = new Uint8Array(/* ... */)
+let boundary = '----WebKitFormBoundary56eac3x'
+
+for (let part of parseMultipart(message, { boundary })) {
+ // ...
+}
+```
+
+In addition, the `parseMultipartStream` function provides an `async` generator
+interface for multipart data in a `ReadableStream`:
+
+```ts
+import { parseMultipartStream } from 'remix/multipart-parser'
+
+let message = new ReadableStream(/* ... */)
+let boundary = '----WebKitFormBoundary56eac3x'
+
+for await (let part of parseMultipartStream(message, { boundary })) {
+ // ...
+}
+```
+
+## Demos
+
+The
+[`demos` directory](https://github.com/remix-run/remix/tree/main/packages/multipart-parser/demos)
+contains a few working demos of how you can use this library:
+
+- [`demos/bun`](https://github.com/remix-run/remix/tree/main/packages/multipart-parser/demos/bun) -
+ using multipart-parser in Bun
+- [`demos/cf-workers`](https://github.com/remix-run/remix/tree/main/packages/multipart-parser/demos/cf-workers) -
+ using multipart-parser in a Cloudflare Worker and storing file uploads in R2
+- [`demos/deno`](https://github.com/remix-run/remix/tree/main/packages/multipart-parser/demos/deno) -
+ using multipart-parser in Deno
+- [`demos/node`](https://github.com/remix-run/remix/tree/main/packages/multipart-parser/demos/node) -
+ using multipart-parser in Node.js
+
+## Benchmark
+
+`multipart-parser` is designed to be as efficient as possible, operating on
+streams of data and rarely buffering in common usage. This design yields
+exceptional performance when handling multipart payloads of any size. In
+benchmarks, `multipart-parser` is as fast or faster than `busboy`.
+
+The results of running the benchmarks on my laptop:
+
+```
+> @remix-run/multipart-parser@0.10.1 bench:node /Users/michael/Projects/remix-the-web/packages/multipart-parser
+> node ./bench/runner.ts
+
+Platform: Darwin (24.5.0)
+CPU: Apple M1 Pro
+Date: 6/13/2025, 12:27:09 PM
+Node.js v24.0.2
+┌──────────────────┬──────────────────┬──────────────────┬──────────────────┬───────────────────┐
+│ (index) │ 1 small file │ 1 large file │ 100 small files │ 5 large files │
+├──────────────────┼──────────────────┼──────────────────┼──────────────────┼───────────────────┤
+│ multipart-parser │ '0.01 ms ± 0.03' │ '1.08 ms ± 0.08' │ '0.04 ms ± 0.01' │ '10.50 ms ± 0.38' │
+│ multipasta │ '0.02 ms ± 0.06' │ '1.07 ms ± 0.02' │ '0.15 ms ± 0.02' │ '10.46 ms ± 0.11' │
+│ busboy │ '0.06 ms ± 0.17' │ '3.07 ms ± 0.24' │ '0.24 ms ± 0.05' │ '29.85 ms ± 0.18' │
+│ @fastify/busboy │ '0.05 ms ± 0.13' │ '1.23 ms ± 0.09' │ '0.45 ms ± 0.22' │ '11.81 ms ± 0.11' │
+└──────────────────┴──────────────────┴──────────────────┴──────────────────┴───────────────────┘
+
+> @remix-run/multipart-parser@0.10.1 bench:bun /Users/michael/Projects/remix-the-web/packages/multipart-parser
+> bun run ./bench/runner.ts
+
+Platform: Darwin (24.5.0)
+CPU: Apple M1 Pro
+Date: 6/13/2025, 12:27:31 PM
+Bun 1.2.13
+┌──────────────────┬────────────────┬────────────────┬─────────────────┬─────────────────┐
+│ │ 1 small file │ 1 large file │ 100 small files │ 5 large files │
+├──────────────────┼────────────────┼────────────────┼─────────────────┼─────────────────┤
+│ multipart-parser │ 0.01 ms ± 0.04 │ 0.86 ms ± 0.09 │ 0.04 ms ± 0.01 │ 8.32 ms ± 0.26 │
+│ multipasta │ 0.02 ms ± 0.07 │ 0.87 ms ± 0.03 │ 0.25 ms ± 0.21 │ 8.27 ms ± 0.09 │
+│ busboy │ 0.05 ms ± 0.17 │ 3.54 ms ± 0.10 │ 0.30 ms ± 0.03 │ 34.79 ms ± 0.38 │
+│ @fastify/busboy │ 0.06 ms ± 0.18 │ 4.04 ms ± 0.08 │ 0.48 ms ± 0.06 │ 39.91 ms ± 0.37 │
+└──────────────────┴────────────────┴────────────────┴─────────────────┴─────────────────┘
+
+> @remix-run/multipart-parser@0.10.1 bench:deno /Users/michael/Projects/remix-the-web/packages/multipart-parser
+> deno run --allow-sys ./bench/runner.ts
+
+Platform: Darwin (24.5.0)
+CPU: Apple M1 Pro
+Date: 6/13/2025, 12:28:12 PM
+Deno 2.3.6
+┌──────────────────┬──────────────────┬────────────────────┬──────────────────┬─────────────────────┐
+│ (idx) │ 1 small file │ 1 large file │ 100 small files │ 5 large files │
+├──────────────────┼──────────────────┼────────────────────┼──────────────────┼─────────────────────┤
+│ multipart-parser │ "0.01 ms ± 0.03" │ "1.03 ms ± 0.04" │ "0.05 ms ± 0.01" │ "10.05 ms ± 0.20" │
+│ multipasta │ "0.02 ms ± 0.07" │ "1.04 ms ± 0.03" │ "0.16 ms ± 0.02" │ "10.10 ms ± 0.08" │
+│ busboy │ "0.05 ms ± 0.19" │ "3.06 ms ± 0.15" │ "0.32 ms ± 0.05" │ "29.92 ms ± 0.24" │
+│ @fastify/busboy │ "0.06 ms ± 0.14" │ "14.72 ms ± 11.42" │ "0.81 ms ± 0.20" │ "127.63 ms ± 35.77" │
+└──────────────────┴──────────────────┴────────────────────┴──────────────────┴─────────────────────┘
+```
+
+## Related Packages
+
+- [`form-data-parser`](https://github.com/remix-run/remix/tree/main/packages/form-data-parser) -
+ Uses `multipart-parser` internally to parse multipart requests and generate
+ `FileUpload`s for storage
+- [`headers`](https://github.com/remix-run/remix/tree/main/packages/headers) -
+ Used internally to parse HTTP headers and get metadata (filename, content
+ type) for each `MultipartPart`
+
+## Credits
+
+Thanks to Jacob Ebey who gave me several code reviews on this project prior to
+publishing.
+
+## License
+
+See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
diff --git a/docs/agents/remix/multipart-parser/benchmarks.md b/docs/agents/remix/multipart-parser/benchmarks.md
deleted file mode 100644
index 2603635..0000000
--- a/docs/agents/remix/multipart-parser/benchmarks.md
+++ /dev/null
@@ -1,91 +0,0 @@
-# Benchmarks and related packages
-
-Source: https://github.com/remix-run/remix/tree/main/packages/multipart-parser
-
-## Demos
-
-The
-[`demos` directory](https://github.com/remix-run/remix/tree/main/packages/multipart-parser/demos)
-contains working demos:
-
-- [`demos/bun`](https://github.com/remix-run/remix/tree/main/packages/multipart-parser/demos/bun) -
- using multipart-parser in Bun
-- [`demos/cf-workers`](https://github.com/remix-run/remix/tree/main/packages/multipart-parser/demos/cf-workers) -
- using multipart-parser in a Cloudflare Worker and storing file uploads in R2
-- [`demos/deno`](https://github.com/remix-run/remix/tree/main/packages/multipart-parser/demos/deno) -
- using multipart-parser in Deno
-- [`demos/node`](https://github.com/remix-run/remix/tree/main/packages/multipart-parser/demos/node) -
- using multipart-parser in Node.js
-
-## Benchmark
-
-`multipart-parser` is designed to be as efficient as possible, operating on
-streams of data and rarely buffering in common usage. This design yields
-exceptional performance when handling multipart payloads of any size. In
-benchmarks, `multipart-parser` is as fast or faster than `busboy`.
-
-The results of running the benchmarks on my laptop:
-
-```
-> @remix-run/multipart-parser@0.10.1 bench:node /Users/michael/Projects/remix-the-web/packages/multipart-parser
-> node --disable-warning=ExperimentalWarning ./bench/runner.ts
-
-Platform: Darwin (24.5.0)
-CPU: Apple M1 Pro
-Date: 6/13/2025, 12:27:09 PM
-Node.js v24.0.2
-(index) | 1 small file | 1 large file | 100 small files | 5 large files
-multipart-parser | '0.01 ms +/- 0.03' | '1.08 ms +/- 0.08' | '0.04 ms +/- 0.01' | '10.50 ms +/- 0.38'
-multipasta | '0.02 ms +/- 0.06' | '1.07 ms +/- 0.02' | '0.15 ms +/- 0.02' | '10.46 ms +/- 0.11'
-busboy | '0.06 ms +/- 0.17' | '3.07 ms +/- 0.24' | '0.24 ms +/- 0.05' | '29.85 ms +/- 0.18'
-@fastify/busboy | '0.05 ms +/- 0.13' | '1.23 ms +/- 0.09' | '0.45 ms +/- 0.22' | '11.81 ms +/- 0.11'
-
-> @remix-run/multipart-parser@0.10.1 bench:bun /Users/michael/Projects/remix-the-web/packages/multipart-parser
-> bun run ./bench/runner.ts
-
-Platform: Darwin (24.5.0)
-CPU: Apple M1 Pro
-Date: 6/13/2025, 12:27:31 PM
-Bun 1.2.13
-(index) | 1 small file | 1 large file | 100 small files | 5 large files
-multipart-parser | 0.01 ms +/- 0.04 | 0.86 ms +/- 0.09 | 0.04 ms +/- 0.01 | 8.32 ms +/- 0.26
-multipasta | 0.02 ms +/- 0.07 | 0.87 ms +/- 0.03 | 0.25 ms +/- 0.21 | 8.27 ms +/- 0.09
-busboy | 0.05 ms +/- 0.17 | 3.54 ms +/- 0.10 | 0.30 ms +/- 0.03 | 34.79 ms +/- 0.38
-@fastify/busboy | 0.06 ms +/- 0.18 | 4.04 ms +/- 0.08 | 0.48 ms +/- 0.06 | 39.91 ms +/- 0.37
-
-> @remix-run/multipart-parser@0.10.1 bench:deno /Users/michael/Projects/remix-the-web/packages/multipart-parser
-> deno run --allow-sys ./bench/runner.ts
-
-Platform: Darwin (24.5.0)
-CPU: Apple M1 Pro
-Date: 6/13/2025, 12:28:12 PM
-Deno 2.3.6
-(idx) | 1 small file | 1 large file | 100 small files | 5 large files
-multipart-parser | "0.01 ms +/- 0.03" | "1.03 ms +/- 0.04" | "0.05 ms +/- 0.01" | "10.05 ms +/- 0.20"
-multipasta | "0.02 ms +/- 0.07" | "1.04 ms +/- 0.03" | "0.16 ms +/- 0.02" | "10.10 ms +/- 0.08"
-busboy | "0.05 ms +/- 0.19" | "3.06 ms +/- 0.15" | "0.32 ms +/- 0.05" | "29.92 ms +/- 0.24"
-@fastify/busboy | "0.06 ms +/- 0.14" | "14.72 ms +/- 11.42" | "0.81 ms +/- 0.20" | "127.63 ms +/- 35.77"
-```
-
-## Related packages
-
-- [`form-data-parser`](https://github.com/remix-run/remix/tree/main/packages/form-data-parser) -
- Uses `multipart-parser` internally to parse multipart requests and generate
- `FileUpload`s for storage
-- [`headers`](https://github.com/remix-run/remix/tree/main/packages/headers) -
- Used internally to parse HTTP headers and get metadata (filename, content
- type) for each `MultipartPart`
-
-## Credits
-
-Thanks to Jacob Ebey who gave me several code reviews on this project prior to
-publishing.
-
-## License
-
-See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [multipart-parser overview](./index.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/multipart-parser/index.md b/docs/agents/remix/multipart-parser/index.md
deleted file mode 100644
index b71de1a..0000000
--- a/docs/agents/remix/multipart-parser/index.md
+++ /dev/null
@@ -1,78 +0,0 @@
-# multipart-parser
-
-Source: https://github.com/remix-run/remix/tree/main/packages/multipart-parser
-
-## Overview
-
-`multipart-parser` is a fast, streaming multipart parser that works in any
-JavaScript environment. Whether you're handling file uploads, parsing email
-attachments, or working with multipart API responses, `multipart-parser` has you
-covered.
-
-## Why multipart-parser?
-
-- **Universal JavaScript** - One library that works everywhere: Node.js, Bun,
- Deno, Cloudflare Workers, and browsers
-- **Blazing Fast** - Outperforms popular alternatives like busboy in benchmarks
-- **Zero Dependencies** - Lightweight and secure with no external dependencies
-- **Memory Efficient** - Streaming architecture that yields files as they are
- found in the stream
-- **Type Safe** - Written in TypeScript with comprehensive type definitions
-- **Standards Based** - Built on the web Streams API for maximum compatibility
-- **Production Ready** - Battle-tested error handling with specific error types
-
-## Installation
-
-```sh
-bun add @remix-run/multipart-parser
-```
-
-## Usage
-
-The most common use case is handling file uploads when you're building a web
-server. The `parseMultipartRequest` function validates the request, extracts the
-multipart boundary from the `Content-Type` header, parses all fields and files
-in the `request.body` stream, and gives each one to you as a `MultipartPart`
-object.
-
-```ts
-import {
- MultipartParseError,
- parseMultipartRequest,
-} from '@remix-run/multipart-parser'
-
-async function handleRequest(request: Request): void {
- try {
- for await (let part of parseMultipartRequest(request)) {
- if (part.isFile) {
- // Access file data in multiple formats
- let buffer = part.arrayBuffer // ArrayBuffer
- console.log(
- `File received: ${part.filename} (${buffer.byteLength} bytes)`,
- )
- console.log(`Content type: ${part.mediaType}`)
- console.log(`Field name: ${part.name}`)
-
- // Save to disk, upload to cloud storage, etc.
- await saveFile(part.filename, part.bytes)
- } else {
- let text = part.text // string
- console.log(`Field received: ${part.name} = ${JSON.stringify(text)}`)
- }
- }
- } catch (error) {
- if (error instanceof MultipartParseError) {
- console.error('Failed to parse multipart request:', error.message)
- } else {
- console.error('An unexpected error occurred:', error)
- }
- }
-}
-```
-
-## Navigation
-
-- [Limits and Node bindings](./limits-and-node.md)
-- [Low-level APIs](./low-level.md)
-- [Benchmarks and related packages](./benchmarks.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/multipart-parser/limits-and-node.md b/docs/agents/remix/multipart-parser/limits-and-node.md
deleted file mode 100644
index 7d2dcc8..0000000
--- a/docs/agents/remix/multipart-parser/limits-and-node.md
+++ /dev/null
@@ -1,78 +0,0 @@
-# Limits and Node bindings
-
-Source: https://github.com/remix-run/remix/tree/main/packages/multipart-parser
-
-## Limiting file upload size
-
-You can set a file upload size limit using the `maxFileSize` option, and return
-a 413 "Payload Too Large" response when you receive a request that exceeds the
-limit.
-
-```ts
-import {
- MultipartParseError,
- MaxFileSizeExceededError,
- parseMultipartRequest,
-} from '@remix-run/multipart-parser/node'
-
-const oneMb = Math.pow(2, 20)
-const maxFileSize = 10 * oneMb
-
-async function handleRequest(request: Request): Promise {
- try {
- for await (let part of parseMultipartRequest(request, { maxFileSize })) {
- // ...
- }
- } catch (error) {
- if (error instanceof MaxFileSizeExceededError) {
- return new Response('File size limit exceeded', { status: 413 })
- } else if (error instanceof MultipartParseError) {
- return new Response('Failed to parse multipart request', { status: 400 })
- } else {
- console.error(error)
- return new Response('Internal Server Error', { status: 500 })
- }
- }
-}
-```
-
-## Node.js bindings
-
-The main module (`import from "@remix-run/multipart-parser"`) assumes you're
-working with the Fetch API (`Request`, `ReadableStream`, etc). Support for these
-interfaces was added to Node.js by the undici project in version 16.5.0.
-
-If you're building a server for Node.js that relies on node-specific APIs like
-`http.IncomingMessage`, `stream.Readable`, and `buffer.Buffer`,
-`multipart-parser` ships with an additional module that works directly with
-these APIs.
-
-```ts
-import * as http from 'node:http'
-import {
- MultipartParseError,
- parseMultipartRequest,
-} from '@remix-run/multipart-parser/node'
-
-let server = http.createServer(async (req, res) => {
- try {
- for await (let part of parseMultipartRequest(req)) {
- // ...
- }
- } catch (error) {
- if (error instanceof MultipartParseError) {
- console.error('Failed to parse multipart request:', error.message)
- } else {
- console.error('An unexpected error occurred:', error)
- }
- }
-})
-
-server.listen(8080)
-```
-
-## Navigation
-
-- [multipart-parser overview](./index.md)
-- [Low-level APIs](./low-level.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/multipart-parser/low-level.md b/docs/agents/remix/multipart-parser/low-level.md
deleted file mode 100644
index 5df5773..0000000
--- a/docs/agents/remix/multipart-parser/low-level.md
+++ /dev/null
@@ -1,40 +0,0 @@
-# Low-level APIs
-
-Source: https://github.com/remix-run/remix/tree/main/packages/multipart-parser
-
-## Low-level API
-
-If you're working directly with multipart boundaries and buffers/streams of
-multipart data that are not necessarily part of a request, `multipart-parser`
-provides a low-level `parseMultipart()` API that you can use directly:
-
-```ts
-import { parseMultipart } from '@remix-run/multipart-parser'
-
-let message = new Uint8Array(/* ... */)
-let boundary = '----WebKitFormBoundary56eac3x'
-
-for (let part of parseMultipart(message, { boundary })) {
- // ...
-}
-```
-
-In addition, the `parseMultipartStream` function provides an async generator
-interface for multipart data in a `ReadableStream`:
-
-```ts
-import { parseMultipartStream } from '@remix-run/multipart-parser'
-
-let message = new ReadableStream(/* ... */)
-let boundary = '----WebKitFormBoundary56eac3x'
-
-for await (let part of parseMultipartStream(message, { boundary })) {
- // ...
-}
-```
-
-## Navigation
-
-- [multipart-parser overview](./index.md)
-- [Benchmarks and related packages](./benchmarks.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/node-fetch-server/README.md b/docs/agents/remix/node-fetch-server/README.md
new file mode 100644
index 0000000..0e46229
--- /dev/null
+++ b/docs/agents/remix/node-fetch-server/README.md
@@ -0,0 +1,342 @@
+# node-fetch-server
+
+Build Node.js servers with web-standard Fetch API primitives.
+`node-fetch-server` converts Node's HTTP server interfaces into
+[`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)/[`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
+flows that match modern runtimes.
+
+## Features
+
+- **Web Standards** - Standard
+ [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) and
+ [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) APIs
+- **Drop-in Integration** - Works with `node:http` and `node:https` modules
+- **Streaming Support** - Response support with `ReadableStream`
+- **Custom Hostname** - Configuration for deployment flexibility
+- **Client Info** - Access to client connection info (IP address, port)
+- **TypeScript** - Full TypeScript support with type definitions
+
+## Installation
+
+```sh
+npm i remix
+```
+
+## Quick Start
+
+### Basic Server
+
+Here's a complete working example with a simple in-memory data store:
+
+```ts
+import * as http from 'node:http'
+import { createRequestListener } from 'remix/node-fetch-server'
+
+// Example: Simple in-memory user storage
+let users = new Map([
+ ['1', { id: '1', name: 'Alice', email: 'alice@example.com' }],
+ ['2', { id: '2', name: 'Bob', email: 'bob@example.com' }],
+])
+
+async function handler(request: Request) {
+ let url = new URL(request.url)
+
+ // GET / - Home page
+ if (url.pathname === '/' && request.method === 'GET') {
+ return new Response('Welcome to the User API! Try GET /api/users')
+ }
+
+ // GET /api/users - List all users
+ if (url.pathname === '/api/users' && request.method === 'GET') {
+ return Response.json(Array.from(users.values()))
+ }
+
+ // GET /api/users/:id - Get specific user
+ let userMatch = url.pathname.match(/^\/api\/users\/(\w+)$/)
+ if (userMatch && request.method === 'GET') {
+ let user = users.get(userMatch[1])
+ if (user) {
+ return Response.json(user)
+ }
+ return new Response('User not found', { status: 404 })
+ }
+
+ return new Response('Not Found', { status: 404 })
+}
+
+// Create a standard Node.js server
+let server = http.createServer(createRequestListener(handler))
+
+server.listen(3000, () => {
+ console.log('Server running at http://localhost:3000')
+})
+```
+
+### Working with Request Data
+
+Handle different types of request data using standard web APIs:
+
+```ts
+async function handler(request: Request) {
+ let url = new URL(request.url)
+
+ // Handle JSON data
+ if (request.method === 'POST' && url.pathname === '/api/users') {
+ try {
+ let userData = await request.json()
+
+ // Validate required fields
+ if (!userData.name || !userData.email) {
+ return Response.json(
+ { error: 'Name and email are required' },
+ { status: 400 },
+ )
+ }
+
+ // Create user (your implementation)
+ let newUser = {
+ id: Date.now().toString(),
+ ...userData,
+ }
+
+ return Response.json(newUser, { status: 201 })
+ } catch (error) {
+ return Response.json({ error: 'Invalid JSON' }, { status: 400 })
+ }
+ }
+
+ // Handle URL search params
+ if (url.pathname === '/api/search') {
+ let query = url.searchParams.get('q')
+ let limit = parseInt(url.searchParams.get('limit') || '10')
+
+ return Response.json({
+ query,
+ limit,
+ results: [], // Your search results here
+ })
+ }
+
+ return new Response('Not Found', { status: 404 })
+}
+```
+
+### Streaming Responses
+
+Take advantage of web-standard streaming with `ReadableStream`:
+
+```ts
+async function handler(request: Request) {
+ if (request.url.endsWith('/stream')) {
+ // Create a streaming response
+ let stream = new ReadableStream({
+ async start(controller) {
+ for (let i = 0; i < 5; i++) {
+ controller.enqueue(new TextEncoder().encode(`Chunk ${i}\n`))
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+ }
+ controller.close()
+ },
+ })
+
+ return new Response(stream, {
+ headers: { 'Content-Type': 'text/plain' },
+ })
+ }
+
+ return new Response('Not Found', { status: 404 })
+}
+```
+
+### Custom Hostname Configuration
+
+Configure custom hostnames for deployment on VPS or custom environments:
+
+```ts
+import * as http from 'node:http'
+import { createRequestListener } from 'remix/node-fetch-server'
+
+// Use a custom hostname (e.g., from environment variable)
+let hostname = process.env.HOST || 'api.example.com'
+
+async function handler(request: Request) {
+ // request.url will now use your custom hostname
+ console.log(request.url) // https://api.example.com/path
+
+ return Response.json({
+ message: 'Hello from custom domain!',
+ url: request.url,
+ })
+}
+
+let server = http.createServer(
+ createRequestListener(handler, { host: hostname }),
+)
+
+server.listen(3000)
+```
+
+### Accessing Client Information
+
+Get client connection details (IP address, port) for logging or security:
+
+```ts
+import { type FetchHandler } from 'remix/node-fetch-server'
+
+let handler: FetchHandler = async (request, client) => {
+ // Log client information
+ console.log(`Request from ${client.address}:${client.port}`)
+
+ // Use for rate limiting, geolocation, etc.
+ if (isRateLimited(client.address)) {
+ return new Response('Too Many Requests', { status: 429 })
+ }
+
+ return Response.json({
+ message: 'Hello!',
+ yourIp: client.address,
+ })
+}
+```
+
+### HTTPS Support
+
+Use with Node.js HTTPS module for secure connections:
+
+```ts
+import * as https from 'node:https'
+import * as fs from 'node:fs'
+import { createRequestListener } from 'remix/node-fetch-server'
+
+let options = {
+ key: fs.readFileSync('private-key.pem'),
+ cert: fs.readFileSync('certificate.pem'),
+}
+
+let server = https.createServer(options, createRequestListener(handler))
+
+server.listen(443, () => {
+ console.log('HTTPS Server running on port 443')
+})
+```
+
+## Advanced Usage
+
+### Low-level API
+
+For more control over request/response handling, use the low-level API:
+
+```ts
+import * as http from 'node:http'
+import { createRequest, sendResponse } from 'remix/node-fetch-server'
+
+let server = http.createServer(async (req, res) => {
+ // Convert Node.js request to Fetch API Request
+ let request = createRequest(req, res, { host: process.env.HOST })
+
+ try {
+ // Add custom headers or middleware logic
+ let startTime = Date.now()
+
+ // Process the request with your handler
+ let response = await handler(request)
+ // Make sure the response is mutable
+ response = new Response(response.body, response)
+
+ // Add response timing header
+ let duration = Date.now() - startTime
+ response.headers.set('X-Response-Time', `${duration}ms`)
+
+ // Send the response
+ await sendResponse(res, response)
+ } catch (error) {
+ console.error('Server error:', error)
+ res.writeHead(500, { 'Content-Type': 'text/plain' })
+ res.end('Internal Server Error')
+ }
+})
+
+server.listen(3000)
+```
+
+The low-level API provides:
+
+- `createRequest(req, res, options)` - Converts Node.js IncomingMessage to web
+ Request
+- `sendResponse(res, response)` - Sends web Response using Node.js
+ ServerResponse
+
+This is useful for:
+
+- Building custom middleware systems
+- Integrating with existing Node.js code
+- Implementing custom error handling
+- Performance-critical applications
+
+## Migration from Express
+
+Transitioning from Express? Here's a comparison of common patterns:
+
+### Basic Routing
+
+```ts
+// Express
+let app = express()
+
+app.get('/users/:id', async (req, res) => {
+ let user = await db.getUser(req.params.id)
+ if (!user) {
+ return res.status(404).json({ error: 'User not found' })
+ }
+ res.json(user)
+})
+
+app.listen(3000)
+
+// node-fetch-server
+import { createRequestListener } from 'remix/node-fetch-server'
+
+async function handler(request: Request) {
+ let url = new URL(request.url)
+ let match = url.pathname.match(/^\/users\/(\w+)$/)
+
+ if (match && request.method === 'GET') {
+ let user = await db.getUser(match[1])
+ if (!user) {
+ return Response.json({ error: 'User not found' }, { status: 404 })
+ }
+ return Response.json(user)
+ }
+
+ return new Response('Not Found', { status: 404 })
+}
+
+http.createServer(createRequestListener(handler)).listen(3000)
+```
+
+## Demos
+
+The
+[`demos` directory](https://github.com/remix-run/remix/tree/main/packages/node-fetch-server/demos)
+contains working demos:
+
+- [`demos/http2`](https://github.com/remix-run/remix/tree/main/packages/node-fetch-server/demos/http2) -
+ HTTP/2 server with TLS certificates
+
+## Benchmark
+
+To run benchmarks comparing `node-fetch-server` performance with comparable
+libraries:
+
+```sh
+pnpm run bench
+```
+
+## Related Packages
+
+- [`fetch-proxy`](https://github.com/remix-run/remix/tree/main/packages/fetch-proxy) -
+ Build HTTP proxy servers using the web fetch API
+
+## License
+
+See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
diff --git a/docs/agents/remix/node-fetch-server/advanced-usage.md b/docs/agents/remix/node-fetch-server/advanced-usage.md
deleted file mode 100644
index b4f1007..0000000
--- a/docs/agents/remix/node-fetch-server/advanced-usage.md
+++ /dev/null
@@ -1,58 +0,0 @@
-# Advanced usage
-
-Source: https://github.com/remix-run/remix/tree/main/packages/node-fetch-server
-
-## Low-level API
-
-For more control over request/response handling, use the low-level API:
-
-```ts
-import * as http from 'node:http'
-import { createRequest, sendResponse } from '@remix-run/node-fetch-server'
-
-let server = http.createServer(async (req, res) => {
- // Convert Node.js request to Fetch API Request
- let request = createRequest(req, res, { host: process.env.HOST })
-
- try {
- // Add custom headers or middleware logic
- let startTime = Date.now()
-
- // Process the request with your handler
- let response = await handler(request)
-
- // Add response timing header
- let duration = Date.now() - startTime
- response.headers.set('X-Response-Time', `${duration}ms`)
-
- // Send the response
- await sendResponse(res, response)
- } catch (error) {
- console.error('Server error:', error)
- res.writeHead(500, { 'Content-Type': 'text/plain' })
- res.end('Internal Server Error')
- }
-})
-
-server.listen(3000)
-```
-
-The low-level API provides:
-
-- `createRequest(req, res, options)` - Converts Node.js IncomingMessage to web
- Request
-- `sendResponse(res, response)` - Sends web Response using Node.js
- ServerResponse
-
-This is useful for:
-
-- Building custom middleware systems
-- Integrating with existing Node.js code
-- Implementing custom error handling
-- Performance-critical applications
-
-## Navigation
-
-- [node-fetch-server overview](./index.md)
-- [Migration from Express](./migration.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/node-fetch-server/demos-and-benchmark.md b/docs/agents/remix/node-fetch-server/demos-and-benchmark.md
deleted file mode 100644
index aab88a9..0000000
--- a/docs/agents/remix/node-fetch-server/demos-and-benchmark.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# Demos and benchmark
-
-Source: https://github.com/remix-run/remix/tree/main/packages/node-fetch-server
-
-## Demos
-
-The
-[`demos` directory](https://github.com/remix-run/remix/tree/main/packages/node-fetch-server/demos)
-contains working demos:
-
-- [`demos/http2`](https://github.com/remix-run/remix/tree/main/packages/node-fetch-server/demos/http2) -
- HTTP/2 server with TLS certificates
-
-## Benchmark
-
-To run benchmarks comparing `node-fetch-server` performance with comparable
-libraries:
-
-```sh
-pnpm run bench
-```
-
-## Related packages
-
-- [`fetch-proxy`](https://github.com/remix-run/remix/tree/main/packages/fetch-proxy) -
- Build HTTP proxy servers using the web fetch API
-
-## License
-
-See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [node-fetch-server overview](./index.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/node-fetch-server/index.md b/docs/agents/remix/node-fetch-server/index.md
deleted file mode 100644
index c0690ae..0000000
--- a/docs/agents/remix/node-fetch-server/index.md
+++ /dev/null
@@ -1,39 +0,0 @@
-# node-fetch-server
-
-Source: https://github.com/remix-run/remix/tree/main/packages/node-fetch-server
-
-## Overview
-
-Build portable Node.js servers using web-standard Fetch API primitives.
-
-`node-fetch-server` brings the simplicity and familiarity of the
-[Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to
-Node.js server development. Instead of dealing with Node's traditional
-`req`/`res` objects, you work with web-standard
-[`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) and
-[`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
-objects - the same APIs you already use in the browser and modern JavaScript
-runtimes.
-
-## Features
-
-- **Web Standards** - Standard `Request` and `Response` APIs
-- **Drop-in Integration** - Works with `node:http` and `node:https`
-- **Streaming Support** - Response support with `ReadableStream`
-- **Custom Hostname** - Configuration for deployment flexibility
-- **Client Info** - Access to client connection info (IP address, port)
-- **TypeScript** - Full TypeScript support with type definitions
-
-## Installation
-
-```sh
-bun add @remix-run/node-fetch-server
-```
-
-## Navigation
-
-- [Quick start examples](./quick-start.md)
-- [Advanced usage](./advanced-usage.md)
-- [Migration from Express](./migration.md)
-- [Demos and benchmark](./demos-and-benchmark.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/node-fetch-server/migration.md b/docs/agents/remix/node-fetch-server/migration.md
deleted file mode 100644
index 63dec91..0000000
--- a/docs/agents/remix/node-fetch-server/migration.md
+++ /dev/null
@@ -1,46 +0,0 @@
-# Migration from Express
-
-Source: https://github.com/remix-run/remix/tree/main/packages/node-fetch-server
-
-## Basic routing
-
-```ts
-// Express
-let app = express()
-
-app.get('/users/:id', async (req, res) => {
- let user = await db.getUser(req.params.id)
- if (!user) {
- return res.status(404).json({ error: 'User not found' })
- }
- res.json(user)
-})
-
-app.listen(3000)
-
-// node-fetch-server
-import { createRequestListener } from '@remix-run/node-fetch-server'
-
-async function handler(request: Request) {
- let url = new URL(request.url)
- let match = url.pathname.match(/^\\/users\\/(\\w+)$/)
-
- if (match && request.method === 'GET') {
- let user = await db.getUser(match[1])
- if (!user) {
- return Response.json({ error: 'User not found' }, { status: 404 })
- }
- return Response.json(user)
- }
-
- return new Response('Not Found', { status: 404 })
-}
-
-http.createServer(createRequestListener(handler)).listen(3000)
-```
-
-## Navigation
-
-- [node-fetch-server overview](./index.md)
-- [Demos and benchmark](./demos-and-benchmark.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/node-fetch-server/quick-start.md b/docs/agents/remix/node-fetch-server/quick-start.md
deleted file mode 100644
index 3ed2e0b..0000000
--- a/docs/agents/remix/node-fetch-server/quick-start.md
+++ /dev/null
@@ -1,193 +0,0 @@
-# Quick start
-
-Source: https://github.com/remix-run/remix/tree/main/packages/node-fetch-server
-
-## Basic server
-
-```ts
-import * as http from 'node:http'
-import { createRequestListener } from '@remix-run/node-fetch-server'
-
-// Example: Simple in-memory user storage
-let users = new Map([
- ['1', { id: '1', name: 'Alice', email: 'alice@example.com' }],
- ['2', { id: '2', name: 'Bob', email: 'bob@example.com' }],
-])
-
-async function handler(request: Request) {
- let url = new URL(request.url)
-
- // GET / - Home page
- if (url.pathname === '/' && request.method === 'GET') {
- return new Response('Welcome to the User API! Try GET /api/users')
- }
-
- // GET /api/users - List all users
- if (url.pathname === '/api/users' && request.method === 'GET') {
- return Response.json(Array.from(users.values()))
- }
-
- // GET /api/users/:id - Get specific user
- let userMatch = url.pathname.match(/^\\/api\\/users\\/(\\w+)$/)
- if (userMatch && request.method === 'GET') {
- let user = users.get(userMatch[1])
- if (user) {
- return Response.json(user)
- }
- return new Response('User not found', { status: 404 })
- }
-
- return new Response('Not Found', { status: 404 })
-}
-
-// Create a standard Node.js server
-let server = http.createServer(createRequestListener(handler))
-
-server.listen(3000, () => {
- console.log('Server running at http://localhost:3000')
-})
-```
-
-## Working with request data
-
-```ts
-async function handler(request: Request) {
- let url = new URL(request.url)
-
- // Handle JSON data
- if (request.method === 'POST' && url.pathname === '/api/users') {
- try {
- let userData = await request.json()
-
- // Validate required fields
- if (!userData.name || !userData.email) {
- return Response.json(
- { error: 'Name and email are required' },
- { status: 400 },
- )
- }
-
- // Create user (your implementation)
- let newUser = {
- id: Date.now().toString(),
- ...userData,
- }
-
- return Response.json(newUser, { status: 201 })
- } catch (error) {
- return Response.json({ error: 'Invalid JSON' }, { status: 400 })
- }
- }
-
- // Handle URL search params
- if (url.pathname === '/api/search') {
- let query = url.searchParams.get('q')
- let limit = parseInt(url.searchParams.get('limit') || '10')
-
- return Response.json({
- query,
- limit,
- results: [], // Your search results here
- })
- }
-
- return new Response('Not Found', { status: 404 })
-}
-```
-
-## Streaming responses
-
-```ts
-async function handler(request: Request) {
- if (request.url.endsWith('/stream')) {
- // Create a streaming response
- let stream = new ReadableStream({
- async start(controller) {
- for (let i = 0; i < 5; i++) {
- controller.enqueue(new TextEncoder().encode(`Chunk ${i}\\n`))
- await new Promise((resolve) => setTimeout(resolve, 1000))
- }
- controller.close()
- },
- })
-
- return new Response(stream, {
- headers: { 'Content-Type': 'text/plain' },
- })
- }
-
- return new Response('Not Found', { status: 404 })
-}
-```
-
-## Custom hostname configuration
-
-```ts
-import * as http from 'node:http'
-import { createRequestListener } from '@remix-run/node-fetch-server'
-
-// Use a custom hostname (e.g., from environment variable)
-let hostname = process.env.HOST || 'api.example.com'
-
-async function handler(request: Request) {
- // request.url will now use your custom hostname
- console.log(request.url) // https://api.example.com/path
-
- return Response.json({
- message: 'Hello from custom domain!',
- url: request.url,
- })
-}
-
-let server = http.createServer(
- createRequestListener(handler, { host: hostname }),
-)
-
-server.listen(3000)
-```
-
-## Accessing client information
-
-```ts
-import { type FetchHandler } from '@remix-run/node-fetch-server'
-
-let handler: FetchHandler = async (request, client) => {
- // Log client information
- console.log(`Request from ${client.address}:${client.port}`)
-
- // Use for rate limiting, geolocation, etc.
- if (isRateLimited(client.address)) {
- return new Response('Too Many Requests', { status: 429 })
- }
-
- return Response.json({
- message: 'Hello!',
- yourIp: client.address,
- })
-}
-```
-
-## HTTPS support
-
-```ts
-import * as https from 'node:https'
-import * as fs from 'node:fs'
-import { createRequestListener } from '@remix-run/node-fetch-server'
-
-let options = {
- key: fs.readFileSync('private-key.pem'),
- cert: fs.readFileSync('certificate.pem'),
-}
-
-let server = https.createServer(options, createRequestListener(handler))
-
-server.listen(443, () => {
- console.log('HTTPS Server running on port 443')
-})
-```
-
-## Navigation
-
-- [node-fetch-server overview](./index.md)
-- [Advanced usage](./advanced-usage.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/remix.md b/docs/agents/remix/remix.md
deleted file mode 100644
index 30fc7d9..0000000
--- a/docs/agents/remix/remix.md
+++ /dev/null
@@ -1,82 +0,0 @@
-# remix
-
-Source: https://github.com/remix-run/remix/tree/main/packages/remix
-
-## README
-
-A modern web framework for JavaScript.
-
-See [remix.run](https://remix.run) for framework docs.
-
-## Installation
-
-```sh
-npm i remix
-```
-
-## Package usage in Remix 3 alpha
-
-The `remix` package is used through subpath imports.
-
-- ✅ `import { createRouter } from 'remix/fetch-router'`
-- ✅ `import { route } from 'remix/fetch-router/routes'`
-- ✅ `import { createRoot } from 'remix/component'`
-- ❌ `import { ... } from 'remix'` (root import removed in `3.0.0-alpha.3`)
-
-## Subpath export surface (`3.0.0-alpha.3`)
-
-Top-level package exports currently include:
-
-- `remix/async-context-middleware`
-- `remix/component`
-- `remix/compression-middleware`
-- `remix/cookie`
-- `remix/data-schema`
-- `remix/data-table`
-- `remix/fetch-proxy`
-- `remix/fetch-router`
-- `remix/file-storage`
-- `remix/file-storage-s3`
-- `remix/form-data-middleware`
-- `remix/form-data-parser`
-- `remix/fs`
-- `remix/headers`
-- `remix/html-template`
-- `remix/interaction`
-- `remix/lazy-file`
-- `remix/logger-middleware`
-- `remix/method-override-middleware`
-- `remix/mime`
-- `remix/multipart-parser`
-- `remix/node-fetch-server`
-- `remix/response`
-- `remix/route-pattern`
-- `remix/session`
-- `remix/session-middleware`
-- `remix/session-storage-memcache`
-- `remix/session-storage-redis`
-- `remix/static-middleware`
-- `remix/tar-parser`
-
-Plus adapter/data helper subpaths and utility subpaths:
-
-- `remix/data-schema/checks`, `remix/data-schema/coerce`,
- `remix/data-schema/lazy`
-- `remix/data-table-mysql`, `remix/data-table-postgres`,
- `remix/data-table-sqlite`
-- `remix/fetch-router/routes`
-- `remix/component/jsx-runtime`, `remix/component/jsx-dev-runtime`,
- `remix/component/server`
-- `remix/interaction/form`, `remix/interaction/keys`,
- `remix/interaction/popover`, `remix/interaction/press`
-- `remix/response/compress`, `remix/response/file`, `remix/response/html`,
- `remix/response/redirect`
-- `remix/route-pattern/specificity`
-- `remix/session/cookie-storage`, `remix/session/fs-storage`,
- `remix/session/memory-storage`
-- `remix/file-storage/fs`, `remix/file-storage/memory`
-- `remix/multipart-parser/node`
-
-## Navigation
-
-- [Remix package index](./index.md)
diff --git a/docs/agents/remix/remix/README.md b/docs/agents/remix/remix/README.md
new file mode 100644
index 0000000..46c8502
--- /dev/null
+++ b/docs/agents/remix/remix/README.md
@@ -0,0 +1,15 @@
+# remix
+
+A modern web framework for JavaScript.
+
+See [remix.run](https://remix.run) for more information.
+
+## Installation
+
+```sh
+npm i remix
+```
+
+## License
+
+See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
diff --git a/docs/agents/remix/response/README.md b/docs/agents/remix/response/README.md
new file mode 100644
index 0000000..57fc86e
--- /dev/null
+++ b/docs/agents/remix/response/README.md
@@ -0,0 +1,274 @@
+# response
+
+Response helper utilities for the web Fetch API. `response` provides focused
+helpers for common HTTP responses with correct headers and caching semantics.
+
+## Features
+
+- **Web Standards Compliant:** Built on the standard `Response` API, works in
+ any JavaScript runtime (Node.js, Bun, Deno, Cloudflare Workers)
+- [**File Responses:**](#file-responses) Full HTTP semantics including ETags,
+ Last-Modified, conditional requests, and Range support
+- [**HTML Responses:**](#html-responses) Automatic DOCTYPE prepending and proper
+ Content-Type headers
+- [**Redirect Responses:**](#redirect-responses) Simple redirect creation with
+ customizable status codes
+- [**Compress Responses:**](#compress-responses) Streaming compression based on
+ Accept-Encoding header
+
+## Installation
+
+```sh
+npm i remix
+```
+
+## Usage
+
+This package provides no default export. Instead, import the specific helper you
+need:
+
+```ts
+import { createFileResponse } from 'remix/response/file'
+import { createHtmlResponse } from 'remix/response/html'
+import { createRedirectResponse } from 'remix/response/redirect'
+import { compressResponse } from 'remix/response/compress'
+```
+
+### File Responses
+
+The `createFileResponse` helper creates a response for serving files with full
+HTTP semantics. It works with both native `File` objects and `LazyFile` from
+`@remix-run/lazy-file`:
+
+```ts
+import { createFileResponse } from 'remix/response/file'
+import { openLazyFile } from 'remix/fs'
+
+let lazyFile = openLazyFile('./public/image.jpg')
+let response = await createFileResponse(lazyFile, request, {
+ cacheControl: 'public, max-age=3600',
+})
+```
+
+#### Features
+
+- **Content-Type** and **Content-Length** headers
+- **ETag** generation (weak or strong)
+- **Last-Modified** headers
+- **Cache-Control** headers
+- **Conditional requests** (`If-None-Match`, `If-Modified-Since`, `If-Match`,
+ `If-Unmodified-Since`)
+- **Range requests** for partial content (`206 Partial Content`)
+- **HEAD** request support
+
+#### Options
+
+```ts
+await createFileResponse(file, request, {
+ // Cache-Control header value.
+ // Defaults to `undefined` (no Cache-Control header).
+ cacheControl: 'public, max-age=3600',
+
+ // ETag generation strategy:
+ // - 'weak': Generates weak ETags based on file size and mtime (default)
+ // - 'strong': Generates strong ETags by hashing file content
+ // - false: Disables ETag generation
+ etag: 'weak',
+
+ // Hash algorithm for strong ETags (Web Crypto API algorithm names).
+ // Only used when etag: 'strong'.
+ // Defaults to 'SHA-256'.
+ digest: 'SHA-256',
+
+ // Whether to generate Last-Modified headers.
+ // Defaults to `true`.
+ lastModified: true,
+
+ // Whether to support HTTP Range requests for partial content.
+ // Defaults to `true`.
+ acceptRanges: true,
+})
+```
+
+#### Strong ETags and Content Hashing
+
+For assets that require strong validation (e.g., to support
+[`If-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match)
+preconditions or
+[`If-Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range)
+with
+[`Range` requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range)),
+configure strong ETag generation:
+
+```ts
+return createFileResponse(file, request, {
+ etag: 'strong',
+})
+```
+
+By default, strong ETags are generated using the Web Crypto API with the
+`'SHA-256'` algorithm. You can customize this:
+
+```ts
+return createFileResponse(file, request, {
+ etag: 'strong',
+ // Specify a different hash algorithm
+ digest: 'SHA-512',
+})
+```
+
+For large files or custom hashing requirements, provide a custom digest
+function:
+
+```ts
+await createFileResponse(file, request, {
+ etag: 'strong',
+ async digest(file) {
+ // Custom streaming hash for large files
+ let { createHash } = await import('node:crypto')
+ let hash = createHash('sha256')
+ for await (let chunk of file.stream()) {
+ hash.update(chunk)
+ }
+ return hash.digest('hex')
+ },
+})
+```
+
+### HTML Responses
+
+The `createHtmlResponse` helper creates HTML responses with proper
+`Content-Type` and DOCTYPE handling:
+
+```ts
+import { createHtmlResponse } from 'remix/response/html'
+
+let response = createHtmlResponse('Hello, World!
')
+// Content-Type: text/html; charset=UTF-8
+// Body: Hello, World!
+```
+
+The helper automatically prepends `` if not already present. It
+works with strings, `SafeHtml`
+[from `@remix-run/html-template`](https://github.com/remix-run/remix/tree/main/packages/html-template),
+Blobs/Files, ArrayBuffers, and ReadableStreams.
+
+```ts
+import { html } from 'remix/html-template'
+import { createHtmlResponse } from 'remix/response/html'
+
+let name = ''
+let response = createHtmlResponse(html`Hello, ${name}!
`)
+// Safely escaped HTML
+```
+
+### Redirect Responses
+
+The `createRedirectResponse` helper creates redirect responses. The main
+improvements over the native `Response.redirect` API are:
+
+- Accepts a relative `location` instead of a full URL. This isn't technically
+ spec-compliant, but it's so widespread that many applications use relative
+ redirects regularly without issues.
+- Accepts a `ResponseInit` object as the second argument, allowing you to set
+ additional headers and status code.
+
+```ts
+import { createRedirectResponse } from 'remix/response/redirect'
+
+// Default 302 redirect
+let response = createRedirectResponse('/login')
+
+// Custom status code
+let response = createRedirectResponse('/new-page', 301)
+
+// With additional headers
+let response = createRedirectResponse('/dashboard', {
+ status: 303,
+ headers: { 'X-Redirect-Reason': 'authentication' },
+})
+```
+
+### Compress Responses
+
+The `compressResponse` helper compresses a `Response` based on the client's
+`Accept-Encoding` header:
+
+```ts
+import { compressResponse } from 'remix/response/compress'
+
+let response = new Response(JSON.stringify(data), {
+ headers: { 'Content-Type': 'application/json' },
+})
+let compressed = await compressResponse(response, request)
+```
+
+Compression is automatically skipped for:
+
+- Responses with no `Accept-Encoding` header
+- Responses that are already compressed (existing `Content-Encoding`)
+- Responses with `Cache-Control: no-transform`
+- Responses with `Content-Length` below threshold (default: 1024 bytes)
+- Responses with range support (`Accept-Ranges: bytes`)
+- 206 Partial Content responses
+- HEAD requests (only headers are modified)
+
+#### Options
+
+The `compressResponse` helper accepts options to customize compression behavior:
+
+```ts
+await compressResponse(response, request, {
+ // Minimum size in bytes to compress (only enforced if Content-Length is present).
+ // Default: 1024
+ threshold: 1024,
+
+ // Which encodings the server supports for negotiation.
+ // Defaults to ['br', 'gzip', 'deflate']
+ encodings: ['br', 'gzip', 'deflate'],
+
+ // node:zlib options for gzip/deflate compression.
+ // For SSE responses (text/event-stream), flush: Z_SYNC_FLUSH
+ // is automatically applied unless you explicitly set a flush value.
+ // See: https://nodejs.org/api/zlib.html#class-options
+ zlib: {
+ level: 6,
+ },
+
+ // node:zlib options for Brotli compression.
+ // For SSE responses (text/event-stream), flush: BROTLI_OPERATION_FLUSH
+ // is automatically applied unless you explicitly set a flush value.
+ // See: https://nodejs.org/api/zlib.html#class-brotlioptions
+ brotli: {
+ params: {
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 4,
+ },
+ },
+})
+```
+
+#### Range Requests and Compression
+
+Range requests and compression are mutually exclusive. When
+`Accept-Ranges: bytes` is present in the response headers, `compressResponse`
+will not compress the response. This is why the `createFileResponse` helper
+enables ranges only for non-compressible MIME types by default - to allow
+text-based assets to be compressed while still supporting resumable downloads
+for media files.
+
+## Related Packages
+
+- [`@remix-run/headers`](https://github.com/remix-run/remix/tree/main/packages/headers) -
+ Type-safe HTTP header manipulation
+- [`@remix-run/html-template`](https://github.com/remix-run/remix/tree/main/packages/html-template) -
+ Safe HTML templating with automatic escaping
+- [`@remix-run/fs`](https://github.com/remix-run/remix/tree/main/packages/fs) -
+ File system utilities including `openFile`
+- [`@remix-run/fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) -
+ Build HTTP routers using the web fetch API
+- [`@remix-run/mime`](https://github.com/remix-run/remix/tree/main/packages/mime) -
+ MIME type utilities
+
+## License
+
+See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
diff --git a/docs/agents/remix/response/compress-responses.md b/docs/agents/remix/response/compress-responses.md
deleted file mode 100644
index 9573fdb..0000000
--- a/docs/agents/remix/response/compress-responses.md
+++ /dev/null
@@ -1,72 +0,0 @@
-# Compressed responses
-
-Source: https://github.com/remix-run/remix/tree/main/packages/response
-
-The `compressResponse` helper compresses a `Response` based on the client's
-`Accept-Encoding` header:
-
-```ts
-import { compressResponse } from '@remix-run/response/compress'
-
-let response = new Response(JSON.stringify(data), {
- headers: { 'Content-Type': 'application/json' },
-})
-let compressed = await compressResponse(response, request)
-```
-
-Compression is automatically skipped for:
-
-- Responses with no `Accept-Encoding` header
-- Responses that are already compressed (existing `Content-Encoding`)
-- Responses with `Cache-Control: no-transform`
-- Responses with `Content-Length` below threshold (default: 1024 bytes)
-- Responses with range support (`Accept-Ranges: bytes`)
-- 206 Partial Content responses
-- HEAD requests (only headers are modified)
-
-## Options
-
-```ts
-await compressResponse(response, request, {
- // Minimum size in bytes to compress (only enforced if Content-Length is present).
- // Default: 1024
- threshold: 1024,
-
- // Which encodings the server supports for negotiation.
- // Defaults to ['br', 'gzip', 'deflate']
- encodings: ['br', 'gzip', 'deflate'],
-
- // node:zlib options for gzip/deflate compression.
- // For SSE responses (text/event-stream), flush: Z_SYNC_FLUSH
- // is automatically applied unless you explicitly set a flush value.
- // See: https://nodejs.org/api/zlib.html#class-options
- zlib: {
- level: 6,
- },
-
- // node:zlib options for Brotli compression.
- // For SSE responses (text/event-stream), flush: BROTLI_OPERATION_FLUSH
- // is automatically applied unless you explicitly set a flush value.
- // See: https://nodejs.org/api/zlib.html#class-brotlioptions
- brotli: {
- params: {
- [zlib.constants.BROTLI_PARAM_QUALITY]: 4,
- },
- },
-})
-```
-
-## Range requests and compression
-
-Range requests and compression are mutually exclusive. When
-`Accept-Ranges: bytes` is present in the response headers, `compressResponse`
-will not compress the response. This is why the `createFileResponse` helper
-enables ranges only for non-compressible MIME types by default - to allow
-text-based assets to be compressed while still supporting resumable downloads
-for media files.
-
-## Navigation
-
-- [Response overview](./index.md)
-- [Related packages](./related.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/response/file-responses.md b/docs/agents/remix/response/file-responses.md
deleted file mode 100644
index 43d7ddc..0000000
--- a/docs/agents/remix/response/file-responses.md
+++ /dev/null
@@ -1,108 +0,0 @@
-# File responses
-
-Source: https://github.com/remix-run/remix/tree/main/packages/response
-
-The `createFileResponse` helper creates a response for serving files with full
-HTTP semantics. It works with both native `File` objects and `LazyFile` from
-`@remix-run/lazy-file`.
-
-```ts
-import { createFileResponse } from '@remix-run/response/file'
-import { openLazyFile } from '@remix-run/fs'
-
-let lazyFile = openLazyFile('./public/image.jpg')
-let response = await createFileResponse(lazyFile, request, {
- cacheControl: 'public, max-age=3600',
-})
-```
-
-## Features
-
-- **Content-Type** and **Content-Length** headers
-- **ETag** generation (weak or strong)
-- **Last-Modified** headers
-- **Cache-Control** headers
-- **Conditional requests** (`If-None-Match`, `If-Modified-Since`, `If-Match`,
- `If-Unmodified-Since`)
-- **Range requests** for partial content (`206 Partial Content`)
-- **HEAD** request support
-
-## Options
-
-```ts
-await createFileResponse(file, request, {
- // Cache-Control header value.
- // Defaults to `undefined` (no Cache-Control header).
- cacheControl: 'public, max-age=3600',
-
- // ETag generation strategy:
- // - 'weak': Generates weak ETags based on file size and mtime (default)
- // - 'strong': Generates strong ETags by hashing file content
- // - false: Disables ETag generation
- etag: 'weak',
-
- // Hash algorithm for strong ETags (Web Crypto API algorithm names).
- // Only used when etag: 'strong'.
- // Defaults to 'SHA-256'.
- digest: 'SHA-256',
-
- // Whether to generate Last-Modified headers.
- // Defaults to `true`.
- lastModified: true,
-
- // Whether to support HTTP Range requests for partial content.
- // Defaults to `true`.
- acceptRanges: true,
-})
-```
-
-## Strong ETags and content hashing
-
-For assets that require strong validation (e.g., to support
-[`If-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match)
-preconditions or
-[`If-Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range)
-with
-[`Range` requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range)),
-configure strong ETag generation:
-
-```ts
-return createFileResponse(file, request, {
- etag: 'strong',
-})
-```
-
-By default, strong ETags are generated using the Web Crypto API with the
-`'SHA-256'` algorithm. You can customize this:
-
-```ts
-return createFileResponse(file, request, {
- etag: 'strong',
- // Specify a different hash algorithm
- digest: 'SHA-512',
-})
-```
-
-For large files or custom hashing requirements, provide a custom digest
-function:
-
-```ts
-await createFileResponse(file, request, {
- etag: 'strong',
- async digest(file) {
- // Custom streaming hash for large files
- let { createHash } = await import('node:crypto')
- let hash = createHash('sha256')
- for await (let chunk of file.stream()) {
- hash.update(chunk)
- }
- return hash.digest('hex')
- },
-})
-```
-
-## Navigation
-
-- [Response overview](./index.md)
-- [HTML responses](./html-responses.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/response/html-responses.md b/docs/agents/remix/response/html-responses.md
deleted file mode 100644
index 27ff0f0..0000000
--- a/docs/agents/remix/response/html-responses.md
+++ /dev/null
@@ -1,33 +0,0 @@
-# HTML responses
-
-Source: https://github.com/remix-run/remix/tree/main/packages/response
-
-The `createHtmlResponse` helper creates HTML responses with proper
-`Content-Type` and DOCTYPE handling:
-
-```ts
-import { createHtmlResponse } from '@remix-run/response/html'
-
-let response = createHtmlResponse('Hello, World!
')
-// Content-Type: text/html; charset=UTF-8
-// Body: Hello, World!
-```
-
-The helper automatically prepends `` if not already present. It
-works with strings, `SafeHtml` from `@remix-run/html-template`, Blobs/Files,
-ArrayBuffers, and ReadableStreams.
-
-```ts
-import { html } from '@remix-run/html-template'
-import { createHtmlResponse } from '@remix-run/response/html'
-
-let name = ''
-let response = createHtmlResponse(html`Hello, ${name}!
`)
-// Safely escaped HTML
-```
-
-## Navigation
-
-- [Response overview](./index.md)
-- [Redirect responses](./redirect-responses.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/response/index.md b/docs/agents/remix/response/index.md
deleted file mode 100644
index eb5fedc..0000000
--- a/docs/agents/remix/response/index.md
+++ /dev/null
@@ -1,48 +0,0 @@
-# response
-
-Source: https://github.com/remix-run/remix/tree/main/packages/response
-
-## Overview
-
-Response helpers for the web Fetch API. `response` provides a collection of
-helper functions for creating common HTTP responses with proper headers and
-semantics.
-
-## Features
-
-- **Web Standards Compliant:** Built on the standard `Response` API, works in
- any JavaScript runtime (Node.js, Bun, Deno, Cloudflare Workers)
-- **File Responses:** Full HTTP semantics including ETags, Last-Modified,
- conditional requests, and Range support
-- **HTML Responses:** Automatic DOCTYPE prepending and proper Content-Type
- headers
-- **Redirect Responses:** Simple redirect creation with customizable status
- codes
-- **Compress Responses:** Streaming compression based on Accept-Encoding header
-
-## Installation
-
-```sh
-bun add @remix-run/response
-```
-
-## Usage
-
-This package provides no default export. Instead, import the specific helper you
-need:
-
-```ts
-import { createFileResponse } from '@remix-run/response/file'
-import { createHtmlResponse } from '@remix-run/response/html'
-import { createRedirectResponse } from '@remix-run/response/redirect'
-import { compressResponse } from '@remix-run/response/compress'
-```
-
-## Navigation
-
-- [File responses](./file-responses.md)
-- [HTML responses](./html-responses.md)
-- [Redirect responses](./redirect-responses.md)
-- [Compressed responses](./compress-responses.md)
-- [Related packages](./related.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/response/redirect-responses.md b/docs/agents/remix/response/redirect-responses.md
deleted file mode 100644
index f5f58f5..0000000
--- a/docs/agents/remix/response/redirect-responses.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# Redirect responses
-
-Source: https://github.com/remix-run/remix/tree/main/packages/response
-
-The `createRedirectResponse` helper creates redirect responses. The main
-improvements over the native `Response.redirect` API are:
-
-- Accepts a relative `location` instead of a full URL.
-- Accepts a `ResponseInit` object as the second argument, allowing you to set
- additional headers and status code.
-
-```ts
-import { createRedirectResponse } from '@remix-run/response/redirect'
-
-// Default 302 redirect
-let response = createRedirectResponse('/login')
-
-// Custom status code
-let response = createRedirectResponse('/new-page', 301)
-
-// With additional headers
-let response = createRedirectResponse('/dashboard', {
- status: 303,
- headers: { 'X-Redirect-Reason': 'authentication' },
-})
-```
-
-## Navigation
-
-- [Response overview](./index.md)
-- [Compressed responses](./compress-responses.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/response/related.md b/docs/agents/remix/response/related.md
deleted file mode 100644
index 9e1eabf..0000000
--- a/docs/agents/remix/response/related.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# Related packages
-
-Source: https://github.com/remix-run/remix/tree/main/packages/response
-
-## Related packages
-
-- [`@remix-run/headers`](https://github.com/remix-run/remix/tree/main/packages/headers) -
- Type-safe HTTP header manipulation
-- [`@remix-run/html-template`](https://github.com/remix-run/remix/tree/main/packages/html-template) -
- Safe HTML templating with automatic escaping
-- [`@remix-run/fs`](https://github.com/remix-run/remix/tree/main/packages/fs) -
- File system utilities including `openFile`
-- [`@remix-run/fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) -
- Build HTTP routers using the web fetch API
-- [`@remix-run/mime`](https://github.com/remix-run/remix/tree/main/packages/mime) -
- MIME type utilities
-
-## License
-
-See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Response overview](./index.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/route-pattern.md b/docs/agents/remix/route-pattern/README.md
similarity index 74%
rename from docs/agents/remix/route-pattern.md
rename to docs/agents/remix/route-pattern/README.md
index a8fafa9..ed8dc32 100644
--- a/docs/agents/remix/route-pattern.md
+++ b/docs/agents/remix/route-pattern/README.md
@@ -1,13 +1,31 @@
# route-pattern
-Source: https://github.com/remix-run/remix/tree/main/packages/route-pattern
+Type-safe URL matching and href generation for JavaScript. `route-pattern`
+supports path params, wildcards, optionals, and full-URL patterns with
+predictable ranking.
+
+## Features
+
+- **Type-Safe Params** - Infer params from patterns for compile-time route
+ correctness
+- **Flexible Pattern Syntax** - Variables, wildcards, optionals, and query
+ constraints
+- **Full URL Support** - Match protocol, host, pathname, and search params
+- **Deterministic Ranking** - Static segments beat params, and params beat
+ wildcards
+- **Runtime Agnostic** - Works across Node.js, Bun, Deno, Cloudflare Workers,
+ and browsers
-## README
+## Installation
+
+```sh
+npm i remix
+```
-Fast URL matching and href generation with type safe params.
+## Quick Example
```ts
-import { RoutePattern } from '@remix-run/route-pattern'
+import { RoutePattern } from 'remix/route-pattern'
let blog = new RoutePattern('blog/:slug')
blog.match('https://remix.run/blog/v3') // { params: { slug: 'v3' } }
@@ -23,22 +41,6 @@ cdn.match('https://us-west.cdn.com/assets/images/logo.png') // { params: { regio
cdn.href({ region: 'us-west', file: 'images/logo', ext: 'png' }) // 'https://us-west.cdn.com/assets/images/logo.png'
```
-**Goals**
-
-- **Universal**: Runs on any JS runtime (Node, Bun, Deno, Cloudflare Workers,
- browsers, ...)
-- **Type-safe params**: Autocomplete and validation for variables, wildcards,
- and search params
-- **Full URL matching**: Protocol, hostname, port, pathname, search params
-- **Fast**: Includes matchers optimized for small and large apps
-- **Simple ranking**: Static segments beat variables, variables beat wildcards
-
-## Installation
-
-```sh
-bun add @remix-run/route-pattern
-```
-
## Intuitive syntax
**Variables** capture dynamic segments using `:name`:
@@ -65,11 +67,14 @@ new RoutePattern('docs(/guides/:category)') // multiple segments optional: /docs
new RoutePattern('api(/v:major(.:minor))') // nested optionals: /api, /api/v2, /api/v2.1
```
-**Search params** narrow matches using `?key` or `?key=value`:
+**Search params** narrow matches using `?key`, `?key=`, or `?key=value`. Parsing
+and serialization follow `URLSearchParams`
+(`application/x-www-form-urlencoded`): `?key` and `?key=` are the same
+constraint (stored as an empty `Set` in `ast.search`: key must be present; empty
+value is OK), and spaces use `+` / `%20` like in real query strings.
```ts
-new RoutePattern('search?q') // requires ?q in URL
-new RoutePattern('search?q=') // requires ?q with any value
+new RoutePattern('search?q') // same constraint as ?q= — key must be present
new RoutePattern('search?q=routing') // requires ?q=routing exactly
```
@@ -87,9 +92,9 @@ Match URLs against multiple patterns. Each pattern can have associated data
(handlers, route IDs, metadata, etc.):
```ts
-import { ArrayMatcher as Matcher } from '@remix-run/route-pattern'
+import { ArrayMatcher as Matcher } from 'remix/route-pattern'
-// Any data type you want!
+// Any data type you want! 👇
let matcher = new Matcher()
matcher.add('/', 'home')
@@ -108,14 +113,14 @@ matcher.match('https://example.com/api/v2/users/profile')
- **ArrayMatcher**: Best for small apps (~80 routes or fewer)
- **TrieMatcher**: Best for large apps (hundreds of routes)
-Note: Performance depends on your specific patterns - benchmark both to verify
+Note: Performance depends on your specific patterns—benchmark both to verify
which is faster for your app.
Both implement the `Matcher` API so you can swap them out easily:
```ts
-// import { ArrayMatcher as Matcher } from "@remix-run/route-pattern"
-import { TrieMatcher as Matcher } from '@remix-run/route-pattern'
+// import { ArrayMatcher as Matcher } from 'remix/route-pattern'
+import { TrieMatcher as Matcher } from 'remix/route-pattern'
```
## Specificity
@@ -125,7 +130,7 @@ When multiple patterns match a URL, the most specific pattern wins.
**Pathname specificity** (left-to-right):
```ts
-import { ArrayMatcher } from '@remix-run/route-pattern'
+import { ArrayMatcher } from 'remix/route-pattern'
let matcher = new ArrayMatcher()
matcher.add('blog/hello', 'static')
@@ -144,12 +149,11 @@ matcher.match('https://example.com/blog/hello')
let router = new ArrayMatcher()
router.add('search', 'no-params')
router.add('search?q', 'has-q')
-router.add('search?q=', 'has-q-with-value')
router.add('search?q=hello', 'exact-match')
router.match('https://example.com/search?q=hello')
// { pattern: 'search?q=hello', params: {}, data: 'exact-match' }
-// More constrained search params = more specific
+// More constrained search params = more specific (`?q` and `?q=` tie)
```
## Benchmark
@@ -169,7 +173,3 @@ pnpm bench bench/comparison.bench.ts
## License
See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Remix package index](./index.md)
diff --git a/docs/agents/remix/session-middleware.md b/docs/agents/remix/session-middleware/README.md
similarity index 63%
rename from docs/agents/remix/session-middleware.md
rename to docs/agents/remix/session-middleware/README.md
index 70a1d76..cd7bce3 100644
--- a/docs/agents/remix/session-middleware.md
+++ b/docs/agents/remix/session-middleware/README.md
@@ -1,26 +1,29 @@
# session-middleware
-Source: https://github.com/remix-run/remix/tree/main/packages/session-middleware
+Session middleware for Remix using signed cookies. It loads session state from
+incoming requests, stores it in request context using `Session`, and persists
+updates automatically.
-## README
+## Features
-Middleware for managing sessions with
-[`@remix-run/fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router)
-via securely signed cookies.
+- **Session Lifecycle Handling** - Reads and saves session state per request
+- **Context Integration** - Exposes session APIs directly on request context
+- **Secure Cookie Support** - Designed for signed session cookies
## Installation
```sh
-bun add @remix-run/session-middleware
+npm i remix
```
## Usage
```ts
-import { createRouter } from '@remix-run/fetch-router'
-import { createCookie } from '@remix-run/cookie'
-import { createCookieSessionStorage } from '@remix-run/session/cookie-storage'
-import { session } from '@remix-run/session-middleware'
+import { createRouter } from 'remix/fetch-router'
+import { createCookie } from 'remix/cookie'
+import { Session } from 'remix/session'
+import { createCookieSessionStorage } from 'remix/session/cookie-storage'
+import { session } from 'remix/session-middleware'
let sessionCookie = createCookie('__session', {
secrets: ['s3cr3t'], // session cookies must be signed!
@@ -36,15 +39,16 @@ let router = createRouter({
})
router.get('/', (context) => {
- context.session.set('count', Number(context.session.get('count') ?? 0) + 1)
- return new Response(`Count: ${context.session.get('count')}`)
+ let session = context.get(Session)
+ session.set('count', Number(session.get('count') ?? 0) + 1)
+ return new Response(`Count: ${session.get('count')}`)
})
```
The middleware:
- Reads the session from the cookie on incoming requests
-- Makes it available as `context.session`
+- Makes it available as `context.get(Session)`
- Automatically saves session changes and sets the cookie on responses
Note: The session cookie must be signed for security. This prevents tampering
@@ -55,9 +59,11 @@ with the session data on the client.
A basic login/logout flow could look like this:
```ts
-import * as res from '@remix-run/fetch-router/response-helpers'
+import * as res from 'remix/fetch-router/response-helpers'
+import { Session } from 'remix/session'
-router.get('/login', ({ session }) => {
+router.get('/login', ({ get }) => {
+ let session = get(Session)
let error = session.get('error')
return res.html(`
@@ -74,7 +80,9 @@ router.get('/login', ({ session }) => {
`)
})
-router.post('/login', ({ session, formData }) => {
+router.post('/login', ({ get }) => {
+ let session = get(Session)
+ let formData = get(FormData)
let username = formData.get('username')
let password = formData.get('password')
@@ -90,7 +98,8 @@ router.post('/login', ({ session, formData }) => {
return res.redirect('/dashboard')
})
-router.post('/logout', ({ session }) => {
+router.post('/logout', ({ get }) => {
+ let session = get(Session)
session.destroy()
return res.redirect('/')
})
@@ -108,7 +117,3 @@ router.post('/logout', ({ session }) => {
## License
See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Remix package index](./index.md)
diff --git a/docs/agents/remix/session-storage-memcache.md b/docs/agents/remix/session-storage-memcache.md
deleted file mode 100644
index c2f8a4b..0000000
--- a/docs/agents/remix/session-storage-memcache.md
+++ /dev/null
@@ -1,46 +0,0 @@
-# session-storage-memcache
-
-Source:
-https://github.com/remix-run/remix/tree/main/packages/session-storage-memcache
-
-## README
-
-Memcache session storage for `remix/session`.
-
-## Installation
-
-```sh
-npm i remix
-```
-
-## Usage
-
-```ts
-import { createMemcacheSessionStorage } from 'remix/session-storage-memcache'
-
-let sessionStorage = createMemcacheSessionStorage('127.0.0.1:11211', {
- keyPrefix: 'my-app:session:',
- ttlSeconds: 60 * 60 * 24 * 7,
-})
-```
-
-## Options
-
-- `useUnknownIds` (`boolean`, default: `false`)
-- `keyPrefix` (`string`, default: `'remix:session:'`)
-- `ttlSeconds` (`number`, default: `0`)
-
-Memcache storage uses TCP sockets and therefore requires a Node.js runtime.
-
-## Related packages
-
-- [`session`](https://github.com/remix-run/remix/tree/main/packages/session)
-- [`session-middleware`](https://github.com/remix-run/remix/tree/main/packages/session-middleware)
-
-## License
-
-See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Remix package index](./index.md)
diff --git a/docs/agents/remix/session-storage-memcache/README.md b/docs/agents/remix/session-storage-memcache/README.md
new file mode 100644
index 0000000..6e7ded0
--- /dev/null
+++ b/docs/agents/remix/session-storage-memcache/README.md
@@ -0,0 +1,42 @@
+# session-storage-memcache
+
+Memcache session storage for
+[`@remix-run/session`](https://github.com/remix-run/remix/tree/main/packages/session).
+
+## Installation
+
+```sh
+npm i remix
+```
+
+## Usage
+
+```ts
+import { createMemcacheSessionStorage } from 'remix/session-storage-memcache'
+
+let sessionStorage = createMemcacheSessionStorage('127.0.0.1:11211', {
+ keyPrefix: 'my-app:session:',
+ ttlSeconds: 60 * 60 * 24 * 7,
+})
+```
+
+Available options:
+
+- `useUnknownIds` (default: `false`) - reuse unknown session IDs sent by the
+ client
+- `keyPrefix` (default: `'remix:session:'`) - prefix for all Memcache keys
+- `ttlSeconds` (default: `0`) - session expiration in seconds (`0` means no
+ expiration)
+
+Note: Memcache storage uses TCP sockets and requires a Node.js runtime.
+
+## Related Packages
+
+- [`@remix-run/session`](https://github.com/remix-run/remix/tree/main/packages/session) -
+ Core session primitives and storage interface
+- [`@remix-run/session-middleware`](https://github.com/remix-run/remix/tree/main/packages/session-middleware) -
+ Middleware for wiring session storage into request handling
+
+## License
+
+See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
diff --git a/docs/agents/remix/session-storage-redis.md b/docs/agents/remix/session-storage-redis/README.md
similarity index 53%
rename from docs/agents/remix/session-storage-redis.md
rename to docs/agents/remix/session-storage-redis/README.md
index 96534c4..835fcdc 100644
--- a/docs/agents/remix/session-storage-redis.md
+++ b/docs/agents/remix/session-storage-redis/README.md
@@ -1,23 +1,20 @@
# session-storage-redis
-Source:
-https://github.com/remix-run/remix/tree/main/packages/session-storage-redis
-
-## README
-
-Redis-backed session storage for `remix/session`.
+Redis-backed session storage for
+[`@remix-run/session`](https://github.com/remix-run/remix/tree/main/packages/session).
+Use this package when app servers need to share session state through Redis.
## Installation
```sh
-npm i remix redis
+npm i @remix-run/session @remix-run/session-storage-redis redis
```
## Usage
```ts
import { createClient } from 'redis'
-import { createRedisSessionStorage } from 'remix/session-storage-redis'
+import { createRedisSessionStorage } from '@remix-run/session-storage-redis'
let redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()
@@ -30,14 +27,12 @@ let sessionStorage = createRedisSessionStorage(redis, {
## Options
+`createRedisSessionStorage(client, options)` supports:
+
- `keyPrefix` (`string`, default: `'session:'`)
-- `ttl` (`number` in seconds)
+- `ttl` (`number` seconds)
- `useUnknownIds` (`boolean`, default: `false`)
## License
See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Remix package index](./index.md)
diff --git a/docs/agents/remix/session/README.md b/docs/agents/remix/session/README.md
new file mode 100644
index 0000000..bf5256e
--- /dev/null
+++ b/docs/agents/remix/session/README.md
@@ -0,0 +1,204 @@
+# session
+
+A session management library for JavaScript. This package provides a flexible
+and secure way to manage user sessions in server-side applications with a
+flexible API for different session storage strategies.
+
+## Features
+
+- **Multiple Storage Strategies:** Includes memory, cookie, and file-based
+ [session storage strategies](#storage-strategies) for different use cases
+- **Flash Messages:** Support for [flash data](#flash-messages) that persists
+ only for the next request
+- **Session Security:** Built-in protection against
+ [session fixation attacks](#regenerating-session-ids)
+
+## Installation
+
+```sh
+npm i remix
+```
+
+## Usage
+
+The following example shows how to use a session to persist data across
+requests.
+
+The standard pattern when working with sessions is to read the session from the
+request, modify it, and save it back to storage and write the session cookie to
+the response.
+
+```ts
+import { createCookieSessionStorage } from 'remix/session/cookie-storage'
+
+// Create a session storage. This is used to store session data across requests.
+let storage = createCookieSessionStorage()
+
+// This function simulates a typical request flow where the session is read from
+// the request cookie, modified, and the new cookie is returned in the response.
+async function handleRequest(cookie: string | null) {
+ let session = await storage.read(cookie)
+ session.set('count', Number(session.get('count') ?? 0) + 1)
+ return {
+ session, // The session data from this "request"
+ cookie: await storage.save(session), // The cookie to use on the next request
+ }
+}
+
+let response1 = await handleRequest(null)
+assert.equal(response1.session.get('count'), 1)
+
+let response2 = await handleRequest(response1.cookie)
+assert.equal(response2.session.get('count'), 2)
+
+let response3 = await handleRequest(response2.cookie)
+assert.equal(response3.session.get('count'), 3)
+```
+
+The example above is a low-level illustration of how to use this package for
+session management. In practice, you would use the `session` middleware in
+[`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router)
+to automatically manage the session for you.
+
+### Flash Messages
+
+Flash messages are values that persist only for the next request, perfect for
+displaying one-time notifications:
+
+```ts
+async function requestIndex(cookie: string | null) {
+ let session = await storage.read(cookie)
+ return { session, cookie: await storage.save(session) }
+}
+
+async function requestSubmit(cookie: string | null) {
+ let session = await storage.read(cookie)
+ session.flash('message', 'success!')
+ return { session, cookie: await storage.save(session) }
+}
+
+// Flash data is undefined on the first request
+let response1 = await requestIndex(null)
+assert.equal(response1.session.get('message'), undefined)
+
+// Flash data is undefined on the same request it is set. This response
+// is typically a redirect to a route that displays the flash data.
+let response2 = await requestSubmit(response1.cookie)
+assert.equal(response2.session.get('message'), undefined)
+
+// Flash data is available on the next request
+let response3 = await requestIndex(response2.cookie)
+assert.equal(response3.session.get('message'), 'success!')
+
+// Flash data is not available on subsequent requests
+let response4 = await requestIndex(response3.cookie)
+assert.equal(response4.session.get('message'), undefined)
+```
+
+### Regenerating Session IDs
+
+For security, regenerate the session ID after privilege changes like a login.
+This helps prevent session fixation attacks by issuing a new session ID in the
+response.
+
+```ts
+import { createFsSessionStorage } from 'remix/session/fs-storage'
+
+let sessionStorage = createFsSessionStorage('/tmp/sessions')
+
+async function requestIndex(cookie: string | null) {
+ let session = await sessionStorage.read(cookie)
+ return { session, cookie: await sessionStorage.save(session) }
+}
+
+async function requestLogin(cookie: string | null) {
+ let session = await sessionStorage.read(cookie)
+ session.set('userId', 'mj')
+ session.regenerateId()
+ return { session, cookie: await sessionStorage.save(session) }
+}
+
+let response1 = await requestIndex(null)
+assert.equal(response1.session.get('userId'), undefined)
+
+let response2 = await requestLogin(response1.cookie)
+assert.notEqual(response2.session.id, response1.session.id)
+
+let response3 = await requestIndex(response2.cookie)
+assert.equal(response3.session.get('userId'), 'mj')
+```
+
+To delete the old session data when the session is saved, use
+`session.regenerateId(true)`. This can help to prevent session fixation attacks
+by deleting the old session data when the session is saved. However, it may not
+be desirable in a situation with mobile clients on flaky connections that may
+need to resume the session using an old session ID.
+
+### Destroying Sessions
+
+When a user logs out, you should destroy the session using `session.destroy()`.
+
+This will clear all session data from storage the next time it is saved. It also
+clears the session ID on the client in the next response, so it will start with
+a new session on the next request.
+
+### Storage Strategies
+
+Several strategies are provided out of the box for storing session data across
+requests, depending on your needs.
+
+A session storage object must always be initialized with a _signed_ session
+cookie. This is used to identify the session and to store the session data in
+the response.
+
+#### Filesystem Storage
+
+Filesystem storage is a good choice for production environments. It requires
+access to a persistent filesystem, which is readily available on most servers.
+And it can scale to handle sessions with a lot of data easily.
+
+```ts
+import { createFsSessionStorage } from 'remix/session/fs-storage'
+
+let sessionStorage = createFsSessionStorage('/tmp/sessions')
+```
+
+#### Cookie Storage
+
+Cookie storage is suitable for production environments. In this strategy, all
+session data is stored directly in the session cookie itself, which means it
+doesn't require any additional storage.
+
+The main limitation of cookie storage is that the total size of the session
+cookie is limited to the browser's maximum cookie size, typically 4096 bytes.
+
+```ts
+import { createCookieSessionStorage } from 'remix/session/cookie-storage'
+
+let sessionStorage = createCookieSessionStorage()
+```
+
+#### Memory Storage
+
+Memory storage is useful in testing and development environments. In this
+strategy, all session data is stored in memory, which means no additional
+storage is required. However, all session data is lost when the server restarts.
+
+```ts
+import { createMemorySessionStorage } from 'remix/session/memory-storage'
+
+let sessionStorage = createMemorySessionStorage()
+```
+
+## Related Packages
+
+- [`@remix-run/cookie`](https://github.com/remix-run/remix/tree/main/packages/cookie) -
+ Cookie parsing and serialization
+- [`@remix-run/fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) -
+ Router with built-in session middleware
+- [`@remix-run/session-storage-memcache`](https://github.com/remix-run/remix/tree/main/packages/session-storage-memcache) -
+ Memcache-backed session storage
+
+## License
+
+See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
diff --git a/docs/agents/remix/session/flash-and-security.md b/docs/agents/remix/session/flash-and-security.md
deleted file mode 100644
index 0f71be4..0000000
--- a/docs/agents/remix/session/flash-and-security.md
+++ /dev/null
@@ -1,91 +0,0 @@
-# Flash data and security
-
-Source: https://github.com/remix-run/remix/tree/main/packages/session
-
-## Flash messages
-
-Flash messages are values that persist only for the next request, perfect for
-displaying one-time notifications:
-
-```ts
-async function requestIndex(cookie: string | null) {
- let session = await storage.read(cookie)
- return { session, cookie: await storage.save(session) }
-}
-
-async function requestSubmit(cookie: string | null) {
- let session = await storage.read(cookie)
- session.flash('message', 'success!')
- return { session, cookie: await storage.save(session) }
-}
-
-// Flash data is undefined on the first request
-let response1 = await requestIndex(null)
-assert.equal(response1.session.get('message'), undefined)
-
-// Flash data is undefined on the same request it is set. This response
-// is typically a redirect to a route that displays the flash data.
-let response2 = await requestSubmit(response1.cookie)
-assert.equal(response2.session.get('message'), undefined)
-
-// Flash data is available on the next request
-let response3 = await requestIndex(response2.cookie)
-assert.equal(response3.session.get('message'), 'success!')
-
-// Flash data is not available on subsequent requests
-let response4 = await requestIndex(response3.cookie)
-assert.equal(response4.session.get('message'), undefined)
-```
-
-## Regenerating session IDs
-
-For security, regenerate the session ID after privilege changes like a login.
-This helps prevent session fixation attacks by issuing a new session ID in the
-response.
-
-```ts
-import { createFsSessionStorage } from '@remix-run/session/fs-storage'
-
-let sessionStorage = createFsSessionStorage('/tmp/sessions')
-
-async function requestIndex(cookie: string | null) {
- let session = await sessionStorage.read(cookie)
- return { session, cookie: await sessionStorage.save(session) }
-}
-
-async function requestLogin(cookie: string | null) {
- let session = await sessionStorage.read(cookie)
- session.set('userId', 'mj')
- session.regenerateId()
- return { session, cookie: await sessionStorage.save(session) }
-}
-
-let response1 = await requestIndex(null)
-assert.equal(response1.session.get('userId'), undefined)
-
-let response2 = await requestLogin(response1.cookie)
-assert.notEqual(response2.session.id, response1.session.id)
-
-let response3 = await requestIndex(response2.cookie)
-assert.equal(response3.session.get('userId'), 'mj')
-```
-
-To delete the old session data when the session is saved, use
-`session.regenerateId(true)`. This can help to prevent session fixation attacks
-by deleting the old session data when the session is saved. However, it may not
-be desirable in a situation with mobile clients on flaky connections that may
-need to resume the session using an old session ID.
-
-## Destroying sessions
-
-When a user logs out, you should destroy the session using `session.destroy()`.
-
-This will clear all session data from storage the next time it is saved. It also
-clears the session ID on the client in the next response, so it will start with
-a new session on the next request.
-
-## Navigation
-
-- [Session overview](./index.md)
-- [Storage strategies](./storage-strategies.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/session/index.md b/docs/agents/remix/session/index.md
deleted file mode 100644
index 6e758ad..0000000
--- a/docs/agents/remix/session/index.md
+++ /dev/null
@@ -1,67 +0,0 @@
-# session
-
-Source: https://github.com/remix-run/remix/tree/main/packages/session
-
-## Overview
-
-A full-featured session management library for JavaScript. This package provides
-a flexible and secure way to manage user sessions in server-side applications
-with a flexible API for different session storage strategies.
-
-## Features
-
-- **Multiple Storage Strategies:** Includes memory, cookie, and file-based
- storage strategies for different use cases
-- **Flash Messages:** Support for flash data that persists only for the next
- request
-- **Session Security:** Built-in protection against session fixation attacks
-
-## Installation
-
-```sh
-bun add @remix-run/session
-```
-
-## Usage
-
-The standard pattern is to read the session from the request, modify it, and
-save it back to storage and write the session cookie to the response.
-
-```ts
-import { createCookieSessionStorage } from '@remix-run/session/cookie-storage'
-
-// Create a session storage. This is used to store session data across requests.
-let storage = createCookieSessionStorage()
-
-// This function simulates a typical request flow where the session is read from
-// the request cookie, modified, and the new cookie is returned in the response.
-async function handleRequest(cookie: string | null) {
- let session = await storage.read(cookie)
- session.set('count', Number(session.get('count') ?? 0) + 1)
- return {
- session, // The session data from this "request"
- cookie: await storage.save(session), // The cookie to use on the next request
- }
-}
-
-let response1 = await handleRequest(null)
-assert.equal(response1.session.get('count'), 1)
-
-let response2 = await handleRequest(response1.cookie)
-assert.equal(response2.session.get('count'), 2)
-
-let response3 = await handleRequest(response2.cookie)
-assert.equal(response3.session.get('count'), 3)
-```
-
-The example above is a low-level illustration of how to use this package for
-session management. In practice, you would use the `session` middleware in
-[`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router)
-to automatically manage the session for you.
-
-## Navigation
-
-- [Flash data and security](./flash-and-security.md)
-- [Storage strategies](./storage-strategies.md)
-- [Related packages](./related.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/session/related.md b/docs/agents/remix/session/related.md
deleted file mode 100644
index 932ef56..0000000
--- a/docs/agents/remix/session/related.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# Related packages
-
-Source: https://github.com/remix-run/remix/tree/main/packages/session
-
-## Related packages
-
-- [`cookie`](https://github.com/remix-run/remix/tree/main/packages/cookie) -
- Cookie parsing and serialization
-- [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) -
- Router with built-in session middleware
-- [`session-storage-memcache`](https://github.com/remix-run/remix/tree/main/packages/session-storage-memcache) -
- Memcache-backed session storage adapter
-- [`session-storage-redis`](https://github.com/remix-run/remix/tree/main/packages/session-storage-redis) -
- Redis-backed session storage adapter
-
-## License
-
-See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Session overview](./index.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/session/storage-strategies.md b/docs/agents/remix/session/storage-strategies.md
deleted file mode 100644
index 2347c11..0000000
--- a/docs/agents/remix/session/storage-strategies.md
+++ /dev/null
@@ -1,55 +0,0 @@
-# Storage strategies
-
-Source: https://github.com/remix-run/remix/tree/main/packages/session
-
-Several strategies are provided out of the box for storing session data across
-requests, depending on your needs.
-
-A session storage object must always be initialized with a signed session
-cookie. This is used to identify the session and to store the session data in
-the response.
-
-## Filesystem storage
-
-Filesystem storage is a good choice for production environments. It requires
-access to a persistent filesystem, which is readily available on most servers.
-And it can scale to handle sessions with a lot of data easily.
-
-```ts
-import { createFsSessionStorage } from '@remix-run/session/fs-storage'
-
-let sessionStorage = createFsSessionStorage('/tmp/sessions')
-```
-
-## Cookie storage
-
-Cookie storage is suitable for production environments. In this strategy, all
-session data is stored directly in the session cookie itself, which means it
-doesn't require any additional storage.
-
-The main limitation of cookie storage is that the total size of the session
-cookie is limited to the browser's maximum cookie size, typically 4096 bytes.
-
-```ts
-import { createCookieSessionStorage } from '@remix-run/session/cookie-storage'
-
-let sessionStorage = createCookieSessionStorage()
-```
-
-## Memory storage
-
-Memory storage is useful in testing and development environments. In this
-strategy, all session data is stored in memory, which means no additional
-storage is required. However, all session data is lost when the server restarts.
-
-```ts
-import { createMemorySessionStorage } from '@remix-run/session/memory-storage'
-
-let sessionStorage = createMemorySessionStorage()
-```
-
-## Navigation
-
-- [Session overview](./index.md)
-- [Related packages](./related.md)
-- [Remix package index](../index.md)
diff --git a/docs/agents/remix/static-middleware.md b/docs/agents/remix/static-middleware/README.md
similarity index 64%
rename from docs/agents/remix/static-middleware.md
rename to docs/agents/remix/static-middleware/README.md
index c5f9ac6..4e90199 100644
--- a/docs/agents/remix/static-middleware.md
+++ b/docs/agents/remix/static-middleware/README.md
@@ -1,30 +1,23 @@
# static-middleware
-Source: https://github.com/remix-run/remix/tree/main/packages/static-middleware
-
-## README
-
-Middleware for serving static files from the filesystem for use with
-[`@remix-run/fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router).
-
-Serves static files from a directory with support for ETags, range requests, and
-conditional requests.
+Static file serving middleware for Remix. Serves static files from a directory
+with support for ETags, range requests, and conditional requests.
## Features
-- [ETag support](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag)
+- [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) support
(weak and strong)
-- [Range request support](https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests)
- (HTTP 206 Partial Content)
-- [Conditional request support](https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests)
- (If-None-Match, If-Modified-Since)
+- [Range requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests)
+ support (HTTP 206 Partial Content)
+- [Conditional request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests)
+ support (If-None-Match, If-Modified-Since)
- Path traversal protection
-- Automatic fall through to next middleware/handler if file not found
+- Automatic fallback to next middleware/handler if file not found
## Installation
```sh
-bun add @remix-run/static-middleware
+npm i remix
```
## Usage
@@ -32,8 +25,8 @@ bun add @remix-run/static-middleware
Static middleware is useful for serving static files from a directory.
```ts
-import { createRouter } from '@remix-run/fetch-router'
-import { staticFiles } from '@remix-run/static-middleware'
+import { createRouter } from 'remix/fetch-router'
+import { staticFiles } from 'remix/static-middleware'
let router = createRouter({
middleware: [staticFiles('./public')],
@@ -103,7 +96,3 @@ let router = createRouter({
## License
See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Remix package index](./index.md)
diff --git a/docs/agents/remix/tar-parser.md b/docs/agents/remix/tar-parser/README.md
similarity index 62%
rename from docs/agents/remix/tar-parser.md
rename to docs/agents/remix/tar-parser/README.md
index 9f0b68d..baf99a8 100644
--- a/docs/agents/remix/tar-parser.md
+++ b/docs/agents/remix/tar-parser/README.md
@@ -1,21 +1,8 @@
# tar-parser
-Source: https://github.com/remix-run/remix/tree/main/packages/tar-parser
-
-## README
-
-`tar-parser` is a fast, efficient parser for
-[tar archives]().
-
-Tar archives are ubiquitous in software development, used for distributing
-packages, backing up files, and transferring data. Most existing JavaScript tar
-parsers are either Node.js-specific or don't handle streaming efficiently,
-forcing you to buffer entire archives in memory. This makes them unsuitable for
-serverless environments or processing large archives.
-
-`tar-parser` can be used in any JavaScript environment (not just Node.js) and
-processes archives as streams, making it ideal for modern web development across
-all runtimes.
+Streaming [tar archive]() parsing
+for JavaScript. `tar-parser` handles POSIX/GNU/PAX archives incrementally so
+large tar files can be processed without buffering the full payload.
## Features
@@ -29,10 +16,8 @@ all runtimes.
## Installation
-Install from [npm](https://www.npmjs.com/):
-
```sh
-bun add @remix-run/tar-parser
+npm i remix
```
## Usage
@@ -40,7 +25,7 @@ bun add @remix-run/tar-parser
The main parser interface is the `parseTar(archive, handler)` function:
```ts
-import { parseTar } from '@remix-run/tar-parser'
+import { parseTar } from 'remix/tar-parser'
let response = await fetch(
'https://github.com/remix-run/remix/archive/refs/heads/main.tar.gz',
@@ -72,16 +57,19 @@ Node.js.
```
> @remix-run/tar-parser@0.0.0 bench /Users/michael/Projects/remix-the-web/packages/tar-parser
-> node --disable-warning=ExperimentalWarning ./bench/runner.ts
+> node ./bench/runner.ts
Platform: Darwin (24.0.0)
CPU: Apple M1 Pro
Date: 12/6/2024, 11:00:55 AM
Node.js v22.8.0
-(index) | lodash npm package
-tar-parser | '6.23 ms +/- 0.58'
-tar-stream | '6.72 ms +/- 2.24'
-node-tar | '6.49 ms +/- 0.44'
+┌────────────┬────────────────────┐
+│ (index) │ lodash npm package │
+├────────────┼────────────────────┤
+│ tar-parser │ '6.23 ms ± 0.58' │
+│ tar-stream │ '6.72 ms ± 2.24' │
+│ node-tar │ '6.49 ms ± 0.44' │
+└────────────┴────────────────────┘
```
## Related Packages
@@ -98,7 +86,3 @@ adopts the same core parsing algorithm, utility functions, and many test cases.
## License
See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
-
-## Navigation
-
-- [Remix package index](./index.md)
From 055b77615d2cc82d8b035be265801e8ad4b4c2fe Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 26 Mar 2026 22:35:11 +0000
Subject: [PATCH 4/7] Fix delete thread double-check handlers
Co-authored-by: Kent C. Dodds
---
client/double-check.ts | 17 +-
client/routes/chat.tsx | 3011 ++++++++++++++++++++++------------------
2 files changed, 1678 insertions(+), 1350 deletions(-)
diff --git a/client/double-check.ts b/client/double-check.ts
index 55eb990..5c98119 100644
--- a/client/double-check.ts
+++ b/client/double-check.ts
@@ -1,8 +1,11 @@
import { on, type Handle } from 'remix/component'
-
type ButtonLikeProps = {
mix?: Array
+ onConfirm?: (event: MouseEvent) => void
+ on?: {
+ click?: (event: MouseEvent) => void
+ }
[key: string]: unknown
}
@@ -24,7 +27,14 @@ export function createDoubleCheck(handle: Handle) {
},
getButtonProps(props?: Props): Props {
const buttonProps = props ?? ({} as Props)
- const mix = [...(buttonProps.mix ?? [])]
+ const {
+ mix: inputMix,
+ onConfirm,
+ on,
+ ...rest
+ } = buttonProps as ButtonLikeProps
+ const mix = [...(inputMix ?? [])]
+ const confirmHandler = onConfirm ?? on?.click
mix.push(
on('blur', () => {
@@ -40,11 +50,12 @@ export function createDoubleCheck(handle: Handle) {
return
}
setDoubleCheck(false)
+ confirmHandler?.(event)
}),
)
return {
- ...buttonProps,
+ ...rest,
mix,
}
},
diff --git a/client/routes/chat.tsx b/client/routes/chat.tsx
index 7c44e44..96f9676 100644
--- a/client/routes/chat.tsx
+++ b/client/routes/chat.tsx
@@ -1,1442 +1,1759 @@
-import { addEventListeners, css, type Handle, on } from 'remix/component';
-import { ChatClient, type ChatClientSnapshot } from '#client/chat-client.ts';
-import { navigate, routerEvents } from '#client/client-router.tsx';
-import { createDoubleCheck } from '#client/double-check.ts';
-import { EditableText } from '#client/editable-text.tsx';
-import { createInfiniteList, type InfiniteListSnapshot, } from '#client/infinite-list.ts';
-import { captureScrollAnchor, getScrollFades, isScrolledNearEdge, restoreScrollAnchorAfterPrepend, scrollToEdge, } from '#client/scroll-container.ts';
-import { createSpinDelay } from '#client/spin-delay.ts';
-import { breakpoints, colors, mq, radius, shadows, spacing, transitions, typography, } from '#client/styles/tokens.ts';
-import { type ChatThreadLookupResponse, type ChatThreadListResponse, type ChatThreadSummary, type ChatThreadUpdateResponse, } from '#shared/chat.ts';
-type ThreadStatus = 'idle' | 'loading' | 'ready' | 'error';
+import { addEventListeners, css, type Handle, on } from 'remix/component'
+import { ChatClient, type ChatClientSnapshot } from '#client/chat-client.ts'
+import { navigate, routerEvents } from '#client/client-router.tsx'
+import { createDoubleCheck } from '#client/double-check.ts'
+import { EditableText } from '#client/editable-text.tsx'
+import {
+ createInfiniteList,
+ type InfiniteListSnapshot,
+} from '#client/infinite-list.ts'
+import {
+ captureScrollAnchor,
+ getScrollFades,
+ isScrolledNearEdge,
+ restoreScrollAnchorAfterPrepend,
+ scrollToEdge,
+} from '#client/scroll-container.ts'
+import { createSpinDelay } from '#client/spin-delay.ts'
+import {
+ breakpoints,
+ colors,
+ mq,
+ radius,
+ shadows,
+ spacing,
+ transitions,
+ typography,
+} from '#client/styles/tokens.ts'
+import {
+ type ChatThreadLookupResponse,
+ type ChatThreadListResponse,
+ type ChatThreadSummary,
+ type ChatThreadUpdateResponse,
+} from '#shared/chat.ts'
+type ThreadStatus = 'idle' | 'loading' | 'ready' | 'error'
function getSelectedThreadIdFromLocation() {
- if (typeof window === 'undefined')
- return null;
- const prefix = '/chat/';
- if (!window.location.pathname.startsWith(prefix))
- return null;
- const threadId = window.location.pathname.slice(prefix.length).trim();
- return threadId || null;
+ if (typeof window === 'undefined') return null
+ const prefix = '/chat/'
+ if (!window.location.pathname.startsWith(prefix)) return null
+ const threadId = window.location.pathname.slice(prefix.length).trim()
+ return threadId || null
}
function buildThreadHref(threadId: string) {
- return `/chat/${threadId}`;
+ return `/chat/${threadId}`
}
function isMobileViewport() {
- return (typeof window !== 'undefined' &&
- window.matchMedia(`(max-width: ${breakpoints.tablet})`).matches);
+ return (
+ typeof window !== 'undefined' &&
+ window.matchMedia(`(max-width: ${breakpoints.tablet})`).matches
+ )
}
-const MESSAGES_SCROLL_CONTAINER_ID = 'chat-messages-scroll-container';
-const THREAD_LIST_SCROLL_CONTAINER_ID = 'chat-thread-list-scroll-container';
-const MESSAGES_SCROLL_THRESHOLD_PX = 96;
-const THREAD_LIST_SCROLL_THRESHOLD_PX = 96;
-const MESSAGE_SCROLL_FADE_HEIGHT = '2.5rem';
-const THREADS_PAGE_LIMIT = 40;
+const MESSAGES_SCROLL_CONTAINER_ID = 'chat-messages-scroll-container'
+const THREAD_LIST_SCROLL_CONTAINER_ID = 'chat-thread-list-scroll-container'
+const MESSAGES_SCROLL_THRESHOLD_PX = 96
+const THREAD_LIST_SCROLL_THRESHOLD_PX = 96
+const MESSAGE_SCROLL_FADE_HEIGHT = '2.5rem'
+const THREADS_PAGE_LIMIT = 40
function truncatePreview(text: string) {
- const normalized = text.trim();
- if (!normalized)
- return '';
- return normalized.length > 120 ? `${normalized.slice(0, 117)}...` : normalized;
+ const normalized = text.trim()
+ if (!normalized) return ''
+ return normalized.length > 120 ? `${normalized.slice(0, 117)}...` : normalized
}
function createInitialSnapshot(): ChatClientSnapshot {
- return {
- messages: [],
- totalMessageCount: 0,
- streamingText: '',
- isStreaming: false,
- hasOlderMessages: false,
- isLoadingMessages: false,
- isLoadingOlderMessages: false,
- error: null,
- connected: false,
- };
+ return {
+ messages: [],
+ totalMessageCount: 0,
+ streamingText: '',
+ isStreaming: false,
+ hasOlderMessages: false,
+ isLoadingMessages: false,
+ isLoadingOlderMessages: false,
+ error: null,
+ connected: false,
+ }
}
-function buildThreadPreviewFromMessages(messages: ChatClientSnapshot['messages']) {
- const lastMessage = messages.at(-1);
- if (!lastMessage)
- return null;
- const text = lastMessage.parts
- .filter((part): part is Extract<(typeof lastMessage.parts)[number], {
- type: 'text';
- text: string;
- }> => part.type === 'text' && typeof part.text === 'string')
- .map((part) => part.text)
- .join('\n')
- .trim();
- return text ? truncatePreview(text) : null;
+function buildThreadPreviewFromMessages(
+ messages: ChatClientSnapshot['messages'],
+) {
+ const lastMessage = messages.at(-1)
+ if (!lastMessage) return null
+ const text = lastMessage.parts
+ .filter(
+ (
+ part,
+ ): part is Extract<
+ (typeof lastMessage.parts)[number],
+ {
+ type: 'text'
+ text: string
+ }
+ > => part.type === 'text' && typeof part.text === 'string',
+ )
+ .map((part) => part.text)
+ .join('\n')
+ .trim()
+ return text ? truncatePreview(text) : null
}
async function fetchThreads(input?: {
- cursor?: string | null;
- signal?: AbortSignal;
- search?: string;
+ cursor?: string | null
+ signal?: AbortSignal
+ search?: string
}) {
- const url = new URL('/chat-threads', window.location.href);
- if (input?.cursor) {
- url.searchParams.set('cursor', input.cursor);
- }
- url.searchParams.set('limit', String(THREADS_PAGE_LIMIT));
- const search = input?.search?.trim();
- if (search) {
- url.searchParams.set('q', search);
- }
- const response = await fetch(url.toString(), {
- credentials: 'include',
- headers: { Accept: 'application/json' },
- signal: input?.signal,
- });
- const payload = (await response.json().catch(() => null)) as (ChatThreadListResponse & {
- error?: string;
- }) | {
- ok?: false;
- error?: string;
- } | null;
- if (!response.ok ||
- !payload?.ok ||
- !('threads' in payload) ||
- !Array.isArray(payload.threads) ||
- typeof payload.totalCount !== 'number' ||
- typeof payload.hasMore !== 'boolean') {
- throw new Error(payload?.error || 'Unable to load threads.');
- }
- return {
- items: payload.threads,
- hasMore: payload.hasMore,
- nextCursor: payload.nextCursor,
- totalCount: payload.totalCount,
- };
+ const url = new URL('/chat-threads', window.location.href)
+ if (input?.cursor) {
+ url.searchParams.set('cursor', input.cursor)
+ }
+ url.searchParams.set('limit', String(THREADS_PAGE_LIMIT))
+ const search = input?.search?.trim()
+ if (search) {
+ url.searchParams.set('q', search)
+ }
+ const response = await fetch(url.toString(), {
+ credentials: 'include',
+ headers: { Accept: 'application/json' },
+ signal: input?.signal,
+ })
+ const payload = (await response.json().catch(() => null)) as
+ | (ChatThreadListResponse & {
+ error?: string
+ })
+ | {
+ ok?: false
+ error?: string
+ }
+ | null
+ if (
+ !response.ok ||
+ !payload?.ok ||
+ !('threads' in payload) ||
+ !Array.isArray(payload.threads) ||
+ typeof payload.totalCount !== 'number' ||
+ typeof payload.hasMore !== 'boolean'
+ ) {
+ throw new Error(payload?.error || 'Unable to load threads.')
+ }
+ return {
+ items: payload.threads,
+ hasMore: payload.hasMore,
+ nextCursor: payload.nextCursor,
+ totalCount: payload.totalCount,
+ }
}
async function fetchThreadById(threadId: string, signal?: AbortSignal) {
- const url = new URL('/chat-threads', window.location.href);
- url.searchParams.set('threadId', threadId);
- const response = await fetch(url.toString(), {
- credentials: 'include',
- headers: { Accept: 'application/json' },
- signal,
- });
- const payload = (await response.json().catch(() => null)) as (ChatThreadLookupResponse & {
- error?: string;
- }) | {
- ok?: false;
- error?: string;
- } | null;
- if (!response.ok ||
- !payload?.ok ||
- !('thread' in payload) ||
- !payload.thread) {
- throw new Error(payload?.error || 'Unable to load the selected thread.');
- }
- return payload.thread;
+ const url = new URL('/chat-threads', window.location.href)
+ url.searchParams.set('threadId', threadId)
+ const response = await fetch(url.toString(), {
+ credentials: 'include',
+ headers: { Accept: 'application/json' },
+ signal,
+ })
+ const payload = (await response.json().catch(() => null)) as
+ | (ChatThreadLookupResponse & {
+ error?: string
+ })
+ | {
+ ok?: false
+ error?: string
+ }
+ | null
+ if (
+ !response.ok ||
+ !payload?.ok ||
+ !('thread' in payload) ||
+ !payload.thread
+ ) {
+ throw new Error(payload?.error || 'Unable to load the selected thread.')
+ }
+ return payload.thread
}
async function createThread() {
- const response = await fetch('/chat-threads', {
- method: 'POST',
- credentials: 'include',
- });
- const payload = (await response.json().catch(() => null)) as {
- ok?: boolean;
- thread?: ChatThreadSummary;
- error?: string;
- } | null;
- if (!response.ok || !payload?.ok || !payload.thread) {
- throw new Error(payload?.error || 'Unable to create thread.');
- }
- return payload.thread;
+ const response = await fetch('/chat-threads', {
+ method: 'POST',
+ credentials: 'include',
+ })
+ const payload = (await response.json().catch(() => null)) as {
+ ok?: boolean
+ thread?: ChatThreadSummary
+ error?: string
+ } | null
+ if (!response.ok || !payload?.ok || !payload.thread) {
+ throw new Error(payload?.error || 'Unable to create thread.')
+ }
+ return payload.thread
}
async function deleteThread(threadId: string) {
- const response = await fetch('/chat-threads/delete', {
- method: 'POST',
- credentials: 'include',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ threadId }),
- });
- const payload = (await response.json().catch(() => null)) as {
- ok?: boolean;
- error?: string;
- } | null;
- if (!response.ok || !payload?.ok) {
- throw new Error(payload?.error || 'Unable to delete thread.');
- }
+ const response = await fetch('/chat-threads/delete', {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ threadId }),
+ })
+ const payload = (await response.json().catch(() => null)) as {
+ ok?: boolean
+ error?: string
+ } | null
+ if (!response.ok || !payload?.ok) {
+ throw new Error(payload?.error || 'Unable to delete thread.')
+ }
}
async function updateThreadTitle(threadId: string, title: string) {
- const response = await fetch('/chat-threads/update', {
- method: 'POST',
- credentials: 'include',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ threadId, title }),
- });
- const payload = (await response.json().catch(() => null)) as (ChatThreadUpdateResponse & {
- error?: string;
- }) | {
- ok?: false;
- error?: string;
- } | null;
- if (!response.ok ||
- !payload?.ok ||
- !('thread' in payload) ||
- !payload.thread) {
- throw new Error(payload?.error || 'Unable to update thread title.');
- }
- return payload.thread;
+ const response = await fetch('/chat-threads/update', {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ threadId, title }),
+ })
+ const payload = (await response.json().catch(() => null)) as
+ | (ChatThreadUpdateResponse & {
+ error?: string
+ })
+ | {
+ ok?: false
+ error?: string
+ }
+ | null
+ if (
+ !response.ok ||
+ !payload?.ok ||
+ !('thread' in payload) ||
+ !payload.thread
+ ) {
+ throw new Error(payload?.error || 'Unable to update thread title.')
+ }
+ return payload.thread
}
-function renderMessageParts(parts: Array<{
- type: string;
- text?: string;
- state?: string;
- input?: unknown;
- output?: unknown;
- errorText?: string;
-}>) {
- return parts.map((part, index) => {
- if (part.type === 'text') {
- return (
+function renderMessageParts(
+ parts: Array<{
+ type: string
+ text?: string
+ state?: string
+ input?: unknown
+ output?: unknown
+ errorText?: string
+ }>,
+) {
+ return parts.map((part, index) => {
+ if (part.type === 'text') {
+ return (
+
{part.text}
-
);
- }
- if (part.type.startsWith('tool-')) {
- return (
+
+ )
+ }
+ if (part.type.startsWith('tool-')) {
+ return (
+
{part.type.replace(/^tool-/, '')}
- State: {part.state}
- {part.input !== undefined ? (
+
+ State: {part.state}
+
+ {part.input !== undefined ? (
+
Input: {JSON.stringify(part.input)}
- ) : null}
- {part.output !== undefined ? (
+
+ ) : null}
+ {part.output !== undefined ? (
+
Output: {JSON.stringify(part.output)}
- ) : null}
- {part.errorText ? ({part.errorText}) : null}
-
);
- }
- return null;
- });
+
+ ) : null}
+ {part.errorText ? (
+
{part.errorText}
+ ) : null}
+
+ )
+ }
+ return null
+ })
}
function renderPaperAirplaneIcon() {
- return ();
+ return (
+
+ )
}
function renderBackIcon() {
- return ();
+ return (
+
+ )
}
function renderTrashIcon() {
- return ();
+ return (
+
+ )
}
-const SEND_BUTTON_SIZE_REM = 2.5;
-const SEND_BUTTON_INSET_REM = 0.375;
-const INPUT_MIN_HEIGHT_REM = SEND_BUTTON_SIZE_REM + SEND_BUTTON_INSET_REM * 2;
-const INPUT_MIN_HEIGHT_PX = INPUT_MIN_HEIGHT_REM * 16;
-const INPUT_MIN_HEIGHT = `${INPUT_MIN_HEIGHT_REM}rem`;
-const INPUT_RIGHT_PADDING = `${SEND_BUTTON_SIZE_REM + SEND_BUTTON_INSET_REM * 2}rem`;
-const SEND_BUTTON_SIZE = `${SEND_BUTTON_SIZE_REM}rem`;
-const SEND_BUTTON_INSET = `${SEND_BUTTON_INSET_REM}rem`;
-const CHAT_PANEL_HEIGHT = 'calc(100vh - 7rem)';
+const SEND_BUTTON_SIZE_REM = 2.5
+const SEND_BUTTON_INSET_REM = 0.375
+const INPUT_MIN_HEIGHT_REM = SEND_BUTTON_SIZE_REM + SEND_BUTTON_INSET_REM * 2
+const INPUT_MIN_HEIGHT_PX = INPUT_MIN_HEIGHT_REM * 16
+const INPUT_MIN_HEIGHT = `${INPUT_MIN_HEIGHT_REM}rem`
+const INPUT_RIGHT_PADDING = `${SEND_BUTTON_SIZE_REM + SEND_BUTTON_INSET_REM * 2}rem`
+const SEND_BUTTON_SIZE = `${SEND_BUTTON_SIZE_REM}rem`
+const SEND_BUTTON_INSET = `${SEND_BUTTON_INSET_REM}rem`
+const CHAT_PANEL_HEIGHT = 'calc(100vh - 7rem)'
/**
* The outer border should follow the button's contour plus its inset from the edge.
* radius = button radius + inset
*/
-const SEND_BUTTON_RADIUS = `${SEND_BUTTON_SIZE_REM / 2 + SEND_BUTTON_INSET_REM}rem`;
+const SEND_BUTTON_RADIUS = `${SEND_BUTTON_SIZE_REM / 2 + SEND_BUTTON_INSET_REM}rem`
function resizeMessageInput(target: EventTarget | null) {
- if (!(target instanceof HTMLTextAreaElement))
- return;
- target.style.height = INPUT_MIN_HEIGHT;
- const height = Math.max(target.scrollHeight, INPUT_MIN_HEIGHT_PX);
- target.style.height = `${height}px`;
+ if (!(target instanceof HTMLTextAreaElement)) return
+ target.style.height = INPUT_MIN_HEIGHT
+ const height = Math.max(target.scrollHeight, INPUT_MIN_HEIGHT_PX)
+ target.style.height = `${height}px`
}
export function ChatRoute(handle: Handle) {
- let threadListSnapshot: InfiniteListSnapshot = {
- items: [],
- hasMore: false,
- totalCount: 0,
- error: null,
- isLoadingInitial: false,
- isLoadingMore: false,
- };
- let threadStatus: ThreadStatus = 'loading';
- let threadError: string | null = null;
- let threadListCursor: string | null = null;
- let activeThreadId: string | null = null;
- let threadSearch = '';
- let chatSnapshot = createInitialSnapshot();
- let activeClient: ChatClient | null = null;
- let actionError: string | null = null;
- let syncInFlight = false;
- let shouldAutoScrollMessages = true;
- let showMessageScrollFadeTop = false;
- let showMessageScrollFadeBottom = false;
- let showThreadListScrollFadeTop = false;
- let showThreadListScrollFadeBottom = false;
- const disconnectedIndicator = createSpinDelay(handle, { ssr: false });
- const deleteThreadChecks = new Map>();
- const threadList = createInfiniteList({
- mergeDirection: 'append',
- getKey: (thread) => thread.id,
- onSnapshot(snapshot) {
- threadListSnapshot = snapshot;
- if (deleteThreadChecks.size) {
- const activeThreadIds = new Set(snapshot.items.map((thread) => thread.id));
- for (const threadId of deleteThreadChecks.keys()) {
- if (!activeThreadIds.has(threadId)) {
- deleteThreadChecks.delete(threadId);
- }
- }
- }
- threadError = snapshot.error;
- if (snapshot.isLoadingInitial) {
- threadStatus = 'loading';
- }
- else if (snapshot.error) {
- threadStatus = 'error';
- }
- else {
- threadStatus = 'ready';
- }
- update();
- },
- });
- function update() {
- handle.update();
- }
- function setThreadState(nextStatus: ThreadStatus, nextError: string | null = null) {
- threadStatus = nextStatus;
- threadError = nextError;
- update();
- }
- function resetChatSnapshot() {
- chatSnapshot = createInitialSnapshot();
- }
- function getThreads() {
- return threadListSnapshot.items;
- }
- function updateThreadListFromSnapshot(updater: (threads: Array) => Array) {
- threadList.updateItems(updater);
- }
- function updateLocalThreadSummary(threadId: string, snapshot: ChatClientSnapshot) {
- updateThreadListFromSnapshot((threads) => {
- const threadIndex = threads.findIndex((thread) => thread.id === threadId);
- if (threadIndex === -1)
- return threads;
- const existingThread = threads[threadIndex];
- if (!existingThread)
- return threads;
- const nextThread: ChatThreadSummary = {
- ...existingThread,
- messageCount: snapshot.totalMessageCount,
- lastMessagePreview: buildThreadPreviewFromMessages(snapshot.messages),
- };
- if (threadSearch.trim()) {
- return threads.map((thread) => thread.id === threadId ? nextThread : thread);
- }
- const remainingThreads = threads.filter((thread) => thread.id !== threadId);
- return [nextThread, ...remainingThreads];
- });
- }
- function syncDisconnectedIndicator() {
- disconnectedIndicator.setLoading(Boolean(activeThreadId) && !chatSnapshot.connected);
- }
- function setMessageScrollFades(nextTopVisible: boolean, nextBottomVisible: boolean) {
- if (showMessageScrollFadeTop === nextTopVisible &&
- showMessageScrollFadeBottom === nextBottomVisible) {
- return;
- }
- showMessageScrollFadeTop = nextTopVisible;
- showMessageScrollFadeBottom = nextBottomVisible;
- update();
- }
- function syncMessageScrollFades(target?: HTMLDivElement | null) {
- const container = target ??
- (() => {
- const element = document.getElementById(MESSAGES_SCROLL_CONTAINER_ID);
- return element instanceof HTMLDivElement ? element : null;
- })();
- const fades = getScrollFades(container);
- setMessageScrollFades(fades.top, fades.bottom);
- }
- function scheduleMessageScrollFadeSync() {
- void handle.queueTask(async () => {
- syncMessageScrollFades();
- });
- }
- function setThreadListScrollFades(nextTopVisible: boolean, nextBottomVisible: boolean) {
- if (showThreadListScrollFadeTop === nextTopVisible &&
- showThreadListScrollFadeBottom === nextBottomVisible) {
- return;
- }
- showThreadListScrollFadeTop = nextTopVisible;
- showThreadListScrollFadeBottom = nextBottomVisible;
- update();
- }
- function syncThreadListScrollFades(target?: HTMLDivElement | null) {
- const container = target ??
- (() => {
- const element = document.getElementById(THREAD_LIST_SCROLL_CONTAINER_ID);
- return element instanceof HTMLDivElement ? element : null;
- })();
- const fades = getScrollFades(container);
- setThreadListScrollFades(fades.top, fades.bottom);
- }
- function scheduleThreadListScrollFadeSync() {
- void handle.queueTask(async () => {
- syncThreadListScrollFades();
- });
- }
- function scheduleScrollToBottom(force = false) {
- void handle.queueTask(async () => {
- const container = document.getElementById(MESSAGES_SCROLL_CONTAINER_ID);
- if (!(container instanceof HTMLDivElement)) {
- setMessageScrollFades(false, false);
- return;
- }
- if (!force &&
- !shouldAutoScrollMessages &&
- !isScrolledNearEdge(container, {
- edge: 'bottom',
- thresholdPx: MESSAGES_SCROLL_THRESHOLD_PX,
- })) {
- syncMessageScrollFades(container);
- return;
- }
- scrollToEdge(container, 'bottom');
- shouldAutoScrollMessages = true;
- syncMessageScrollFades(container);
- });
- }
- function handleMessagesScroll(event: Event) {
- if (!(event.currentTarget instanceof HTMLDivElement))
- return;
- shouldAutoScrollMessages = isScrolledNearEdge(event.currentTarget, {
- edge: 'bottom',
- thresholdPx: MESSAGES_SCROLL_THRESHOLD_PX,
- });
- syncMessageScrollFades(event.currentTarget);
- if (chatSnapshot.hasOlderMessages &&
- !chatSnapshot.isLoadingOlderMessages &&
- isScrolledNearEdge(event.currentTarget, {
- edge: 'top',
- thresholdPx: MESSAGES_SCROLL_THRESHOLD_PX,
- })) {
- const scrollAnchor = captureScrollAnchor(event.currentTarget);
- void handle.queueTask(async (signal) => {
- const didLoad = await activeClient?.loadOlderMessages(signal);
- if (!didLoad)
- return;
- void handle.queueTask(async () => {
- const container = document.getElementById(MESSAGES_SCROLL_CONTAINER_ID);
- if (!(container instanceof HTMLDivElement))
- return;
- restoreScrollAnchorAfterPrepend(container, scrollAnchor);
- syncMessageScrollFades(container);
- });
- });
- }
- }
- function handleThreadListScroll(event: Event) {
- if (!(event.currentTarget instanceof HTMLDivElement))
- return;
- syncThreadListScrollFades(event.currentTarget);
- if (threadListSnapshot.hasMore &&
- !threadListSnapshot.isLoadingMore &&
- isScrolledNearEdge(event.currentTarget, {
- edge: 'bottom',
- thresholdPx: THREAD_LIST_SCROLL_THRESHOLD_PX,
- })) {
- void handle.queueTask(async (signal) => {
- await loadMoreThreads(signal);
- });
- }
- }
- function handleThreadSearchInput(event: Event) {
- if (!(event.currentTarget instanceof HTMLInputElement))
- return;
- threadSearch = event.currentTarget.value;
- update();
- void handle.queueTask(async (signal) => {
- await refreshThreads(signal);
- });
- }
- function handleComposerKeyDown(event: KeyboardEvent) {
- if (!(event.currentTarget instanceof HTMLTextAreaElement))
- return;
- if (event.key !== 'Enter' || !(event.metaKey || event.ctrlKey))
- return;
- event.preventDefault();
- event.currentTarget.form?.requestSubmit();
- }
- async function connectThread(threadId: string) {
- if (activeThreadId === threadId && activeClient)
- return;
- activeClient?.close();
- shouldAutoScrollMessages = true;
- activeClient = new ChatClient({
- threadId,
- onSnapshot(snapshot) {
- if (activeThreadId !== threadId)
- return;
- chatSnapshot = snapshot;
- updateLocalThreadSummary(threadId, snapshot);
- syncDisconnectedIndicator();
- update();
- scheduleMessageScrollFadeSync();
- scheduleThreadListScrollFadeSync();
- scheduleScrollToBottom();
- },
- });
- activeThreadId = threadId;
- resetChatSnapshot();
- syncDisconnectedIndicator();
- setMessageScrollFades(false, false);
- update();
- try {
- await activeClient.initialize();
- }
- catch (error) {
- chatSnapshot = {
- ...createInitialSnapshot(),
- error: error instanceof Error
- ? error.message
- : 'Unable to connect to the selected thread.',
- };
- syncDisconnectedIndicator();
- update();
- }
- }
- async function syncActiveThreadFromLocation() {
- if (threadStatus !== 'ready' || syncInFlight)
- return;
- syncInFlight = true;
- try {
- const locationThreadId = getSelectedThreadIdFromLocation();
- const threads = getThreads();
- if (threads.length === 0) {
- activeClient?.close();
- activeClient = null;
- activeThreadId = null;
- resetChatSnapshot();
- disconnectedIndicator.reset();
- setMessageScrollFades(false, false);
- update();
- if (locationThreadId) {
- navigate('/chat');
- }
- return;
- }
- if (locationThreadId &&
- !threads.some((thread) => thread.id === locationThreadId)) {
- try {
- const selectedThread = await fetchThreadById(locationThreadId);
- updateThreadListFromSnapshot((currentThreads) => [
- selectedThread,
- ...currentThreads,
- ]);
- }
- catch {
- // Ignore missing selections and fall back to the first loaded thread.
- }
- }
- const selectedThread = locationThreadId &&
- getThreads().find((thread) => thread.id === locationThreadId)
- ? locationThreadId
- : null;
- const resolvedThreadId = selectedThread ?? getThreads()[0]?.id ?? null;
- if (!resolvedThreadId)
- return;
- if (locationThreadId !== resolvedThreadId) {
- if (locationThreadId || !isMobileViewport()) {
- navigate(buildThreadHref(resolvedThreadId));
- }
- else {
- activeClient?.close();
- activeClient = null;
- activeThreadId = null;
- resetChatSnapshot();
- disconnectedIndicator.reset();
- setMessageScrollFades(false, false);
- update();
- }
- return;
- }
- await connectThread(resolvedThreadId);
- }
- finally {
- syncInFlight = false;
- }
- }
- async function loadMoreThreads(signal?: AbortSignal) {
- if (!threadListCursor)
- return false;
- let nextCursor: string | null = null;
- const didLoad = await threadList.loadMore(async ({ signal }) => {
- const page = await fetchThreads({
- cursor: threadListCursor,
- search: threadSearch,
- signal,
- });
- nextCursor = page.nextCursor ?? null;
- return {
- items: page.items,
- hasMore: page.hasMore,
- totalCount: page.totalCount,
- };
- }, signal);
- if (didLoad) {
- threadListCursor = nextCursor;
- }
- return didLoad;
- }
- async function refreshThreads(signal?: AbortSignal) {
- try {
- threadListCursor = null;
- let nextCursor: string | null = null;
- const didLoad = await threadList.loadInitial(async ({ signal }) => {
- const page = await fetchThreads({ search: threadSearch, signal });
- nextCursor = page.nextCursor ?? null;
- return {
- items: page.items,
- hasMore: page.hasMore,
- totalCount: page.totalCount,
- };
- }, signal);
- if (!didLoad)
- return;
- threadListCursor = nextCursor;
- setThreadState('ready');
- scheduleThreadListScrollFadeSync();
- await syncActiveThreadFromLocation();
- }
- catch (error) {
- if (signal?.aborted)
- return;
- setThreadState('error', error instanceof Error ? error.message : 'Unable to load threads.');
- }
- }
- addEventListeners(routerEvents, handle.signal, {
- navigate: () => {
- void handle.queueTask(async () => {
- await syncActiveThreadFromLocation();
- });
- },
- });
- async function createAndSelectThread() {
- const thread = await createThread();
- navigate(buildThreadHref(thread.id));
- await refreshThreads();
- await connectThread(thread.id);
- return thread;
- }
- async function handleCreateThread() {
- actionError = null;
- update();
- try {
- await createAndSelectThread();
- await activeClient?.waitUntilConnected();
- }
- catch (error) {
- actionError =
- error instanceof Error ? error.message : 'Unable to create thread.';
- update();
- }
- }
- async function handleDeleteThread(threadId: string) {
- actionError = null;
- update();
- try {
- await deleteThread(threadId);
- deleteThreadChecks.delete(threadId);
- if (activeThreadId === threadId) {
- activeClient?.close();
- activeClient = null;
- activeThreadId = null;
- resetChatSnapshot();
- disconnectedIndicator.reset();
- }
- await refreshThreads();
- scheduleThreadListScrollFadeSync();
- const nextThread = getThreads()[0];
- if (nextThread) {
- navigate(buildThreadHref(nextThread.id));
- await connectThread(nextThread.id);
- }
- else {
- navigate('/chat');
- }
- }
- catch (error) {
- actionError =
- error instanceof Error ? error.message : 'Unable to delete thread.';
- update();
- }
- }
- async function handleRenameThread(threadId: string, title: string) {
- actionError = null;
- update();
- try {
- const updatedThread = await updateThreadTitle(threadId, title);
- updateThreadListFromSnapshot((threads) => threads.map((thread) => thread.id === updatedThread.id ? updatedThread : thread));
- update();
- return true;
- }
- catch (error) {
- actionError =
- error instanceof Error
- ? error.message
- : 'Unable to update thread title.';
- update();
- return false;
- }
- }
- async function handleSubmit(event: SubmitEvent) {
- event.preventDefault();
- actionError = null;
- if (!(event.currentTarget instanceof HTMLFormElement))
- return;
- const form = event.currentTarget;
- const formData = new FormData(form);
- const text = String(formData.get('message') ?? '').trim();
- if (!text)
- return;
- try {
- let client = activeClient;
- if (!client) {
- await createAndSelectThread();
- client = activeClient;
- }
- if (!client) {
- throw new Error('Unable to start a chat thread.');
- }
- await client.waitUntilConnected();
- client.sendMessage(text);
- form.reset();
- const messageInput = form.elements.namedItem('message');
- resizeMessageInput(messageInput instanceof HTMLTextAreaElement ? messageInput : null);
- }
- catch (error) {
- actionError =
- error instanceof Error ? error.message : 'Unable to send message.';
- update();
- }
- }
- return () => {
- if (threadStatus === 'loading') {
- handle.queueTask(refreshThreads);
- }
- const threads = getThreads();
- const activeThread = activeThreadId
- ? (threads.find((thread) => thread.id === activeThreadId) ?? null)
- : null;
- const showEmptyStateComposer = !activeThread && threads.length === 0 && threadStatus !== 'error';
- const hasThreadInUrl = Boolean(getSelectedThreadIdFromLocation());
- return (
- {actionError ? ({actionError}
) : null}
+ let threadListSnapshot: InfiniteListSnapshot = {
+ items: [],
+ hasMore: false,
+ totalCount: 0,
+ error: null,
+ isLoadingInitial: false,
+ isLoadingMore: false,
+ }
+ let threadStatus: ThreadStatus = 'loading'
+ let threadError: string | null = null
+ let threadListCursor: string | null = null
+ let activeThreadId: string | null = null
+ let threadSearch = ''
+ let chatSnapshot = createInitialSnapshot()
+ let activeClient: ChatClient | null = null
+ let actionError: string | null = null
+ let syncInFlight = false
+ let shouldAutoScrollMessages = true
+ let showMessageScrollFadeTop = false
+ let showMessageScrollFadeBottom = false
+ let showThreadListScrollFadeTop = false
+ let showThreadListScrollFadeBottom = false
+ const disconnectedIndicator = createSpinDelay(handle, { ssr: false })
+ const deleteThreadChecks = new Map<
+ string,
+ ReturnType
+ >()
+ const threadList = createInfiniteList({
+ mergeDirection: 'append',
+ getKey: (thread) => thread.id,
+ onSnapshot(snapshot) {
+ threadListSnapshot = snapshot
+ if (deleteThreadChecks.size) {
+ const activeThreadIds = new Set(
+ snapshot.items.map((thread) => thread.id),
+ )
+ for (const threadId of deleteThreadChecks.keys()) {
+ if (!activeThreadIds.has(threadId)) {
+ deleteThreadChecks.delete(threadId)
+ }
+ }
+ }
+ threadError = snapshot.error
+ if (snapshot.isLoadingInitial) {
+ threadStatus = 'loading'
+ } else if (snapshot.error) {
+ threadStatus = 'error'
+ } else {
+ threadStatus = 'ready'
+ }
+ update()
+ },
+ })
+ function update() {
+ handle.update()
+ }
+ function setThreadState(
+ nextStatus: ThreadStatus,
+ nextError: string | null = null,
+ ) {
+ threadStatus = nextStatus
+ threadError = nextError
+ update()
+ }
+ function resetChatSnapshot() {
+ chatSnapshot = createInitialSnapshot()
+ }
+ function getThreads() {
+ return threadListSnapshot.items
+ }
+ function updateThreadListFromSnapshot(
+ updater: (threads: Array) => Array,
+ ) {
+ threadList.updateItems(updater)
+ }
+ function updateLocalThreadSummary(
+ threadId: string,
+ snapshot: ChatClientSnapshot,
+ ) {
+ updateThreadListFromSnapshot((threads) => {
+ const threadIndex = threads.findIndex((thread) => thread.id === threadId)
+ if (threadIndex === -1) return threads
+ const existingThread = threads[threadIndex]
+ if (!existingThread) return threads
+ const nextThread: ChatThreadSummary = {
+ ...existingThread,
+ messageCount: snapshot.totalMessageCount,
+ lastMessagePreview: buildThreadPreviewFromMessages(snapshot.messages),
+ }
+ if (threadSearch.trim()) {
+ return threads.map((thread) =>
+ thread.id === threadId ? nextThread : thread,
+ )
+ }
+ const remainingThreads = threads.filter(
+ (thread) => thread.id !== threadId,
+ )
+ return [nextThread, ...remainingThreads]
+ })
+ }
+ function syncDisconnectedIndicator() {
+ disconnectedIndicator.setLoading(
+ Boolean(activeThreadId) && !chatSnapshot.connected,
+ )
+ }
+ function setMessageScrollFades(
+ nextTopVisible: boolean,
+ nextBottomVisible: boolean,
+ ) {
+ if (
+ showMessageScrollFadeTop === nextTopVisible &&
+ showMessageScrollFadeBottom === nextBottomVisible
+ ) {
+ return
+ }
+ showMessageScrollFadeTop = nextTopVisible
+ showMessageScrollFadeBottom = nextBottomVisible
+ update()
+ }
+ function syncMessageScrollFades(target?: HTMLDivElement | null) {
+ const container =
+ target ??
+ (() => {
+ const element = document.getElementById(MESSAGES_SCROLL_CONTAINER_ID)
+ return element instanceof HTMLDivElement ? element : null
+ })()
+ const fades = getScrollFades(container)
+ setMessageScrollFades(fades.top, fades.bottom)
+ }
+ function scheduleMessageScrollFadeSync() {
+ void handle.queueTask(async () => {
+ syncMessageScrollFades()
+ })
+ }
+ function setThreadListScrollFades(
+ nextTopVisible: boolean,
+ nextBottomVisible: boolean,
+ ) {
+ if (
+ showThreadListScrollFadeTop === nextTopVisible &&
+ showThreadListScrollFadeBottom === nextBottomVisible
+ ) {
+ return
+ }
+ showThreadListScrollFadeTop = nextTopVisible
+ showThreadListScrollFadeBottom = nextBottomVisible
+ update()
+ }
+ function syncThreadListScrollFades(target?: HTMLDivElement | null) {
+ const container =
+ target ??
+ (() => {
+ const element = document.getElementById(THREAD_LIST_SCROLL_CONTAINER_ID)
+ return element instanceof HTMLDivElement ? element : null
+ })()
+ const fades = getScrollFades(container)
+ setThreadListScrollFades(fades.top, fades.bottom)
+ }
+ function scheduleThreadListScrollFadeSync() {
+ void handle.queueTask(async () => {
+ syncThreadListScrollFades()
+ })
+ }
+ function scheduleScrollToBottom(force = false) {
+ void handle.queueTask(async () => {
+ const container = document.getElementById(MESSAGES_SCROLL_CONTAINER_ID)
+ if (!(container instanceof HTMLDivElement)) {
+ setMessageScrollFades(false, false)
+ return
+ }
+ if (
+ !force &&
+ !shouldAutoScrollMessages &&
+ !isScrolledNearEdge(container, {
+ edge: 'bottom',
+ thresholdPx: MESSAGES_SCROLL_THRESHOLD_PX,
+ })
+ ) {
+ syncMessageScrollFades(container)
+ return
+ }
+ scrollToEdge(container, 'bottom')
+ shouldAutoScrollMessages = true
+ syncMessageScrollFades(container)
+ })
+ }
+ function handleMessagesScroll(event: Event) {
+ if (!(event.currentTarget instanceof HTMLDivElement)) return
+ shouldAutoScrollMessages = isScrolledNearEdge(event.currentTarget, {
+ edge: 'bottom',
+ thresholdPx: MESSAGES_SCROLL_THRESHOLD_PX,
+ })
+ syncMessageScrollFades(event.currentTarget)
+ if (
+ chatSnapshot.hasOlderMessages &&
+ !chatSnapshot.isLoadingOlderMessages &&
+ isScrolledNearEdge(event.currentTarget, {
+ edge: 'top',
+ thresholdPx: MESSAGES_SCROLL_THRESHOLD_PX,
+ })
+ ) {
+ const scrollAnchor = captureScrollAnchor(event.currentTarget)
+ void handle.queueTask(async (signal) => {
+ const didLoad = await activeClient?.loadOlderMessages(signal)
+ if (!didLoad) return
+ void handle.queueTask(async () => {
+ const container = document.getElementById(
+ MESSAGES_SCROLL_CONTAINER_ID,
+ )
+ if (!(container instanceof HTMLDivElement)) return
+ restoreScrollAnchorAfterPrepend(container, scrollAnchor)
+ syncMessageScrollFades(container)
+ })
+ })
+ }
+ }
+ function handleThreadListScroll(event: Event) {
+ if (!(event.currentTarget instanceof HTMLDivElement)) return
+ syncThreadListScrollFades(event.currentTarget)
+ if (
+ threadListSnapshot.hasMore &&
+ !threadListSnapshot.isLoadingMore &&
+ isScrolledNearEdge(event.currentTarget, {
+ edge: 'bottom',
+ thresholdPx: THREAD_LIST_SCROLL_THRESHOLD_PX,
+ })
+ ) {
+ void handle.queueTask(async (signal) => {
+ await loadMoreThreads(signal)
+ })
+ }
+ }
+ function handleThreadSearchInput(event: Event) {
+ if (!(event.currentTarget instanceof HTMLInputElement)) return
+ threadSearch = event.currentTarget.value
+ update()
+ void handle.queueTask(async (signal) => {
+ await refreshThreads(signal)
+ })
+ }
+ function handleComposerKeyDown(event: KeyboardEvent) {
+ if (!(event.currentTarget instanceof HTMLTextAreaElement)) return
+ if (event.key !== 'Enter' || !(event.metaKey || event.ctrlKey)) return
+ event.preventDefault()
+ event.currentTarget.form?.requestSubmit()
+ }
+ async function connectThread(threadId: string) {
+ if (activeThreadId === threadId && activeClient) return
+ activeClient?.close()
+ shouldAutoScrollMessages = true
+ activeClient = new ChatClient({
+ threadId,
+ onSnapshot(snapshot) {
+ if (activeThreadId !== threadId) return
+ chatSnapshot = snapshot
+ updateLocalThreadSummary(threadId, snapshot)
+ syncDisconnectedIndicator()
+ update()
+ scheduleMessageScrollFadeSync()
+ scheduleThreadListScrollFadeSync()
+ scheduleScrollToBottom()
+ },
+ })
+ activeThreadId = threadId
+ resetChatSnapshot()
+ syncDisconnectedIndicator()
+ setMessageScrollFades(false, false)
+ update()
+ try {
+ await activeClient.initialize()
+ } catch (error) {
+ chatSnapshot = {
+ ...createInitialSnapshot(),
+ error:
+ error instanceof Error
+ ? error.message
+ : 'Unable to connect to the selected thread.',
+ }
+ syncDisconnectedIndicator()
+ update()
+ }
+ }
+ async function syncActiveThreadFromLocation() {
+ if (threadStatus !== 'ready' || syncInFlight) return
+ syncInFlight = true
+ try {
+ const locationThreadId = getSelectedThreadIdFromLocation()
+ const threads = getThreads()
+ if (threads.length === 0) {
+ activeClient?.close()
+ activeClient = null
+ activeThreadId = null
+ resetChatSnapshot()
+ disconnectedIndicator.reset()
+ setMessageScrollFades(false, false)
+ update()
+ if (locationThreadId) {
+ navigate('/chat')
+ }
+ return
+ }
+ if (
+ locationThreadId &&
+ !threads.some((thread) => thread.id === locationThreadId)
+ ) {
+ try {
+ const selectedThread = await fetchThreadById(locationThreadId)
+ updateThreadListFromSnapshot((currentThreads) => [
+ selectedThread,
+ ...currentThreads,
+ ])
+ } catch {
+ // Ignore missing selections and fall back to the first loaded thread.
+ }
+ }
+ const selectedThread =
+ locationThreadId &&
+ getThreads().find((thread) => thread.id === locationThreadId)
+ ? locationThreadId
+ : null
+ const resolvedThreadId = selectedThread ?? getThreads()[0]?.id ?? null
+ if (!resolvedThreadId) return
+ if (locationThreadId !== resolvedThreadId) {
+ if (locationThreadId || !isMobileViewport()) {
+ navigate(buildThreadHref(resolvedThreadId))
+ } else {
+ activeClient?.close()
+ activeClient = null
+ activeThreadId = null
+ resetChatSnapshot()
+ disconnectedIndicator.reset()
+ setMessageScrollFades(false, false)
+ update()
+ }
+ return
+ }
+ await connectThread(resolvedThreadId)
+ } finally {
+ syncInFlight = false
+ }
+ }
+ async function loadMoreThreads(signal?: AbortSignal) {
+ if (!threadListCursor) return false
+ let nextCursor: string | null = null
+ const didLoad = await threadList.loadMore(async ({ signal }) => {
+ const page = await fetchThreads({
+ cursor: threadListCursor,
+ search: threadSearch,
+ signal,
+ })
+ nextCursor = page.nextCursor ?? null
+ return {
+ items: page.items,
+ hasMore: page.hasMore,
+ totalCount: page.totalCount,
+ }
+ }, signal)
+ if (didLoad) {
+ threadListCursor = nextCursor
+ }
+ return didLoad
+ }
+ async function refreshThreads(signal?: AbortSignal) {
+ try {
+ threadListCursor = null
+ let nextCursor: string | null = null
+ const didLoad = await threadList.loadInitial(async ({ signal }) => {
+ const page = await fetchThreads({ search: threadSearch, signal })
+ nextCursor = page.nextCursor ?? null
+ return {
+ items: page.items,
+ hasMore: page.hasMore,
+ totalCount: page.totalCount,
+ }
+ }, signal)
+ if (!didLoad) return
+ threadListCursor = nextCursor
+ setThreadState('ready')
+ scheduleThreadListScrollFadeSync()
+ await syncActiveThreadFromLocation()
+ } catch (error) {
+ if (signal?.aborted) return
+ setThreadState(
+ 'error',
+ error instanceof Error ? error.message : 'Unable to load threads.',
+ )
+ }
+ }
+ addEventListeners(routerEvents, handle.signal, {
+ navigate: () => {
+ void handle.queueTask(async () => {
+ await syncActiveThreadFromLocation()
+ })
+ },
+ })
+ async function createAndSelectThread() {
+ const thread = await createThread()
+ navigate(buildThreadHref(thread.id))
+ await refreshThreads()
+ await connectThread(thread.id)
+ return thread
+ }
+ async function handleCreateThread() {
+ actionError = null
+ update()
+ try {
+ await createAndSelectThread()
+ await activeClient?.waitUntilConnected()
+ } catch (error) {
+ actionError =
+ error instanceof Error ? error.message : 'Unable to create thread.'
+ update()
+ }
+ }
+ async function handleDeleteThread(threadId: string) {
+ actionError = null
+ update()
+ try {
+ await deleteThread(threadId)
+ deleteThreadChecks.delete(threadId)
+ if (activeThreadId === threadId) {
+ activeClient?.close()
+ activeClient = null
+ activeThreadId = null
+ resetChatSnapshot()
+ disconnectedIndicator.reset()
+ }
+ await refreshThreads()
+ scheduleThreadListScrollFadeSync()
+ const nextThread = getThreads()[0]
+ if (nextThread) {
+ navigate(buildThreadHref(nextThread.id))
+ await connectThread(nextThread.id)
+ } else {
+ navigate('/chat')
+ }
+ } catch (error) {
+ actionError =
+ error instanceof Error ? error.message : 'Unable to delete thread.'
+ update()
+ }
+ }
+ async function handleRenameThread(threadId: string, title: string) {
+ actionError = null
+ update()
+ try {
+ const updatedThread = await updateThreadTitle(threadId, title)
+ updateThreadListFromSnapshot((threads) =>
+ threads.map((thread) =>
+ thread.id === updatedThread.id ? updatedThread : thread,
+ ),
+ )
+ update()
+ return true
+ } catch (error) {
+ actionError =
+ error instanceof Error
+ ? error.message
+ : 'Unable to update thread title.'
+ update()
+ return false
+ }
+ }
+ async function handleSubmit(event: SubmitEvent) {
+ event.preventDefault()
+ actionError = null
+ if (!(event.currentTarget instanceof HTMLFormElement)) return
+ const form = event.currentTarget
+ const formData = new FormData(form)
+ const text = String(formData.get('message') ?? '').trim()
+ if (!text) return
+ try {
+ let client = activeClient
+ if (!client) {
+ await createAndSelectThread()
+ client = activeClient
+ }
+ if (!client) {
+ throw new Error('Unable to start a chat thread.')
+ }
+ await client.waitUntilConnected()
+ client.sendMessage(text)
+ form.reset()
+ const messageInput = form.elements.namedItem('message')
+ resizeMessageInput(
+ messageInput instanceof HTMLTextAreaElement ? messageInput : null,
+ )
+ } catch (error) {
+ actionError =
+ error instanceof Error ? error.message : 'Unable to send message.'
+ update()
+ }
+ }
+ return () => {
+ if (threadStatus === 'loading') {
+ handle.queueTask(refreshThreads)
+ }
+ const threads = getThreads()
+ const activeThread = activeThreadId
+ ? (threads.find((thread) => thread.id === activeThreadId) ?? null)
+ : null
+ const showEmptyStateComposer =
+ !activeThread && threads.length === 0 && threadStatus !== 'error'
+ const hasThreadInUrl = Boolean(getSelectedThreadIdFromLocation())
+ return (
+
+ {actionError ? (
+ {actionError}
+ ) : null}
-