diff --git a/.agents/skills/remix/SKILL.md b/.agents/skills/remix/SKILL.md new file mode 100644 index 00000000..eb9e7d42 --- /dev/null +++ b/.agents/skills/remix/SKILL.md @@ -0,0 +1,561 @@ +--- +name: remix +description: + Build and review Remix 3 applications using the `remix` npm package and + subpath imports. Use when working on Remix app structure, routes, controllers, + middleware, validation, data access, auth, sessions, file uploads, server + setup, UI components, hydration, navigation, or tests. +--- + +# Build a Remix App + +Use this skill for end-to-end Remix app work. It should help the agent choose +the right layer first, reach for the right package, and avoid the most common +Remix-specific mistakes. + +## What Remix Is + +Remix 3 is a server-first web framework built on Web APIs such as `Request`, +`Response`, `URL`, and `FormData`. All packages ship from a single npm package, +`remix`, and are imported via subpath. There is no top-level `remix` import. + +A Remix app has four main pieces: + +- **Routes** in `app/routes.ts` define the typed URL contract and power `href()` + generation. +- **Controllers and actions** implement that contract and return `Response` + objects. +- **Middleware** composes request lifecycle behavior and populates typed context + via `context.set(Key, value)`. +- **Components** render UI with `remix/ui`. This is not React. A component + receives a `handle`, reads current props from `handle.props`, and returns a + render function. + +## When To Use This Skill + +Use this skill for: + +- new features or refactors that touch routing, controllers, middleware, data, + auth, sessions, UI, or tests +- reviewing Remix app code for correctness, architecture, or framework usage +- answering "how should this be structured in Remix?" questions +- finding the right package, reference doc, or default pattern for a task + +## Load Only The References You Need + +Classify the task first, then load the smallest useful reference set. Each +reference file starts with a "What This Covers" section that lists the topics +inside it — read that first to confirm the file is relevant before reading the +rest. + +Use the table below to find candidates. Loading more than two or three files at +once is usually a sign that the task hasn't been narrowed enough yet. + +| Task involves... | Start with | +| ----------------------------------------------------------------------------- | ------------------------------------------- | +| Defining URLs, writing controllers and actions, returning responses | `references/routing-and-controllers.md` | +| Composing the request lifecycle, ordering middleware, bridging to a server | `references/middleware-and-server.md` | +| Compiling and serving browser modules, asset URL namespaces, preloads | `references/assets-and-browser-modules.md` | +| Parsing input, validating with schemas, defining tables, querying, migrations | `references/data-and-validation.md` | +| Per-browser state, login flows, route protection, identity | `references/auth-and-sessions.md` | +| Component setup, state, lifecycle, updates, `queueTask`, context | `references/component-model.md` | +| Event handlers, styles, refs, click/key behavior, simple animations | `references/mixins-styling-events.md` | +| `clientEntry`, `run`, ``, navigation, `` | `references/hydration-frames-navigation.md` | +| Router tests, component tests, test isolation | `references/testing-patterns.md` | +| Spring physics, tweens, layout transitions | `references/animate-elements.md` | +| Authoring custom reusable mixins | `references/create-mixins.md` | + +Common bundles: + +- **Form or CRUD feature** -> routing, data and validation, testing; add auth if + user-specific +- **Protected area** -> auth and sessions, routing, testing +- **Interactive widget** -> component model, mixins and styling; add hydration + only if it runs in the browser +- **Browser asset pipeline** -> assets and browser modules, hydration, + middleware and server +- **File upload** -> middleware and server, data and validation, testing +- **Navigation or frames** -> hydration, frames, navigation + +## Default Workflow + +1. **Classify the change.** Decide whether it changes the route contract, + request lifecycle, data model, auth or session behavior, or only UI. +2. **Start from the server contract.** Add or update `app/routes.ts` before + wiring handlers or UI. +3. **Put code in the narrowest owner.** Favor route-local code first, then + promote only when reuse is real. +4. **Make the server path correct before adding browser behavior.** A route + should return the right `Response` via `router.fetch(...)` before you add + `clientEntry(...)`, animations, or DOM effects. +5. **Add middleware deliberately.** Keep fast-exit middleware early and + request-enriching middleware later. Export a typed `AppContext` from the root + middleware stack and use it in controllers. +6. **Validate input at the boundary.** Parse and validate `Request`, `FormData`, + params, cookies, and external payloads before they reach rendering or + persistence logic. +7. **Hydrate only when necessary.** Prefer server-rendered UI. Use + `clientEntry(...)` and `run(...)` only for real browser interactivity or + browser-only APIs. +8. **Test the narrowest meaningful layer.** Prefer router tests for route + behavior. Use component tests when the behavior is truly interactive or + DOM-specific. +9. **Finish with verification.** Re-read the route flow, confirm auth and + authorization boundaries, and run the smallest relevant test and typecheck + loop. + +## Project Layout + +Use these root directories consistently: + +- `app/` for runtime application code +- `db/` for migrations and local database files +- `public/` for static assets served as-is +- `test/` for shared helpers, fixtures, and integration coverage +- `tmp/` for uploads, caches, local session files, and other scratch data + +Inside `app/`, organize by responsibility: + +- `assets/` for client entrypoints and client-owned browser behavior +- `controllers/` for route-owned handlers and route-local UI +- `data/` for schema, queries, persistence setup, migrations, and runtime data + initialization +- `middleware/` for request lifecycle concerns such as auth, sessions, uploads, + and database injection +- `ui/` for shared cross-route UI primitives +- `utils/` only for genuinely cross-layer helpers that do not clearly belong + elsewhere +- `routes.ts` for the route contract +- `router.ts` for router setup and wiring + +### Placement Precedence + +When code could live in multiple places: + +1. Put it in the narrowest owner first. +2. If it belongs to one route, keep it with that route. +3. If it is shared UI across route areas, move it to `app/ui/`. +4. If it is request lifecycle setup, keep it in `app/middleware/`. +5. If it is schema, query, persistence, or startup data logic, keep it in + `app/data/`. +6. Use `app/utils/` only as a last resort for truly cross-layer helpers. + +### Route Ownership + +- Use a flat file in `app/controllers/` for a simple leaf action, such as + `app/controllers/home.tsx` +- Use a folder with `controller.tsx` when a route owns nested routes or multiple + actions, such as `app/controllers/account/controller.tsx` +- Mirror nested route structure on disk, such as + `app/controllers/auth/login/controller.tsx` +- Keep route-local UI next to its owner, such as + `app/controllers/contact/page.tsx` +- Move shared UI to `app/ui/` +- If a flat leaf grows child routes or multiple actions, promote it to a + controller folder + +### Layout Anti-Patterns + +- Do not create `app/lib/` as a generic dumping ground +- Do not create `app/components/` as a second shared UI bucket when `app/ui/` + already owns that role +- Do not put shared cross-route UI in `app/controllers/` +- Do not put middleware or persistence helpers in `app/utils/` when they have a + clearer home +- Do not create folders for simple leaf actions unless they are real controllers + +## Core Remix Rules + +- Import from `remix/`, never `import { ... } from 'remix'` +- Treat `app/routes.ts` as the source of truth for URLs. Use + `routes..href(...)` for redirects, links, tests, and internal URL + construction +- Controllers and actions should return explicit `Response` objects, including + redirects, 404s, and validation failures. At the route boundary, prefer + returning a `Response` for expected outcomes (validation errors, conflicts, + not found) over throwing for control flow +- Model HTTP behavior explicitly. Status codes, headers, redirects, cache rules, + and content types are part of the route contract +- Make the server route correct first. A POST should already return the right + HTML, redirect, or error response on its own before `clientEntry(...)` layers + interactivity on top +- Validate input at the boundary using `remix/data-schema` (and + `remix/data-schema/form-data` for forms). `parseSafe` makes the failure path a + return value instead of an exception +- Derive `AppContext` from the root middleware stack so `get(Database)`, + `get(Session)`, `get(Auth)`, and similar keys stay typed. If the controller + never reads from context, it doesn't need the harness +- Outside actions and controllers, only use `getContext()` when `asyncContext()` + is in the middleware stack +- Remix Component is not React: read props from `handle.props`, keep state in + setup-scope variables, call `handle.update()` explicitly, and do DOM-sensitive + work in event handlers or `queueTask(...)`, not in render +- Prefer host-element mixins via `mix={mixin(...)}` for behavior and styling + instead of inventing custom host prop conventions. Use `mix={[...]}` only when + composing multiple mixins +- Hydrated `clientEntry(...)` props must be serializable. Do not pass functions, + class instances, or opaque runtime objects + +## Security And Session Defaults + +- Never ship demo secrets. In non-test environments, require session and + provider secrets from the environment and fail fast if they are missing +- Use hardened cookies: `httpOnly` always, `sameSite` by default, and `secure` + when serving over HTTPS +- Regenerate session IDs on login, logout, and privilege changes +- Use `requireAuth()` to protect authenticated route areas, but still authorize + resource ownership inside handlers and data writes +- Add CSRF protection when browser forms mutate state using cookie-backed + sessions +- Add CORS only for endpoints that must be called cross-origin. Prefer + same-origin by default +- Prefer JSX or `remix/html-template` for HTML generation so escaping stays + correct +- Validate uploads for size, type, and destination. Treat filenames and content + as untrusted input + +## Testing Defaults + +- Prefer server and router tests first. Drive the app with + `router.fetch(new Request(...))` and assert on the returned `Response` +- Build a fresh router per test or per suite so sessions, in-memory storage, and + database state stay isolated +- Use `routes..href(...)` in tests so URLs stay coupled to the route + contract +- For auth or session scenarios, use a test cookie and + `createMemorySessionStorage()` instead of production storage +- Use component tests only for interactive or DOM-specific behavior. Render with + `createRoot(...)`, interact with the real DOM, and call `root.flush()` between + steps +- Prefer one representative behavior test over many repetitive assertion + variants + +## Common Mistakes To Avoid + +- Treating Remix Component like React and reaching for hooks or implicit + rerendering +- Importing from a top-level `remix` entry instead of a subpath +- Adding `clientEntry(...)` before the server-rendered route behavior is correct +- Passing non-serializable props into `clientEntry(...)` +- Calling `getContext()` without `asyncContext()` in the middleware stack +- Getting middleware order wrong; fast exits like static files belong early, + request enrichment later +- Skipping boundary validation and trusting raw `FormData`, params, cookies, or + external payloads +- Letting route-local domain errors leak out of the controller. Translate + expected outcomes (validation, conflicts, not-found) into the HTTP `Response` + the route means to return rather than throwing a custom `Error` subclass and + catching it elsewhere +- Reaching for `createCookie` when a tamper-sensitive or server-managed + per-browser fact really wants `remix/session`. If editing the value would be a + bug, use a session +- Building a JSON-only RPC layer when a normal form POST, redirect, or resource + route would be simpler. Fetch-from-the-client is a layer on top of sound route + behavior, not a replacement for it +- Treating JSON state endpoints and `` reloads as mutually exclusive + patterns. Pick the lightest sync mechanism that fits the UX; small widgets may + reasonably poll a JSON endpoint +- Assuming authentication is enough without per-resource authorization checks +- Dropping shared code into vague buckets like `utils.ts`, `helpers.ts`, or + `common.ts` when ownership is known +- Writing only component tests for a feature whose main behavior is really an + HTTP route concern + +## Package Map + +Use this map to find the right package quickly. Each entry says what the package +is for, not just what it exports. Open the linked reference file when you need +full examples. + +### Routing, Server, and Responses + +- `remix/fetch-router` — the router itself. Use for `createRouter`, controller + and middleware types, and registering routes +- `remix/fetch-router/routes` — declarative route builders. Use for `route`, + `get`, `post`, `put`, `del`, `form`, `resources` when defining `app/routes.ts` +- `remix/node-fetch-server` — adapter from Node's `http` module to a Fetch-style + router. Use for `createRequestListener` in `server.ts` +- `remix/assets` — browser asset server. Use for `createAssetServer` when + serving compiled scripts and styles, getting public hrefs, and emitting + preloads. Shared compiler options such as `target`, `sourceMaps`, + `sourceMapSourcePaths`, and `minify` live at the top level +- `remix/headers` — typed header parsers and builders. Use when reading + `Accept`, `Cookie`, or setting `CacheControl`, `Vary`, etc., instead of + hand-formatting strings +- `remix/response/redirect` — `redirect(href, status?)`. Use for the canonical + "POST then redirect" pattern and other location changes +- `remix/response/html` — `createHtmlResponse`. Use when you need an HTML + `Response` from a string or stream without rendering through `remix/ui` +- `remix/response/compress` — `compressResponse`. Use when compressing one-off + responses outside the global `compression()` middleware +- `remix/response/file` — file-download responses. Use for + `Content-Disposition: attachment` responses +- `remix/route-pattern` — low-level URL matching and generation. Use when + working with raw patterns outside the router (custom matchers, scripts) +- `remix/fetch-proxy` — Fetch-based HTTP proxying. Use to forward a request to + another origin; pass `xForwardedHeaders` when the upstream needs forwarded + proto, host, and port + +### Data, Validation, and Persistence + +- `remix/data-schema` — schema builders for runtime validation. Use for `parse` + and `parseSafe` to validate any input that crosses a trust boundary, and + `.transform(...)` when validated output should map to a different value or + type +- `remix/data-schema/checks` — common check helpers (`email`, `minLength`, + `maxLength`, etc.). Use to compose into a schema +- `remix/data-schema/coerce` — coercion helpers for strings, numbers, booleans, + dates, and ids. Use when input arrives as a string but should be a typed value +- `remix/data-schema/form-data` — `f.object` and `f.field` for parsing + `FormData` directly. Use in actions that read browser forms +- `remix/data-table` — typed tables and a `Database` interface. Use for `table`, + `column`, `createDatabase` when modeling persisted data +- `remix/data-table-sqlite`, `remix/data-table-postgres`, + `remix/data-table-mysql` — adapters. Use to back `createDatabase` with a real + engine. SQLite accepts Node, Bun, and compatible synchronous clients with the + shared `prepare`/`exec` surface +- `remix/data-table/migrations` — migration authoring and runners. Use for + `createMigration`, `createMigrationRunner` +- `remix/data-table/migrations/node` — `loadMigrations` from disk. Use in + startup scripts that apply migrations +- `remix/data-table/operators` — query operators such as `inList(...)`. Use when + `where` clauses need set or comparison logic + +### Auth, Sessions, and Cookies + +- `remix/session` — the `Session` object: `get`, `set`, `flash`, `unset`, + `regenerateId`. Use for any per-browser state where tampering would be a bug + (login, "I submitted this form already", cart, flash messages) +- `remix/session-middleware` — `session(cookie, storage)`. Use to wire a session + cookie and storage backend into the root middleware stack +- `remix/session/fs-storage`, `remix/session/memory-storage`, + `remix/session/cookie-storage` — storage backends. Use `fs-storage` for + single-process apps, `memory-storage` for tests, `cookie-storage` for + stateless deployments where data fits in a cookie +- `remix/session-storage-redis` — Redis-backed storage. Use for multi-process or + multi-host deployments +- `remix/session-storage-memcache` — Memcache-backed storage. Same multi-host + use case as Redis +- `remix/cookie` — `createCookie` for plain signed/unsigned cookies. Use for + non-sensitive preferences where the client is allowed to control the value + (theme, locale, dismissed banner). For state where tampering matters, prefer + `remix/session` +- `remix/auth` — credentials, OAuth, OIDC, and Atmosphere providers. Use to + define how identity is verified, start/finish external login, and refresh + stored OAuth/OIDC token bundles with `refreshExternalAuth(...)` +- `remix/auth-middleware` — `auth({ schemes })`, `requireAuth`, the `Auth` + context key. Use to resolve identity into the request context and to gate + routes + +### UI, Hydration, and Browser Behavior + +- `remix/ui` — the component runtime: components, core mixins, `clientEntry`, + `run`, ``, navigation helpers, and `createRoot`. Use for app UI + behavior +- `remix/ui/server` — server rendering: `renderToStream`, `renderToString`. Use + in the `render(...)` helper that returns HTML responses +- `remix/ui/animation` — animation APIs: `animateEntrance`, `animateExit`, + `animateLayout`, `spring`, `tween`, and `easings` +- `remix/ui/` — UI primitives, mixins, glyphs, and theme helpers. + Import from `remix/ui/accordion`, `remix/ui/button`, `remix/ui/select`, etc. +- `remix/ui/test` — component test rendering helpers such as `render` +- `remix/ui/jsx-runtime` — JSX transform target. Configured in `tsconfig.json`, + rarely imported directly +- `remix/html-template` — escaped HTML template literals. Use when generating + HTML outside the component system (RSS feeds, email bodies, error pages) +- `remix/file-storage` — backend-agnostic `File` storage interface. Use as the + type bound for upload destinations +- `remix/file-storage/fs`, `remix/file-storage/memory`, `remix/file-storage-s3` + — storage backends. Use to implement an upload destination + +### Middleware + +- `remix/static-middleware` — `staticFiles(dir)`. Use to serve files from + `public/` exactly as they exist on disk +- `remix/form-data-middleware` — `formData()`. Use to parse `FormData` once and + expose it via `get(FormData)` instead of calling `await request.formData()` in + each action +- `remix/form-data-parser` — lower-level `parseFormData`, `FileUpload`. Use when + implementing custom upload handlers. Upload handler errors propagate directly +- `remix/multipart-parser` and `remix/multipart-parser/node` — low-level + multipart stream parsing. `MultipartPart.headers` is a plain object keyed by + lower-case header name; read values with bracket notation such as + `part.headers['content-type']` +- `remix/compression-middleware` — `compression()`. Use globally for text-like + responses +- `remix/logger-middleware` — `logger()`. Use in development for request logs; + pass `colors` to force terminal color output on or off +- `remix/method-override-middleware` — `methodOverride()`. Use when HTML forms + need `PUT`, `PATCH`, or `DELETE` +- `remix/async-context-middleware` — `asyncContext()`, `getContext()`. Use when + helpers outside actions need request context without threading it through + every call +- `remix/cors-middleware` — `cors(opts?)`. Use for endpoints called cross-origin +- `remix/csrf-middleware` — `csrf(opts?)`. Use when session-backed forms mutate + state and need synchronizer-token CSRF protection +- `remix/cop-middleware` — cross-origin protection. Use to reject unsafe + cross-origin browser requests + +### Test + +- `remix/test` — `describe`, `it`, and lifecycle hooks. Use as the test + framework +- `remix/test/cli` — programmatic test runner APIs such as `runRemixTest` +- `remix/cli` — programmatic Remix CLI API. Use the `remix` executable for + project commands such as `remix test`, `remix routes`, and `remix doctor` +- `remix/assert` — assertion helpers. Use in place of `node:assert` so messages + render cleanly in the runner +- `remix/terminal` — ANSI styles, color detection, style factories, and testable + terminal streams. Use for CLIs and terminal output instead of hand-rolled + escape sequences + +## Canonical Patterns + +### Define routes first + +```typescript +import { form, get, post, resources, route } from 'remix/fetch-router/routes' + +export const routes = route({ + home: '/', + contact: form('contact'), + books: { + index: '/books', + show: '/books/:slug', + }, + auth: route('auth', { + login: form('login'), + logout: post('logout'), + }), + admin: route('admin', { + index: get('/'), + books: resources('books', { param: 'bookId' }), + }), +}) +``` + +### Type controllers against the route contract + +```typescript +import type { Controller } from 'remix/fetch-router' + +import type { AppContext } from '../router.ts' +import { routes } from '../routes.ts' + +export default { + actions: { + async index({ get }) { + let db = get(Database) + let allBooks = await db.findMany(books, { orderBy: ['id', 'asc'] }) + return render() + }, + async show({ get, params }) { + let db = get(Database) + let book = await db.findOne(books, { where: { slug: params.slug } }) + if (!book) return new Response('Not Found', { status: 404 }) + return render() + }, + }, +} satisfies Controller +``` + +### Compose middleware deliberately + +```typescript +import { + createRouter, + type AnyParams, + type MiddlewareContext, + type WithParams, +} from 'remix/fetch-router' + +export type RootMiddleware = [ + ReturnType, + ReturnType, + ReturnType, + ReturnType, +] + +export type AppContext = WithParams< + MiddlewareContext, + params +> + +let middleware = [] + +if (process.env.NODE_ENV === 'development') { + middleware.push(logger()) +} + +middleware.push(compression()) +middleware.push(staticFiles('./public')) +middleware.push(formData()) +middleware.push(methodOverride()) +middleware.push(session(cookie, storage)) +middleware.push(asyncContext()) +middleware.push(loadDatabase()) +middleware.push(loadAuth()) + +let router = createRouter({ middleware }) +``` + +### Mutate, validate, and respond + +```typescript +import { redirect } from 'remix/response/redirect' +import * as s from 'remix/data-schema' +import * as f from 'remix/data-schema/form-data' +import { Session } from 'remix/session' +import { Database } from 'remix/data-table' + +let bookSchema = f.object({ + slug: f.field(s.string()), + title: f.field(s.string()), +}) + +export default { + actions: { + async create({ get }) { + let parsed = s.parseSafe(bookSchema, get(FormData)) + if (!parsed.success) { + return render(, { status: 400 }) + } + + let db = get(Database) + let book = await db.create(books, parsed.value) + + let session = get(Session) + session.flash('message', `Added ${book.title}.`) + + return redirect(routes.books.show.href({ slug: book.slug })) + }, + }, +} satisfies Controller +``` + +This shape works without JavaScript, returns a `Response` for every outcome, and +is ready for `clientEntry(...)` interactivity when the UI needs it. + +### Build UI from handle props plus render + +```tsx +import { on, type Handle } from 'remix/ui' + +function Counter(handle: Handle<{ initialCount?: number; label: string }>) { + let count = handle.props.initialCount ?? 0 + + return () => ( + + ) +} +``` + +Only add `clientEntry(...)` and `run(...)` when the component needs browser +interactivity or browser-only APIs. diff --git a/.agents/skills/remix/references/animate-elements.md b/.agents/skills/remix/references/animate-elements.md new file mode 100644 index 00000000..344d6f64 --- /dev/null +++ b/.agents/skills/remix/references/animate-elements.md @@ -0,0 +1,222 @@ +# Animating Elements + +## What This Covers + +How to animate insertion, removal, and layout changes of elements. Read this +when the task involves: + +- Adding entrance, exit, or shared-layout transitions to UI +- Choosing between spring physics (`spring(...)`) and time-based easing + (`tween`) +- Coordinating CSS transitions with the same easing as JS animations +- Imperative animation loops via `requestAnimationFrame` + +Import animation APIs from `remix/ui/animation`. For the smaller set of +animation helpers that show up alongside other mixins, see +`mixins-styling-events.md`. + +## Animation Mixins + +### `animateEntrance(config)` + +Animates an element when inserted. Config specifies the **starting** style the +element animates **from**: + +```tsx +
+``` + +### `animateExit(config)` + +Animates an element when removed. Config specifies the **ending** style the +element animates **to**. The element stays in the DOM until the animation +completes: + +```tsx +{ + isVisible && ( +
+ ) +} +``` + +### `animateLayout(config?)` + +Animates layout changes (position/size) using FLIP-style transforms: + +```tsx +{ + items.map((item) => ( +
  • + )) +} +``` + +Options: `duration` (default 200ms), `easing` (default spring snappy), `size` +(default true — include scale projection for size changes). + +### Combining mixins + +```tsx +
    +``` + +### Shared-layout swap + +```tsx +
    *': { gridArea: '1 / 1' } })}> + {stateA ? ( +
    + ) : ( +
    + )} +
    +``` + +## Spring API + +Physics-based spring animation. Returns a `SpringIterator` with `duration`, +`easing`, and `toString()` for CSS. + +### Presets + +| Preset | Bounce | Duration | Character | +| -------- | ------ | -------- | --------------------------- | +| `smooth` | -0.3 | 400ms | Overdamped, no overshoot | +| `snappy` | 0 | 200ms | Critically damped, quick | +| `bouncy` | 0.3 | 400ms | Underdamped, visible bounce | + +```tsx +spring('bouncy') +spring('snappy') +spring('smooth') +spring('bouncy', { duration: 300 }) // override duration +``` + +### Custom spring + +```tsx +spring({ duration: 500, bounce: 0.3 }) +spring({ duration: 500, bounce: 0.3, velocity: 2 }) // continue momentum from gesture +``` + +### Spread into animation mixins + +Spreading a spring gives both `duration` and `easing`: + +```tsx +animateEntrance({ opacity: 0, ...spring('bouncy') }) +``` + +### CSS transitions + +The iterator stringifies to `"550ms linear(...)"`: + +```tsx +css({ transition: `width ${spring('bouncy')}` }) +``` + +Or use the `spring.transition()` helper for multiple properties: + +```tsx +css({ transition: spring.transition('width', 'bouncy') }) +css({ transition: spring.transition(['left', 'top'], 'snappy') }) +``` + +### Web Animations API + +```tsx +element.animate(keyframes, { ...spring('bouncy') }) +``` + +### JS iteration + +The iterator yields position values from 0 to 1, one per frame: + +```tsx +for (let t of spring('bouncy')) { + let x = from + (to - from) * t + updateSomething(x) + await nextFrame() +} +``` + +## Tween API + +Generator-based tween for animating values over time with cubic bezier easing. +Prefer animation mixins or CSS transitions with `spring` for most UI work. Use +`tween` for imperative `requestAnimationFrame` loops, canvas/WebGL, or non-CSS +properties. + +```tsx +import { tween, easings } from 'remix/ui/animation' + +let animation = tween({ + from: 0, + to: 100, + duration: 300, + curve: easings.easeOut, +}) + +animation.next() // initialize +function tick(timestamp: number) { + if (handle.signal.aborted) return + let { value, done } = animation.next(timestamp) + element.style.transform = `translateX(${value}px)` + if (!done) requestAnimationFrame(tick) +} +requestAnimationFrame(tick) +``` + +Built-in easings: `easings.linear`, `easings.ease`, `easings.easeIn`, +`easings.easeOut`, `easings.easeInOut`. + +## Practical Guidance + +- Always key conditional or switching elements you expect to animate. +- Use `animateLayout` only on the element whose position or size changes. +- Prefer one clear transition intent per mixin: entrance starts from a style, + exit ends at a style. +- Default to `...spring()` for duration and easing in most cases. +- Keep DOM work in `handle.queueTask(...)` or `ref(...)`, not in render. diff --git a/.agents/skills/remix/references/assets-and-browser-modules.md b/.agents/skills/remix/references/assets-and-browser-modules.md new file mode 100644 index 00000000..45e7387d --- /dev/null +++ b/.agents/skills/remix/references/assets-and-browser-modules.md @@ -0,0 +1,133 @@ +# Assets and Browser Modules + +## What This Covers + +How to serve browser scripts and styles from source. Read this when the task +involves: + +- Configuring `createAssetServer` (`fileMap`, `allow`, `deny`, fingerprinting, + compiler options) +- Choosing between `staticFiles()` for already-built files and + `createAssetServer()` for source assets that need import rewriting, preloads, + or fingerprinted URLs +- Generating script URLs or `` tags for a client entry +- Keeping server-only files out of the browser via `deny` rules + +For routing the URL namespace itself, see `routing-and-controllers.md`. For +client entry hydration, see `hydration-frames-navigation.md`. + +## When To Reach For It + +Use `remix/assets` when the app serves browser JavaScript, TypeScript, or CSS +from source files. This is the right tool for client entrypoints, browser-only +helpers, styles under `app/assets/`, and monorepo code that should be compiled +and served under a public URL namespace. + +Use `staticFiles()` for files that already exist on disk exactly as they should +be served. Use `createAssetServer()` for source scripts or styles that need +rewriting, dependency scanning, preloads, sourcemaps, or fingerprinted URLs. + +## Default Pattern + +```typescript +import * as path from 'node:path' + +import { createAssetServer } from 'remix/assets' +import { createRouter } from 'remix/fetch-router' + +let assetServer = createAssetServer({ + rootDir: path.resolve(import.meta.dirname, '..'), + fileMap: { + '/assets/app/*path': 'app/*path', + '/assets/packages/*path': '../packages/*path', + }, + allow: ['app/assets/**', '../packages/**'], + deny: ['app/**/*.server.*'], + target: { es: '2020', chrome: '109', safari: '16.4' }, + sourceMaps: process.env.NODE_ENV === 'development' ? 'external' : undefined, + minify: process.env.NODE_ENV === 'production', + scripts: { + define: { + 'process.env.NODE_ENV': JSON.stringify( + process.env.NODE_ENV ?? 'development', + ), + }, + }, +}) + +let router = createRouter() + +router.get('/assets/*path', ({ request }) => { + return assetServer.fetch(request) +}) +``` + +## Rules + +- Treat `allow` and `deny` as the security boundary for browser-reachable source + files. +- Add a `deny` list for server-only modules such as `*.server.*`, private + config, or other files that should never be exposed. +- Set `rootDir` explicitly in monorepos so relative paths resolve from the + intended project root. +- `fileMap` keys are public URL patterns and values are root-relative file path + patterns. They use `route-pattern` syntax on both sides. +- Keep the same wildcard params on both sides of a `fileMap` entry so import + rewriting can map source files back to public URLs. +- CSS files are compiled and served alongside scripts. Local CSS `@import` rules + are rewritten and fingerprinted with the same asset server routing rules. + +## Rendering HTML + +Use `getHref()` when you need the public URL for one module, and `getPreloads()` +when you want `` tags or `Link` headers for one or +more entrypoints and their dependencies. + +```typescript +let entryHref = await assetServer.getHref('app/assets/entry.ts') +let preloads = await assetServer.getPreloads(['app/assets/entry.ts']) +``` + +Use this when rendering documents or layouts that boot browser behavior with a +known client entry. + +When resolving hydrated client entries during server rendering, pass the source +entry ID from `clientEntry(import.meta.url, ...)` to `getHref()` inside +`resolveClientEntry`. Keep export-name resolution in that render helper, and +avoid hard-coding public asset URLs in source-owned component modules. + +## Development vs Deployment + +In development: + +- Keep `watch` enabled so source changes are picked up without restarting the + server +- Prefer stable URLs with normal revalidation +- Enable source maps when debugging browser code + +In deployment: + +- Set `watch: false` +- Use `fingerprint: { buildId }` for long-lived immutable caching +- Make sure `buildId` changes for each deploy + +Fingerprinting assumes files on disk are stable and requires `watch: false`. + +## Useful Compiler Options + +- `minify` for production minification of scripts and styles +- `sourceMaps` for `'external'` or `'inline'` source maps for scripts and styles +- `sourceMapSourcePaths` for `'url'` or `'absolute'` source map paths +- `target` as an object for shared browser targets and script-only ECMAScript + output, such as `{ es: '2020', chrome: '109', safari: '16.4' }` +- `scripts.define` to replace globals such as `process.env.NODE_ENV` +- `scripts.external` to leave specific script imports untouched + +Do not nest shared compiler options under `scripts`. Use top-level `minify`, +`sourceMaps`, `sourceMapSourcePaths`, and `target` so they apply to styles as +well as scripts. + +## Lifecycle + +If the asset server is long-lived and watching the file system, call +`await assetServer.close()` when shutting down dev servers or disposing tests. diff --git a/.agents/skills/remix/references/auth-and-sessions.md b/.agents/skills/remix/references/auth-and-sessions.md new file mode 100644 index 00000000..58982ab8 --- /dev/null +++ b/.agents/skills/remix/references/auth-and-sessions.md @@ -0,0 +1,436 @@ +# Authentication and Sessions + +## What This Covers + +How to remember things about a browser between requests and how to identify a +user. Read this when the task involves: + +- Storing per-browser state across requests (login, cart, "I have submitted this + form") +- Adding a credentials login flow or an OAuth provider +- Protecting routes with `requireAuth()` or stacking authorization checks +- Reading or writing `Session`, `Auth`, or other identity-related context values +- Logging in, logging out, or rotating session IDs + +For raw cookies that are not session-backed (theme, locale, dismissed-banner), +see `createCookie` in this file plus the broader `Package Map` in `SKILL.md`. + +## Sessions vs Plain Cookies + +Reach for `remix/session` when state is sensitive, must be tamper-resistant, or +represents the identity of a request: who is logged in, which form a browser +already submitted, what items are in a cart. Sessions sign or encrypt their +backing cookie with a server-held secret and give you a typed `Session` object +you can `get`, `set`, `flash`, `unset`, and `regenerateId`. + +Reach for `remix/cookie` directly when the browser is allowed to carry the value +and the server does not need session semantics. This often means preferences +(theme, locale, dismissed banner), but a signed cookie can also be fine for +small low-risk values where you truly only need one cookie-shaped fact and do +not need `Session` helpers. + +If a malicious user editing the value would be a bug, or if the value needs +server-managed lifecycle, reach for a session. + +### Quick chooser + +| Need | Best fit | Why | +| ------------------------------------------------------------------- | --------------- | -------------------------------------------------- | +| Theme, locale, dismissed banner | `remix/cookie` | Browser-controlled preference | +| Small signed hint with minimal lifecycle | `remix/cookie` | One value, no `Session` helpers needed | +| "This browser already submitted", cart, flash messages, login state | `remix/session` | Tamper-sensitive, server-managed per-browser state | +| "One real person only", ownership, durable identity | account/auth | Cookies or sessions alone do not prove personhood | + +## Session Setup + +### Create a session cookie + +```typescript +import { createCookie } from 'remix/cookie' + +let sessionSecret = process.env.SESSION_SECRET +if (!sessionSecret && process.env.NODE_ENV !== 'test') { + throw new Error('SESSION_SECRET is required') +} + +export let sessionCookie = createCookie('session', { + secrets: [sessionSecret ?? 'test-only-secret'], + httpOnly: true, + sameSite: 'Lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 2592000, // 30 days + path: '/', +}) +``` + +The cookie should always be `httpOnly`, default to `sameSite: 'Lax'`, and be +`secure` in production. Demo defaults like `'s3cr3t'` are fine in tests but +should never reach production — fail fast when the secret is missing. + +### Create session storage + +```typescript +// Filesystem storage +import { createFsSessionStorage } from 'remix/session/fs-storage' +export let sessionStorage = createFsSessionStorage('./tmp/sessions') + +// Memory storage (for tests) +import { createMemorySessionStorage } from 'remix/session/memory-storage' +export let sessionStorage = createMemorySessionStorage() +``` + +### Add session middleware + +```typescript +import { session } from 'remix/session-middleware' + +let router = createRouter({ + middleware: [ + session(sessionCookie, sessionStorage), + // ... other middleware + ], +}) +``` + +### Using sessions in handlers + +```typescript +import { Session } from 'remix/session' + +async function handler({ get }) { + let session = get(Session) + + // Read + let userId = session.get('userId') + + // Write + session.set('userId', 42) + + // Flash (read once, then cleared) + session.flash('message', 'Settings saved!') + let message = session.get('message') // returns and clears + + // Remove a key + session.unset('userId') + + // Regenerate session ID (after login/logout) + session.regenerateId(true) +} +``` + +### Sessions for non-auth state + +Sessions are not just for login. They are the right place to store any +tamper-sensitive per-browser fact: which form a browser already submitted, how +many free actions are left in a trial, which feature flags a tester opted into, +what items are in a cart. + +```typescript +async function submit({ get }) { + let session = get(Session) + if (session.get('hasSubmitted')) { + return render(, { status: 409 }) + } + + let parsed = s.parseSafe(submitSchema, get(FormData)) + if (!parsed.success) { + return render(, { status: 400 }) + } + + await saveSubmission(parsed.value) + session.set('hasSubmitted', true) + session.flash('message', 'Thanks for submitting!') + + return redirect(routes.thanks.href()) +} +``` + +Notice that there is no manual `Set-Cookie` plumbing in the action — the session +middleware handles that, and the handler returns an ordinary `Response`. +Per-browser state enforced this way is still bypassable by clearing cookies; if +the guarantee needs to survive that, you also need an account (see auth +providers below). + +## Auth Middleware + +### Basic setup + +```typescript +import { auth, createSessionAuthScheme } from 'remix/auth-middleware' +import { Session } from 'remix/session' +import { Database } from 'remix/data-table' + +export function loadAuth() { + return auth({ + schemes: [ + createSessionAuthScheme({ + read(session) { + let data = session.get('auth') + return data ?? null + }, + async verify(value, context) { + let db = context.get(Database) + return (await db.find(users, value.userId)) ?? null + }, + invalidate(session) { + session.unset('auth') + }, + }), + ], + }) +} +``` + +### Reading auth state + +```typescript +import { Auth } from 'remix/auth-middleware' + +function handler({ get }) { + let auth = get(Auth) + + if (auth.ok) { + // User is authenticated + let user = auth.identity + } +} +``` + +## Credentials Auth + +### Define a credentials provider + +```typescript +import { + createCredentialsAuthProvider, + verifyCredentials, + completeAuth, +} from 'remix/auth' +import * as s from 'remix/data-schema' +import * as f from 'remix/data-schema/form-data' + +let loginSchema = f.object({ + email: f.field(s.defaulted(s.string(), '')), + password: f.field(s.defaulted(s.string(), '')), +}) + +export let passwordProvider = createCredentialsAuthProvider({ + parse(context) { + let formData = context.get(FormData) + return s.parse(loginSchema, formData) + }, + async verify({ email, password }, context) { + let db = context.get(Database) + let user = await db.findOne(users, { where: { email } }) + if (!user || !(await verifyPassword(password, user.password_hash))) { + return null + } + return user + }, +}) +``` + +### Login action + +```typescript +import { verifyCredentials, completeAuth } from 'remix/auth' +import { redirect } from 'remix/response/redirect' + +async action(context) { + let user = await verifyCredentials(passwordProvider, context) + + if (user == null) { + let session = context.get(Session) + session.flash('error', 'Invalid email or password.') + return redirect(routes.auth.login.href()) + } + + let session = completeAuth(context) + session.set('auth', { userId: user.id }) + + return redirect(routes.home.href()) +}, +``` + +### Logout action + +```typescript +import { Session } from 'remix/session' +import { redirect } from 'remix/response/redirect' + +function logout(context) { + let session = context.get(Session) + session.unset('auth') + session.regenerateId(true) + return redirect(routes.home.href()) +} +``` + +## OAuth / External Auth + +### Create providers + +```typescript +import { + createAtmosphereAuthProvider, + createGoogleAuthProvider, + createGitHubAuthProvider, + startExternalAuth, + finishExternalAuth, + completeAuth, + refreshExternalAuth, +} from 'remix/auth' + +let googleProvider = createGoogleAuthProvider({ + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + redirectUri: new URL(routes.auth.google.callback.href(), origin), +}) + +let githubProvider = createGitHubAuthProvider({ + clientId: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + redirectUri: new URL(routes.auth.github.callback.href(), origin), +}) + +let atmosphereSessionSecret = process.env.ATMOSPHERE_SESSION_SECRET +if (!atmosphereSessionSecret && process.env.NODE_ENV !== 'test') { + throw new Error('ATMOSPHERE_SESSION_SECRET is required') +} + +let atmosphereProvider = createAtmosphereAuthProvider({ + clientId: 'https://app.example.com/oauth/client-metadata.json', + redirectUri: new URL(routes.auth.atmosphere.callback.href(), origin), + sessionSecret: atmosphereSessionSecret ?? 'test-only-secret', +}) +``` + +For Atmosphere-compatible atproto OAuth, create the provider once, call +`atmosphereProvider.prepare(handleOrDid)` before `startExternalAuth(...)`, then +pass the same module-scope provider to `finishExternalAuth(...)` and +`refreshExternalAuth(...)`. + +### OAuth controller + +```typescript +export default { + actions: { + // GET /auth/google — redirect to Google + async index(context) { + return await startExternalAuth(googleProvider, context, { + returnTo: context.url.searchParams.get('returnTo'), + }) + }, + + // GET /auth/google/callback — handle redirect back + async callback(context) { + let { result, returnTo } = await finishExternalAuth( + googleProvider, + context, + ) + + let db = context.get(Database) + let { user, authAccount } = await resolveExternalAuth(db, result) + + let session = completeAuth(context) + session.set('auth', { + userId: user.id, + loginMethod: result.provider, + authAccountId: authAccount.id, + }) + + return redirect(returnTo ?? routes.account.href()) + }, + }, +} satisfies Controller +``` + +### Refresh stored provider tokens + +Use `refreshExternalAuth(provider, tokens)` when an app has stored OAuth/OIDC +tokens and needs a fresh access token from a refresh token. Built-in OIDC +providers, X, and Atmosphere support refresh-token exchange. If the provider +does not rotate the refresh token, the refreshed bundle preserves the current +one. + +```typescript +async function refreshGoogleTokens({ get }) { + let db = get(Database) + let account = await db.findOne(authAccounts, { + where: { provider: 'google' }, + }) + if (!account) return null + + let refreshed = await refreshExternalAuth(googleProvider, account.tokens) + await db.update(authAccounts, account.id, { tokens: refreshed.tokens }) + + return refreshed.tokens +} +``` + +## Protecting Routes + +### Controller-level protection + +Apply `requireAuth()` to an entire controller subtree: + +```typescript +import { requireAuth } from 'remix/auth-middleware' + +export default { + middleware: [requireAuth()], + actions: { + index() { + /* guaranteed authenticated */ + }, + settings: settingsController, + }, +} satisfies Controller +``` + +### Stacking middleware + +Combine auth checks with role checks: + +```typescript +export default { + middleware: [requireAuth(), requireAdmin()], + actions: { + index() { + /* requires auth + admin */ + }, + }, +} satisfies Controller +``` + +### Action-level protection + +Apply middleware to a single route: + +```typescript +import { Auth, requireAuth } from 'remix/auth-middleware' + +router.get(routes.account, { + middleware: [requireAuth()], + handler(context) { + let auth = context.get(Auth) + return render() + }, +}) +``` + +### Redirect on auth failure + +```typescript +import { requireAuth } from 'remix/auth-middleware' +import { redirect } from 'remix/response/redirect' + +export function requireAuthRedirect() { + return requireAuth({ + onFailure(context) { + let returnTo = encodeURIComponent(context.url.pathname) + return redirect(routes.auth.login.href() + `?returnTo=${returnTo}`, 303) + }, + }) +} +``` diff --git a/.agents/skills/remix/references/component-model.md b/.agents/skills/remix/references/component-model.md new file mode 100644 index 00000000..873b83e0 --- /dev/null +++ b/.agents/skills/remix/references/component-model.md @@ -0,0 +1,298 @@ +# Component Model + +## What This Covers + +How a Remix Component is shaped and how its state, lifecycle, and updates +behave. Read this when the task involves: + +- Writing a component (`handle` plus render function) +- Managing component-local state, derived values, or post-render DOM work +- Using `handle.props`, `handle.update()`, `handle.queueTask()`, + `handle.signal`, `handle.id`, or `handle.context` +- Listening to global events with cleanup tied to the component lifecycle + +For host-element behavior (event handlers, styles, refs, animations), see +`mixins-styling-events.md`. For browser hydration, frames, and navigation, see +`hydration-frames-navigation.md`. + +## Phases + +A component has two phases: + +1. **Setup phase** — runs once when the component is created +2. **Render phase** — returned function runs on initial render and every update + +```tsx +import { on, type Handle } from 'remix/ui' + +function Counter(handle: Handle<{ initialCount?: number; label: string }>) { + let count = handle.props.initialCount ?? 0 + + return () => ( + + ) +} +``` + +## Props + +Components receive all JSX props through `handle.props`. The object identity is +stable for the component lifetime, and its values are updated before each +render. Put initialization inputs on normal JSX props and read them from +`handle.props`: + +```tsx +function Timer(handle: Handle<{ initialSeconds: number; paused?: boolean }>) { + let seconds = handle.props.initialSeconds + + return () =>
    Time remaining: {seconds}s
    +} + +// Usage: +``` + +Because `handle.props` is stable, destructuring `let { props } = handle` is safe +when helpers need to read current values later. Destructuring individual prop +values is only a snapshot; prefer `handle.props.name` inside callbacks and +render output when values can change. + +## State Rules + +- Keep state in setup scope as plain JavaScript variables. +- Store only what affects rendering. Derive computed values in render. +- Do not mirror input state unless you truly need controlled behavior. +- Do work in event handlers, not in render. Use the handler scope for transient + state. + +```tsx +// Derive computed values in render +function TodoList(handle: Handle) { + let todos: Array<{ text: string; completed: boolean }> = [] + + return () => { + let completedCount = todos.filter((t) => t.completed).length + return
    Completed: {completedCount}
    + } +} +``` + +## Handle API + +### `handle.update()` + +Schedules a rerender. Returns a promise that resolves with an `AbortSignal` +after the update completes. Await it when you need the updated DOM before +follow-up work: + +```tsx +on('click', async () => { + isPlaying = true + let signal = await handle.update() + // DOM is now updated, safe to focus or measure + stopButton.focus() +}) +``` + +### `handle.queueTask(task)` + +Schedules a task to run after the next update. The task receives an +`AbortSignal` that aborts when the component re-renders or is removed. Use for +post-render DOM work, reactive data loading, or hydration-sensitive setup: + +```tsx +let data = null +let requestedUrl: string | null = null + +// Post-render DOM work in an event handler +on('click', () => { + showDetails = true + handle.update() + handle.queueTask(() => { + detailsSection.scrollIntoView({ behavior: 'smooth' }) + }) +}) + +// Reactive data loading keyed by props.url +return () => { + if (requestedUrl !== handle.props.url) { + let nextUrl = handle.props.url + requestedUrl = nextUrl + data = null + + handle.queueTask(async (signal) => { + let response = await fetch(nextUrl, { signal }) + let json = await response.json() + if (signal.aborted || requestedUrl !== nextUrl) return + data = json + handle.update() + }) + } + + return
    {data ?? 'Loading...'}
    +} +``` + +Avoid creating intermediate state just to trigger `queueTask`. Do the work +directly in the handler or the queued task. + +### `handle.signal` + +An `AbortSignal` aborted when the component disconnects. Use for cleanup: + +```tsx +function Clock(handle: Handle) { + let interval = setInterval(handle.update, 1000) + handle.signal.addEventListener('abort', () => clearInterval(interval)) + + return () => {new Date().toString()} +} +``` + +### `handle.id` + +Stable identifier per component instance. Useful for `htmlFor`, `aria-owns`, +etc.: + +```tsx +function LabeledInput(handle: Handle) { + return () => ( +
    + + +
    + ) +} +``` + +### `handle.frame` and `handle.frames` + +Frame-aware behavior for client entries rendered inside frames: + +- `handle.frame.reload()` — reload the containing frame +- `handle.frame.src` — the URL of the containing frame +- `handle.frames.top` — the root frame (the whole page) +- `handle.frames.top.reload()` — reload the entire page/frame tree +- `handle.frames.get(name)` — look up a named frame; returns + `FrameHandle | undefined` + +```tsx +function RefreshButton(handle: Handle) { + return () => ( + + ) +} +``` + +### `handle.context` + +Context for ancestor/descendant communication. See the context section below. + +## Context + +Use `handle.context.set()` to provide values and `handle.context.get(Provider)` +to consume them. `set()` does **not** trigger updates — call `handle.update()` +if the tree needs to rerender. + +```tsx +function ThemeProvider( + handle: Handle<{ children?: RemixNode }, { theme: 'light' | 'dark' }>, +) { + let theme: 'light' | 'dark' = 'light' + handle.context.set({ theme }) + + return () => ( +
    + + {handle.props.children} +
    + ) +} + +function ThemedContent(handle: Handle) { + let { theme } = handle.context.get(ThemeProvider) + return () =>
    Current theme: {theme}
    +} +``` + +For granular updates without re-rendering the full subtree, use +`TypedEventTarget`: + +```tsx +import { TypedEventTarget, addEventListeners } from 'remix/ui' + +class Theme extends TypedEventTarget<{ change: Event }> { + #value: 'light' | 'dark' = 'light' + get value() { + return this.#value + } + setValue(value: 'light' | 'dark') { + this.#value = value + this.dispatchEvent(new Event('change')) + } +} + +function ThemeProvider(handle: Handle<{ children?: RemixNode }, Theme>) { + let theme = new Theme() + handle.context.set(theme) + + return () => ( +
    + + {handle.props.children} +
    + ) +} + +function ThemedContent(handle: Handle) { + let theme = handle.context.get(ThemeProvider) + addEventListeners(theme, handle.signal, { + change() { + handle.update() + }, + }) + return () =>
    Theme: {theme.value}
    +} +``` + +## Global Events + +Use `addEventListeners(target, handle.signal, listeners)` to listen to global +targets with automatic cleanup when the component disconnects: + +```tsx +import { addEventListeners, type Handle } from 'remix/ui' + +function ResizeTracker(handle: Handle) { + let width = window.innerWidth + + addEventListeners(window, handle.signal, { + resize() { + width = window.innerWidth + handle.update() + }, + }) + + return () =>
    {width}
    +} +``` diff --git a/.agents/skills/remix/references/create-mixins.md b/.agents/skills/remix/references/create-mixins.md new file mode 100644 index 00000000..e0b7d6f4 --- /dev/null +++ b/.agents/skills/remix/references/create-mixins.md @@ -0,0 +1,161 @@ +# Creating Mixins + +## What This Covers + +How to author your own reusable host-element behavior with `createMixin`. Read +this when the task involves: + +- Combining multiple low-level events or DOM hooks into one semantic mixin +- Dispatching custom DOM events from a host node +- Encapsulating imperative DOM setup that several components share +- Typing custom events on `HTMLElementEventMap` for use with `on(...)` + +For the built-in mixins most code should use, see `mixins-styling-events.md`. + +Use `createMixin` from `remix/ui` to author reusable host-element behavior. + +Most app code should use built-in core mixins (`on`, `css`, `ref`, `link`, +`attrs`) and animation mixins from `remix/ui/animation`. Create custom mixins +when combining multiple low-level events into one semantic event, or when the +pattern is reused across components. + +## Core Semantics + +1. A mixin handle is tied to one mounted host node lifecycle. +2. `insert` is the host-node availability point for imperative setup. +3. `remove` is teardown for that same lifecycle. +4. `queueTask` runs post-commit and receives `(node, signal)` for mixins. +5. Mixin render functions should stay pure; side effects belong in `insert`, + `remove`, or queued work. + +```tsx +import { createMixin } from 'remix/ui' + +let myMixin = createMixin((handle) => { + handle.addEventListener('insert', (event) => { + // event.node is the mounted host node + }) + + handle.addEventListener('remove', () => { + // Clean up listeners, timers, observers + }) + + return (props) => { + handle.queueTask((node) => { + // Post-commit work that needs the concrete host node + }) + return + } +}) +``` + +## Patterns + +### Pure prop transform + +```tsx +let withTitle = createMixin( + (handle) => (title: string, props: { title?: string }) => ( + + ), +) +``` + +### Lifecycle-managed imperative setup + +```tsx +let withFocus = createMixin((handle) => { + handle.addEventListener('insert', (event) => { + event.node.focus() + }) + return (props) => +}) +``` + +## Custom Event Mixins + +Create event mixins when you combine multiple low-level events into one semantic +custom event that is reused across components. + +1. Namespace custom event names (`myapp:*`) to avoid collisions. +2. Extend `Event` with the data consumers need. +3. Declare the event on `HTMLElementEventMap` for type safety with `on(...)`. +4. Dispatch from the host node inside the mixin. + +```tsx +import { createMixin, on } from 'remix/ui' + +export let dragReleaseType = 'myapp:drag-release' as const + +declare global { + interface HTMLElementEventMap { + [dragReleaseType]: DragReleaseEvent + } +} + +export class DragReleaseEvent extends Event { + velocityX: number + velocityY: number + constructor(init: { velocityX: number; velocityY: number }) { + super(dragReleaseType, { bubbles: true, cancelable: true }) + this.velocityX = init.velocityX + this.velocityY = init.velocityY + } +} + +export let dragRelease = createMixin((handle) => { + let node: HTMLElement | undefined + let tracking = false + let velocityX = 0 + let velocityY = 0 + let lastX = 0 + let lastY = 0 + let lastT = 0 + + handle.addEventListener('insert', (event) => { + node = event.node + }) + + return () => ( + { + if (!event.isPrimary) return + tracking = true + lastX = event.clientX + lastY = event.clientY + lastT = event.timeStamp + node?.setPointerCapture(event.pointerId) + }), + on('pointermove', (event) => { + if (!tracking) return + let dt = Math.max(1, event.timeStamp - lastT) + velocityX = (event.clientX - lastX) / dt + velocityY = (event.clientY - lastY) / dt + lastX = event.clientX + lastY = event.clientY + lastT = event.timeStamp + }), + on('pointerup', () => { + if (!tracking) return + tracking = false + node?.dispatchEvent(new DragReleaseEvent({ velocityX, velocityY })) + }), + ]} + /> + ) +}) +``` + +Consume it: + +```tsx +
    { + console.log('velocity:', event.velocityX, event.velocityY) + }), + ]} +/> +``` diff --git a/.agents/skills/remix/references/data-and-validation.md b/.agents/skills/remix/references/data-and-validation.md new file mode 100644 index 00000000..2cdb11a5 --- /dev/null +++ b/.agents/skills/remix/references/data-and-validation.md @@ -0,0 +1,393 @@ +# Data Access and Validation + +## What This Covers + +How input becomes a value the app trusts, and how that value reaches storage. +Read this when the task involves: + +- Defining database tables, columns, relations, and migrations +- Querying or mutating persisted data with `Database` +- Parsing and validating user input from forms, query strings, or external + payloads +- Choosing between schema-level checks, table validation hooks, and + migration-level constraints + +For where validation runs in the request lifecycle, see +`routing-and-controllers.md`. For session or identity-bound writes, see +`auth-and-sessions.md`. + +## Table Definitions (`remix/data-table`) + +Define tables with typed columns, relations, and optional validation hooks: + +```typescript +import { belongsTo, column as c, hasMany, table } from 'remix/data-table' +import type { TableRow, TableRowWith } from 'remix/data-table' + +export const books = table({ + name: 'books', + columns: { + id: c.integer().primaryKey().autoIncrement(), + slug: c.text().notNull().unique(), + title: c.text().notNull(), + author: c.text().notNull(), + price: c.decimal(10, 2).notNull(), + genre: c.text().notNull(), + in_stock: c.boolean(), + }, +}) + +export const orders = table({ + name: 'orders', + columns: { + id: c.integer().primaryKey().autoIncrement(), + user_id: c.integer().notNull().references('users', 'id'), + total: c.decimal(10, 2).notNull(), + created_at: c.integer().notNull(), + }, + relations: { + user: belongsTo('users', 'user_id'), + items: hasMany('order_items', 'order_id'), + }, +}) + +export type Book = TableRow +export type Order = TableRow +export type OrderWithItems = TableRowWith +``` + +### Column types + +| Method | SQL type | +| ----------------------------- | ------------------ | +| `c.integer()` | INTEGER | +| `c.text()` | TEXT | +| `c.boolean()` | BOOLEAN | +| `c.decimal(precision, scale)` | DECIMAL | +| `c.enum([...])` | TEXT (string enum) | +| `c.uuid()` | UUID / TEXT | +| `c.varchar(length)` | VARCHAR | + +Column modifiers: `.primaryKey()`, `.autoIncrement()`, `.notNull()`, +`.unique()`, `.references(table, column, fkName?)`, `.onDelete(action)`, +`.default(value)`. + +Composite primary keys go on the table option, not the column: +`primaryKey: ['order_id', 'book_id']`. + +### Schema vs migrations + +Column modifiers describe SQL constraints — the source of truth for them is your +**migration** files, where they generate the actual DDL. Runtime `table(...)` +definitions in `app/data/schema.ts` can use the same modifiers, or they can stay +minimal (`c.integer()`, `c.text()`, ...) since the runtime only needs the column +shape and validation hooks. Two valid patterns: + +- **Modifiers in both** — schema and migrations stay in sync visually; useful + when you want schema-level docs. +- **Bare columns in schema, full modifiers in migrations** — schema describes + what the app reads and writes; migrations own the DDL and constraints. + +Pick one and apply it consistently across the app. + +### Table validation hooks + +Tables can define `validate`, `beforeWrite`, and `afterRead` hooks: + +```typescript +export const books = table({ + name: 'books', + columns: { + /* ... */ + }, + validate({ operation, value }) { + let issues = [] + if (operation === 'create' && !value.slug) { + issues.push({ message: 'Slug is required.', path: ['slug'] }) + } + return issues.length > 0 ? { issues } : { value } + }, +}) +``` + +## Database Setup + +Create a database with an adapter and expose it via middleware: + +```typescript +import BetterSqlite3 from 'better-sqlite3' +import { createDatabase, Database } from 'remix/data-table' +import { createSqliteDatabaseAdapter } from 'remix/data-table-sqlite' + +let sqlite = new BetterSqlite3('./db/app.db') +sqlite.pragma('foreign_keys = ON') +let adapter = createSqliteDatabaseAdapter(sqlite) +export let db = createDatabase(adapter) +``` + +`createSqliteDatabaseAdapter` accepts synchronous SQLite clients with a shared +`prepare`/`exec` surface, including Node's `node:sqlite`, Bun's `bun:sqlite`, +and compatible clients. Use whichever client fits the runtime instead of +assuming `better-sqlite3` is required. + +### Database middleware + +```typescript +import type { Middleware } from 'remix/fetch-router' +import { Database } from 'remix/data-table' + +export function loadDatabase(): Middleware { + return async (context, next) => { + context.set(Database, db) + return next() + } +} +``` + +### Querying + +```typescript +let db = get(Database) + +// Find by primary key +let book = await db.find(books, id) + +// Find one by condition +let user = await db.findOne(users, { where: { email } }) + +// Find many with ordering +let allBooks = await db.findMany(books, { orderBy: ['id', 'asc'] }) + +// Count +let total = await db.count(orders, { where: { user_id: userId } }) + +// Query builder +let genres = await db + .query(books) + .select('genre') + .distinct() + .orderBy('genre', 'asc') + .all() + +// Create +let newBook = await db.create(books, { + slug: 'new-book', + title: 'New Book' /* ... */, +}) + +// Update +await db.update(books, bookId, { title: 'Updated Title' }) + +// Delete +await db.delete(books, bookId) +``` + +### Operators + +```typescript +import { inList } from 'remix/data-table/operators' + +let featured = await db.findMany(books, { + where: inList('slug', ['book-a', 'book-b', 'book-c']), +}) +``` + +## Migrations + +### Writing migrations + +```typescript +import { column as c, createMigration } from 'remix/data-table/migrations' +import { table } from 'remix/data-table' + +export default createMigration({ + async up({ schema }) { + let users = table({ + name: 'users', + columns: { + id: c.integer().primaryKey().autoIncrement(), + email: c.text().notNull().unique(), + name: c.text().notNull(), + }, + }) + await schema.createTable(users) + await schema.createIndex(users, 'email', { + name: 'users_email_idx', + unique: true, + }) + }, + + async down({ schema }) { + await schema.dropTable('users') + }, +}) +``` + +Migrations can also import table definitions from the app schema to avoid +duplication: + +```typescript +import { createMigration } from 'remix/data-table/migrations' +import { users, authAccounts } from '../../app/data/schema.ts' + +export default createMigration({ + async up({ schema }) { + await schema.createTable(users) + await schema.createTable(authAccounts) + }, +}) +``` + +### Running migrations + +```typescript +import { createMigrationRunner } from 'remix/data-table/migrations' +import { loadMigrations } from 'remix/data-table/migrations/node' + +let migrations = await loadMigrations('./db/migrations') +let runner = createMigrationRunner(adapter, migrations) +await runner.up() +``` + +### Migration file naming + +Name migration files with a timestamp prefix: `20260228090000_create_users.ts`. +Place them in `db/migrations/`. + +## Input Validation (`remix/data-schema`) + +Use `data-schema` to validate user input (forms, query params, API payloads). +This is separate from table-level `validate` hooks which run at persistence. + +### Schema builders + +```typescript +import * as s from 'remix/data-schema' +import { email, minLength, maxLength } from 'remix/data-schema/checks' + +let userSchema = s.object({ + name: s.string().pipe(minLength(1)), + email: s.string().pipe(email()), + age: s.optional(s.number()), +}) + +let result = s.parse(userSchema, data) +``` + +### FormData validation + +Use `remix/data-schema/form-data` to validate `FormData` directly: + +```typescript +import * as s from 'remix/data-schema' +import * as f from 'remix/data-schema/form-data' +import { email, minLength } from 'remix/data-schema/checks' + +let signupSchema = f.object({ + name: f.field(s.string().pipe(minLength(1))), + email: f.field(s.string().pipe(email())), + password: f.field(s.string().pipe(minLength(8))), +}) + +// In a controller action: +let formData = get(FormData) +let { name, email, password } = s.parse(signupSchema, formData) +``` + +### Reading FormData: middleware vs `request.formData()` + +There are two ways to get a `FormData` value inside an action. + +The recommended way: register `formData()` middleware in the root stack and read +with `get(FormData)`. The body is parsed once per request, and the typed +`FormData` value flows through the context system. This also lets +`methodOverride()` and CSRF middleware work uniformly. + +```typescript +import { formData } from 'remix/form-data-middleware' + +let router = createRouter({ + middleware: [, /* ... */ formData() /* ... */], +}) + +// In an action: +let parsed = s.parseSafe(signupSchema, get(FormData)) +``` + +The fallback: `await request.formData()` directly. This works without middleware +and is fine for small one-off cases, but it bypasses the context system, runs +once per call site, and doesn't compose with middleware that depends on parsed +form fields. + +### Safe parsing + +`s.parse` throws on invalid input. `s.parseSafe` returns a tagged result and is +usually what an action wants, since validation failure is an expected outcome +(re-render the form with errors) rather than an exception: + +```typescript +let result = s.parseSafe(signupSchema, get(FormData)) +if (!result.success) { + return render(, { status: 400 }) +} +let { name, email, password } = result.value +``` + +Returning a `Response` for validation failures keeps the route contract honest: +the same action returns 200 on success, 400 with errors on bad input, no +out-of-band exception flow. + +### Transforming validated output + +Use `.transform(...)` when a schema should validate one shape but return another +value or output type. Transforms run after validation and compose with +`.pipe(...)` and `.refine(...)`: + +```typescript +import * as coerce from 'remix/data-schema/coerce' + +let slugSchema = s + .string() + .pipe(minLength(1)) + .transform((value) => value.trim().toLowerCase().replace(/\s+/g, '-')) + +let pageSchema = f.object({ + page: f.field(s.defaulted(coerce.coerceNumber(), 1).refine(Number.isInteger)), + q: f.field(s.defaulted(s.string(), '').transform((value) => value.trim())), +}) + +let { page, q } = s.parse(pageSchema, formData) +``` + +### Anti-patterns + +Avoid these shapes when reading and validating input: + +- **Raw `formData.get('name')` plus an `if (typeof name !== 'string')` guard**, + then a thrown custom error. This reinvents what `data-schema` already does, + loses the typed result, and pushes error translation into a `try/catch` + instead of a return value. +- **Letting route-local domain errors leak out of the action.** Translate + expected outcomes (bad input, missing record, duplicate entry) into the + `Response` the route means to return instead of throwing a custom `Error` + subclass with a `status` field and catching it later. +- **Trusting `params`, query strings, or external payloads without a schema.** + Anything that crosses a trust boundary should be parsed before it reaches + business logic. + +### Common patterns + +```typescript +// Optional with default +let limitSchema = f.field(s.defaulted(s.string(), '10')) + +// Union types +let methodSchema = s.union([ + s.literal('credentials'), + s.literal('google'), + s.literal('github'), +]) + +// Refinements +let idSchema = s.number().refine(Number.isInteger, 'Expected an integer') +``` diff --git a/.agents/skills/remix/references/hydration-frames-navigation.md b/.agents/skills/remix/references/hydration-frames-navigation.md new file mode 100644 index 00000000..a2ffcb37 --- /dev/null +++ b/.agents/skills/remix/references/hydration-frames-navigation.md @@ -0,0 +1,311 @@ +# Hydration, Frames, and Navigation + +## What This Covers + +How server-rendered UI becomes interactive in the browser, and how the page +updates without a full navigation. Read this when the task involves: + +- Marking a component for client-side hydration with `clientEntry` +- Booting the client runtime with `run` +- Streaming server content into a region of the page with `` and + reloading those regions +- Triggering Navigation API transitions with `navigate(...)` or `link(...)` +- Server rendering with `renderToStream` or `renderToString` +- Managing the document `` + +For component-local state and updates, see `component-model.md`. For +host-element behavior and events, see `mixins-styling-events.md`. + +## Server First, Then Hydrate + +Make the server route correct before adding `clientEntry(...)`. A POST should +already do the right thing on its own — return HTML, a redirect, or an error +response — and a GET should already render the page the user expects. +`clientEntry` exists to layer interactivity on top of UI that already works +without it. + +When server state changes after a mutation, prefer reloading a `` when +the UI region already maps cleanly to a server-rendered route. Frames re-fetch +the same route, so the rendering logic stays in one place and the client does +not need a parallel "state" API. + +```tsx +on('submit', async (event, signal) => { + event.preventDefault() + await fetch(routes.cart.add.href(), { + method: 'POST', + body: new FormData(event.currentTarget), + signal, + }) + if (signal.aborted) return + await handle.frames.get('cart-summary')?.reload() +}) +``` + +Use polling or a small JSON state endpoint when the data changes outside this +page, or when a tiny shared widget would be heavier to model as a frame. Pick +the lightest sync mechanism that preserves clear ownership of rendering logic. + +## Client Entries + +Use `clientEntry` to mark a component for client-side hydration. In +source-served apps, prefer the source module's `import.meta.url` as the entry ID +and let server rendering map it to the public asset URL: + +```tsx +import { clientEntry, on, type Handle } from 'remix/ui' + +export const Counter = clientEntry( + import.meta.url, + function Counter(handle: Handle<{ initialCount: number; label: string }>) { + let count = handle.props.initialCount + + return () => ( +
    + + {handle.props.label}: {count} + + +
    + ) + }, +) +``` + +On the server, provide `resolveClientEntry` to `renderToStream(...)` so source +file URLs become browser-loadable asset URLs. Keep this resolution in the render +helper so component modules do not hard-code deployment-specific asset paths: + +```tsx +let stream = renderToStream(, { + async resolveClientEntry(entryId, component) { + let exportName = entryId.split('#')[1] || component.name + if (!exportName) { + throw new Error(`Unable to resolve client entry export for ${entryId}`) + } + + return { + href: await assetServer.getHref(entryId), + exportName, + } + }, +}) +``` + +If the module export name differs from the component function name, include +`#ExportName` in the entry ID or return the exact export name from +`resolveClientEntry`. A render helper that only supports source-owned entries +can also fail fast when `entryId` is not a `file://` URL. + +On the server, `clientEntry` components render like any other component. The +server wraps their output in comment markers and serializes 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 = '
    Title
    ' -let body = '
    Content
    ' -let footer = '
    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 0b6f0e79..00000000 --- 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 14b0110d..00000000 --- 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 62e183d3..00000000 --- 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 - `
    ` and handling its submit action at the same URL. -- [`resources` (and `resource`)](./routing-resources.md) - creates a route map - with a set of resource-based routes, useful when defining RESTful API routes - or - [Rails-style resource-based routes](https://guides.rubyonrails.org/routing.html#resource-routing-the-rails-default). - -### Declaring form routes - -Continuing with the contact page example, let's use the `form` shorthand to make -the route map a little less verbose. - -A `form()` route map contains two routes: `index` and `action`. The `index` -route is a `GET` route that shows the form, and the `action` route is a `POST` -route that handles the form submission. - -```tsx -import { createRouter } from 'remix/fetch-router' -import { form, route } from 'remix/fetch-router/routes' -import { createHtmlResponse } from 'remix/response/html' -import { html } from 'remix/html-template' - -let routes = route({ - home: '/', - contact: form('contact'), -}) - -type Routes = typeof routes -// { -// home: Route<'ANY', '/'> -// contact: { -// index: Route<'GET', '/contact'> - Shows the form -// action: Route<'POST', '/contact'> - Handles the form submission -// }, -// } - -let router = createRouter() - -router.map(routes, { - home() { - return createHtmlResponse(` - - -

    Home

    - - - - `) - }, - contact: { - // GET /contact - shows the form - index() { - return createHtmlResponse(` - - -

    Contact Us

    - - - - -
    - - - `) - }, - // 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 150b7e52..00000000 --- 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 16607c5a..00000000 --- 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 14fc7b8e..00000000 --- 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!

    -
    -

    You said: ${message}

    -
    - - - - ` - - 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 ed64e787..00000000 --- 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.md b/docs/agents/remix/file-storage.md deleted file mode 100644 index 4cf3af13..00000000 --- a/docs/agents/remix/file-storage.md +++ /dev/null @@ -1,83 +0,0 @@ -# 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. - -## 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.) -- **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 -``` - -## Usage - -### File System - -```ts -import { createFsFileStorage } from 'remix/file-storage/fs' - -let storage = createFsFileStorage('./user/files') - -let file = new File(['hello world'], 'hello.txt', { type: 'text/plain' }) -let key = 'hello-key' - -// Put the file in storage. -await storage.set(key, file) - -// Then, sometime later... -let fileFromStorage = await storage.get(key) -// All of the original file's metadata is intact -fileFromStorage.name // 'hello.txt' -fileFromStorage.type // 'text/plain' - -// To remove from storage -await storage.remove(key) -``` - -## Related Packages - -- [`file-storage-s3`](https://github.com/remix-run/remix/tree/main/packages/file-storage-s3) - - S3 backend for `file-storage` -- [`form-data-parser`](https://github.com/remix-run/remix/tree/main/packages/form-data-parser) - - Pairs well with this library for storing `FileUpload` objects received in - `multipart/form-data` requests -- [`lazy-file`](https://github.com/remix-run/remix/tree/main/packages/lazy-file) - - The streaming `File` implementation used internally to stream files from - storage - -## 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 df789f40..00000000 --- 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-parser.md b/docs/agents/remix/form-data-parser.md deleted file mode 100644 index f80095df..00000000 --- a/docs/agents/remix/form-data-parser.md +++ /dev/null @@ -1,183 +0,0 @@ -# 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 -streaming directly to disk or cloud storage services like -[AWS S3](https://aws.amazon.com/s3/) or -[Cloudflare R2](https://www.cloudflare.com/developer-platform/r2/), preventing -server crashes from memory exhaustion. - -## Features - -- **Drop-in replacement** for `request.formData()` with streaming file upload - support -- **Minimal buffering** - processes file upload streams with minimal memory - footprint -- **Standards-based** - built on the - [web Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API) - and [File API](https://developer.mozilla.org/en-US/docs/Web/API/File) -- **Smart fallback** - automatically uses native `request.formData()` for - non-`multipart/form-data` requests -- **Storage agnostic** - works with any storage backend (local disk, S3, R2, - etc.) - -## Why You Need This - -The native -[`request.formData()` method](https://developer.mozilla.org/en-US/docs/Web/API/Request/formData) -has a few major flaws in server environments: - -- It buffers all file uploads in memory -- It does not provide fine-grained control over file upload handling -- It does not prevent DoS attacks from malicious requests - -In normal usage, this makes it difficult to process requests with large file -uploads because they can exhaust your server's RAM and crash the application. - -For attackers, this creates an attack vector where malicious actors can -overwhelm your server's memory by sending large payloads with many files. - -`form-data-parser` solves this by handling file uploads as they arrive in the -request body stream, allowing you to safely store files and use either a) the -`File` directly or b) a unique identifier for that file in the returned -`FormData` object. - -## Installation - -Install from [npm](https://www.npmjs.com/): - -```sh -bun add @remix-run/form-data-parser -``` - -## Usage - -The `parseFormData` interface allows you to define an "upload handler" function -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' - -// Define how to handle incoming file uploads -async function uploadHandler(fileUpload: FileUpload) { - // Is this file upload from the field? - if (fileUpload.fieldName === 'user-avatar') { - let filename = `/uploads/user-${user.id}-avatar.bin` - - // Store the file safely on disk - await fsp.writeFile(filename, fileUpload.bytes) - - // Return the file name to use in the FormData object so we don't - // keep the file contents around in memory. - return filename - } - - // Ignore unrecognized fields -} - -// Handle form submissions with file uploads -async function requestHandler(request: Request) { - // Parse the form data from the request.body stream, passing any files - // through your upload handler as they are parsed from the stream - let formData = await parseFormData(request, uploadHandler) - - let avatarFilename = formData.get('user-avatar') - - if (avatarFilename != null) { - console.log(`User avatar uploaded to ${avatarFilename}`) - } else { - console.log(`No user avatar file was uploaded`) - } -} -``` - -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. - -```ts -import { - MaxFilesExceededError, - MaxFileSizeExceededError, -} from '@remix-run/form-data-parser' - -const oneKb = 1024 -const oneMb = 1024 * oneKb - -try { - let formData = await parseFormData(request, { - maxFiles: 5, - maxFileSize: 10 * oneMb, - }) -} catch (error) { - if (error instanceof MaxFilesExceededError) { - console.error(`Request may not contain more than 5 files`) - } else if (error instanceof MaxFileSizeExceededError) { - console.error(`Files may not be larger than 10 MiB`) - } else { - console.error(`An unknown error occurred:`, error) - } -} -``` - -If you're looking for a more flexible storage solution for `File` objects that -are uploaded, this library pairs really well with -[the `file-storage` library](https://github.com/remix-run/remix/tree/main/packages/file-storage) -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' - -// Set up storage for uploaded files -const fileStorage = new LocalFileStorage('/uploads/user-avatars') - -// Define how to handle incoming file uploads -async function uploadHandler(fileUpload: FileUpload) { - // Is this file upload from the field? - if (fileUpload.fieldName === 'user-avatar') { - let storageKey = `user-${user.id}-avatar` - - // Put the file in storage - await fileStorage.set(storageKey, fileUpload) - - // Return a lazy File object that can access the stored file when needed - return fileStorage.get(storageKey) - } - - // Ignore unrecognized fields -} -``` - -## Demos - -The -[`demos` directory](https://github.com/remix-run/remix/tree/main/packages/form-data-parser/demos) -contains working demos: - -- [`demos/node`](https://github.com/remix-run/remix/tree/main/packages/form-data-parser/demos/node) - - using form-data-parser with file-storage in Node.js - -## Related Packages - -- [`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 -- [`multipart-parser`](https://github.com/remix-run/remix/tree/main/packages/multipart-parser) - - The parser used internally for parsing `multipart/form-data` HTTP messages - -## 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.md deleted file mode 100644 index 8e5c38b9..00000000 --- a/docs/agents/remix/fs.md +++ /dev/null @@ -1,83 +0,0 @@ -# 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 -[`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. - -## Features - -- **Web Standards** - Uses - [`LazyFile`](https://github.com/remix-run/remix/tree/main/packages/lazy-file) - which matches the native - [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) API and - provides `.stream()`, `.toFile()`, and `.toBlob()` for converting to native - types. -- **Seamless Node.js Compat** - Works seamlessly with Node.js file descriptors - and handles - -## Installation - -Install from [npm](https://www.npmjs.com/): - -```sh -bun add @remix-run/fs -``` - -## Usage - -### Opening Lazy Files - -```ts -import { openLazyFile } from '@remix-run/fs' - -// Open a file from the filesystem -let lazyFile = openLazyFile('./path/to/file.json') - -// The file is lazy - no data is read until you call lazyFile.text(), lazyFile.bytes(), etc. -let json = JSON.parse(await lazyFile.text()) - -// You can override file metadata -let customLazyFile = openLazyFile('./image.jpg', { - name: 'custom-name.jpg', - type: 'image/jpeg', - lastModified: Date.now(), -}) -``` - -### Writing Files - -```ts -import { openLazyFile, writeFile } from '@remix-run/fs' - -// Read a file and write it elsewhere -let lazyFile = openLazyFile('./source.txt') -await writeFile('./destination.txt', lazyFile) - -// Write to an open file handle -import * as fsp from 'node:fs/promises' -let handle = await fsp.open('./destination.txt', 'w') -await writeFile(handle, lazyFile) -await handle.close() -``` - -## Related Packages - -- [`lazy-file`](https://github.com/remix-run/remix/tree/main/packages/lazy-file) - - Lazy, streaming `Blob`/`File` implementation -- [`file-storage`](https://github.com/remix-run/remix/tree/main/packages/file-storage) - - Storage abstraction for files - -## 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/accept-headers.md b/docs/agents/remix/headers/accept-headers.md deleted file mode 100644 index bf2b9a05..00000000 --- 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 1dd4cbfa..00000000 --- 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 f007953a..00000000 --- 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 65e4fa38..00000000 --- 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 ad5586d8..00000000 --- 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 eeea3698..00000000 --- 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.md deleted file mode 100644 index b9cd9f77..00000000 --- a/docs/agents/remix/html-template.md +++ /dev/null @@ -1,127 +0,0 @@ -# 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. - -## Features - -- **Automatic HTML escaping** - All interpolated values are escaped by default -- **Explicit raw HTML** - Use `html.raw` when you need unescaped HTML from - trusted sources -- **Composable** - SafeHtml values can be nested without double-escaping -- **Type-safe** - Full TypeScript support with branded types -- **Zero dependencies** - Lightweight and self-contained -- **Runtime agnostic** - Works in Node.js, Bun, Deno, browsers, and edge - runtimes - -## Installation - -Install from [npm](https://www.npmjs.com/): - -```sh -bun add @remix-run/html-template -``` - -## Usage - -```ts -import { html } from '@remix-run/html-template' - -let userInput = '' -let greeting = html`

    Hello ${userInput}!

    ` - -console.log(String(greeting)) -// Output:

    Hello <script>alert("XSS")</script>!

    -``` - -By default, all interpolated values are automatically escaped to prevent XSS -attacks. - -If you have trusted HTML that should not be escaped, use `html.raw`: - -```ts -import { html } from '@remix-run/html-template' - -let trustedIcon = '...' -let button = html.raw`` - -console.log(String(button)) -// => -``` - -**Warning**: Only use `html.raw` with content you trust. Never use it with user -input. - -### Composing HTML Fragments - -SafeHtml values can be nested without double-escaping: - -```ts -import { html } from '@remix-run/html-template' - -let title = html`

    My Title

    ` -let content = html`

    Some content with ${userInput}

    ` - -let page = html` - - - - ${title} ${content} - - -` -``` - -### Working with Arrays - -You can interpolate arrays of values, which will be flattened and joined: - -```ts -import { html } from '@remix-run/html-template' - -let items = ['Apple', 'Banana', 'Cherry'] -let list = html` -
      - ${items.map((item) => html`
    • ${item}
    • `)} -
    -` -``` - -### Conditional Rendering - -Use `null` or `undefined` to render nothing: - -```ts -import { html } from '@remix-run/html-template' - -let showError = false -let errorMessage = 'Something went wrong' -let page = html`
    - ${showError ? html`
    ${errorMessage}
    ` : null} -
    ` -``` - -## Related Packages - -- [`@remix-run/fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - - HTTP router that works great with html-template - -## 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 deleted file mode 100644 index 4e2588d1..00000000 --- a/docs/agents/remix/index.md +++ /dev/null @@ -1,183 +0,0 @@ -# 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. 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 10c11164..00000000 --- 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 c47f8546..00000000 --- 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 1d5d08ba..00000000 --- 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 970cdfb1..00000000 --- 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.md deleted file mode 100644 index d4162c32..00000000 --- a/docs/agents/remix/lazy-file.md +++ /dev/null @@ -1,146 +0,0 @@ -# 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. - -It allows you to easily create -[Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob)-like and -[File](https://developer.mozilla.org/en-US/docs/Web/API/File)-like objects that -defer reading their contents until needed, which is ideal for situations where a -file's contents do not fit in memory all at once. When file contents are read, -they are streamed to avoid buffering. - -## Features - -- **Deferred Loading** - Blob/file contents loaded on demand to minimize memory - usage -- **Familiar Interface** - `LazyBlob` and `LazyFile` implement the same - interface as native `Blob` and `File` -- **Easy Conversion** - Convert to native `ReadableStream` with `.stream()`, or - to native `Blob`/`File` with `.toBlob()` and `.toFile()` -- **Standard Constructors** - Accepts all the same content types as the original - [`Blob()`](https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob) and - [`File()`](https://developer.mozilla.org/en-US/docs/Web/API/File/File) - constructors -- **Slice Support** - Supports - [`Blob.slice()`](https://developer.mozilla.org/en-US/docs/Web/API/Blob/slice), - even on streaming content - -## The Problem - -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 -don't want to buffer file contents. In particular, -[`the File() constructor`](https://developer.mozilla.org/en-US/docs/Web/API/File/File) -requires the contents of a file to be supplied up front when the object is first -created, like this: - -```ts -let file = new File(['hello world'], 'hello.txt', { type: 'text/plain' }) -``` - -A `LazyFile` improves this model by accepting an additional content type in its -constructor: `LazyContent`. - -```ts -let lazyContent: LazyContent = { - /* See below for usage */ -} -let lazyFile = new LazyFile(lazyContent, 'hello.txt', { type: 'text/plain' }) -``` - -All other `File` functionality works as you'd expect. - -## Installation - -Install from [npm](https://www.npmjs.com/): - -```sh -bun add @remix-run/lazy-file -``` - -## Usage - -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' - -let content: LazyContent = { - // The total length of this file in bytes. - byteLength: 100000, - // A function that provides a stream of data for the file contents, - // beginning at the `start` index and ending at `end`. - stream(start, end) { - // ... read the file contents from somewhere and return a ReadableStream - return new ReadableStream({ - start(controller) { - controller.enqueue('X'.repeat(100000).slice(start, end)) - controller.close() - }, - }) - }, -} - -let lazyFile = new LazyFile(content, 'example.txt', { type: 'text/plain' }) -await lazyFile.arrayBuffer() // ArrayBuffer of the file's content -lazyFile.name // "example.txt" -lazyFile.type // "text/plain" -``` - -All file contents are read on-demand and nothing is ever buffered unless you -explicitly call `.toFile()` or `.toBlob()`. - -### Streaming Content - -Use `.stream()` to get a `ReadableStream` for `Response` and other streaming -APIs: - -```ts -import { openLazyFile } from '@remix-run/fs' - -let lazyFile = openLazyFile('./large-video.mp4') - -let response = new Response(lazyFile.stream(), { - headers: { - 'Content-Type': lazyFile.type, - 'Content-Length': String(lazyFile.size), - }, -}) -``` - -### Converting to Native File/Blob - -For non-streaming APIs that require a complete `File` or `Blob` (e.g. -`FormData`), use `.toFile()` or `.toBlob()`. - -```ts -let lazyFile = openLazyFile('./document.pdf') -let realFile = await lazyFile.toFile() - -let formData = new FormData() -formData.append('document', realFile) -``` - -> **Note:** `.toFile()` and `.toBlob()` read the entire file into memory. Only -> use these for non-streaming APIs that require a complete `File` or `Blob` -> (e.g. `FormData`). Always prefer `.stream()` if possible. - -## Related Packages - -- [`fs`](https://github.com/remix-run/remix/tree/main/packages/fs) - Filesystem - utilities for reading and writing files using the Web `File` API -- [`file-storage`](https://github.com/remix-run/remix/tree/main/packages/file-storage) - - Storage abstraction for files on disk or in memory - -## 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.md deleted file mode 100644 index 04555554..00000000 --- a/docs/agents/remix/logger-middleware.md +++ /dev/null @@ -1,110 +0,0 @@ -# logger-middleware - -Source: https://github.com/remix-run/remix/tree/main/packages/logger-middleware - -## README - -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. - -## Installation - -```sh -bun add @remix-run/logger-middleware -``` - -## Usage - -```ts -import { createRouter } from '@remix-run/fetch-router' -import { logger } from '@remix-run/logger-middleware' - -let router = createRouter({ - middleware: [logger()], -}) - -// Logs: [19/Nov/2025:14:32:10 -0800] GET /users/123 200 1234 -``` - -### Custom Format - -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) -- `%dateISO` - Date and time in ISO format -- `%duration` - Request duration in milliseconds -- `%contentLength` - Response Content-Length header -- `%contentType` - Response Content-Type header -- `%host` - Request URL host -- `%hostname` - Request URL hostname -- `%method` - Request method -- `%path` - Request pathname + search -- `%pathname` - Request pathname -- `%port` - Request port -- `%query` - Request query string (search) -- `%referer` - Request Referer header -- `%search` - Request search string -- `%status` - Response status code -- `%statusText` - Response status text -- `%url` - Full request URL -- `%userAgent` - Request User-Agent header - -```ts -let router = createRouter({ - middleware: [ - logger({ - format: '%method %path - %status (%duration ms)', - }), - ], -}) -// Logs: GET /users/123 - 200 (42 ms) -``` - -For Apache-style combined log format, you can use the following format: - -```ts -let router = createRouter({ - middleware: [ - logger({ - format: - '%host - - [%date] "%method %path" %status %contentLength "%referer" "%userAgent"', - }), - ], -}) -``` - -### Custom Logger - -You can use a custom logger to write logs to a file or other stream. - -```ts -import { createWriteStream } from 'node:fs' - -let logStream = createWriteStream('access.log', { flags: 'a' }) - -let router = createRouter({ - middleware: [ - logger({ - log(message) { - logStream.write(message + '\n') - }, - }), - ], -}) -``` - -## Related Packages - -- [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - - Router for the web Fetch API - -## 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.md deleted file mode 100644 index 86fed5fd..00000000 --- a/docs/agents/remix/method-override-middleware.md +++ /dev/null @@ -1,85 +0,0 @@ -# method-override-middleware - -Source: -https://github.com/remix-run/remix/tree/main/packages/method-override-middleware - -## README - -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. - -## Installation - -```sh -bun add @remix-run/method-override-middleware -``` - -## Usage - -This middleware runs after -[the `formData` middleware](https://github.com/remix-run/remix/tree/main/packages/form-data-middleware) -and updates the request context's `context.method` with the value of the method -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' - -let router = createRouter({ - // methodOverride must come AFTER formData middleware - middleware: [formData(), methodOverride()], -}) - -router.delete('/users/:id', async (context) => { - let userId = context.params.id - // Delete user logic... - return new Response('User deleted') -}) -``` - -In your HTML form: - -```html -
    - - -
    -``` - -### Custom Field Name - -You can customize the name of the method override field by passing a `fieldName` -option to the `methodOverride()` middleware. - -```ts -let router = createRouter({ - middleware: [formData(), methodOverride({ fieldName: '__method__' })], -}) -``` - -```html -
    - - -
    -``` - -## Related Packages - -- [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - - Router for the web Fetch API -- [`form-data-middleware`](https://github.com/remix-run/remix/tree/main/packages/form-data-middleware) - - Required for parsing form data - -## 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.md deleted file mode 100644 index 81ce6fd6..00000000 --- a/docs/agents/remix/mime.md +++ /dev/null @@ -1,120 +0,0 @@ -# @remix-run/mime - -Source: https://github.com/remix-run/remix/tree/main/packages/mime - -## README - -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). - -## Installation - -```bash -bun add @remix-run/mime -``` - -## Usage - -### `detectMimeType(extension)` - -Detects the MIME type for a given file extension or filename. - -```ts -import { detectMimeType } from '@remix-run/mime' - -detectMimeType('txt') // 'text/plain' -detectMimeType('.txt') // 'text/plain' -detectMimeType('file.txt') // 'text/plain' -detectMimeType('path/to/file.txt') // 'text/plain' -detectMimeType('unknown') // undefined -``` - -### `detectContentType(extension)` - -Detects the Content-Type header value for a given file extension or filename, -including `charset` for text-based types. See -[`mimeTypeToContentType`](#mimetypetocontenttypemimetype) for charset logic. - -```ts -import { detectContentType } from '@remix-run/mime' - -detectContentType('css') // 'text/css; charset=utf-8' -detectContentType('.json') // 'application/json; charset=utf-8' -detectContentType('image.png') // 'image/png' -detectContentType('path/to/file.unknown') // undefined -``` - -### `isCompressibleMimeType(mimeType)` - -Checks if a MIME type is known to be compressible. - -```ts -import { isCompressibleMimeType } from '@remix-run/mime' - -isCompressibleMimeType('text/html') // true -isCompressibleMimeType('application/json') // true -isCompressibleMimeType('image/png') // false -isCompressibleMimeType('video/mp4') // false -``` - -For convenience, the function also accepts a full Content-Type header value: - -```ts -import { isCompressibleMimeType } from '@remix-run/mime' - -isCompressibleMimeType('text/html; charset=utf-8') // true -isCompressibleMimeType('application/json; charset=utf-8') // true -isCompressibleMimeType('image/png; charset=utf-8') // false -isCompressibleMimeType('video/mp4; charset=utf-8') // false -``` - -### `mimeTypeToContentType(mimeType)` - -Converts a MIME type to a Content-Type header value, adding `; charset=utf-8` to -text-based MIME types: `text/*` (except `text/xml` which has built-in encoding -declarations), `application/json`, `application/javascript`, and all `+json` -suffixed types. All other types are returned unchanged. - -```ts -import { mimeTypeToContentType } from '@remix-run/mime' - -mimeTypeToContentType('text/css') // 'text/css; charset=utf-8' -mimeTypeToContentType('application/json') // 'application/json; charset=utf-8' -mimeTypeToContentType('application/ld+json') // 'application/ld+json; charset=utf-8' -mimeTypeToContentType('image/png') // 'image/png' -``` - -### `defineMimeType(definition)` - -Registers or overrides a MIME type for one or more file extensions. - -```ts -import { defineMimeType } from '@remix-run/mime' - -defineMimeType({ - extensions: ['myformat'], - mimeType: 'application/x-myformat', -}) -``` - -You can also optionally configure the charset and whether the MIME type is -compressible: - -```ts -defineMimeType({ - extensions: ['myformat'], - mimeType: 'application/x-myformat', - compressible: true, - charset: 'utf-8', -}) -``` - -## License - -MIT - -## Navigation - -- [Remix package index](./index.md) diff --git a/docs/agents/remix/multipart-parser/benchmarks.md b/docs/agents/remix/multipart-parser/benchmarks.md deleted file mode 100644 index 2603635b..00000000 --- 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 b71de1a7..00000000 --- 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 7d2dcc8b..00000000 --- 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 5df57730..00000000 --- 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/advanced-usage.md b/docs/agents/remix/node-fetch-server/advanced-usage.md deleted file mode 100644 index b4f1007a..00000000 --- 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 aab88a96..00000000 --- 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 c0690aea..00000000 --- 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 63dec91f..00000000 --- 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 3ed2e0b5..00000000 --- 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 30fc7d9b..00000000 --- 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/response/compress-responses.md b/docs/agents/remix/response/compress-responses.md deleted file mode 100644 index 9573fdb2..00000000 --- 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 43d7ddc5..00000000 --- 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 27ff0f04..00000000 --- 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 eb5fedcb..00000000 --- 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 f5f58f54..00000000 --- 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 9e1eabf4..00000000 --- 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.md deleted file mode 100644 index a8fafa97..00000000 --- a/docs/agents/remix/route-pattern.md +++ /dev/null @@ -1,175 +0,0 @@ -# route-pattern - -Source: https://github.com/remix-run/remix/tree/main/packages/route-pattern - -## README - -Fast URL matching and href generation with type safe params. - -```ts -import { RoutePattern } from '@remix-run/route-pattern' - -let blog = new RoutePattern('blog/:slug') -blog.match('https://remix.run/blog/v3') // { params: { slug: 'v3' } } -blog.href({ slug: 'v3' }) // '/blog/v3' - -let api = new RoutePattern('api(/v:version)/*path') -api.match('https://api.com/api/v2/users/profile') // { params: { version: '2', path: 'users/profile' } } -api.href({ version: '2', path: 'users/profile' }) // '/api/v2/users/profile' -api.href({ path: 'users/profile' }) // '/api/users/profile' - -let cdn = new RoutePattern('http(s)://:region.cdn.com/assets/*file.:ext') -cdn.match('https://us-west.cdn.com/assets/images/logo.png') // { params: { region: 'us-west', file: 'images/logo', ext: 'png' } } -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`: - -```ts -new RoutePattern('users/:id') // matches /users/123 -new RoutePattern('blog/:year-:month-:day/:slug') // matches /blog/2024-01-15/hello -``` - -**Wildcards** match multi-segment paths using `*name`: - -```ts -new RoutePattern('files/*path') // matches /files/images/logo.png -new RoutePattern('node_modules/*package/dist/index.js') // matches /node_modules/@remix-run/router/dist/index.js -new RoutePattern('files/*') // matches any path under /files, but doesn't capture the value for the wildcard -``` - -**Optionals** make parts optional using `()`: - -```ts -new RoutePattern('api(/v:version)/users') // matches /api/users AND /api/v2/users -new RoutePattern('blog/:slug(.html)') // matches /blog/hello AND /blog/hello.html -new RoutePattern('docs(/guides/:category)') // multiple segments optional: /docs OR /docs/guides/routing -new RoutePattern('api(/v:major(.:minor))') // nested optionals: /api, /api/v2, /api/v2.1 -``` - -**Search params** narrow matches using `?key` or `?key=value`: - -```ts -new RoutePattern('search?q') // requires ?q in URL -new RoutePattern('search?q=') // requires ?q with any value -new RoutePattern('search?q=routing') // requires ?q=routing exactly -``` - -**Flexible matching** for partial URL patterns: - -```ts -new RoutePattern('blog/:slug') // omits protocol/hostname, matches any origin -new RoutePattern('://example.com/api') // omits protocol, matches http and https -new RoutePattern('search?q') // allows additional search params beyond ?q -``` - -## Matchers - -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' - -// Any data type you want! -let matcher = new Matcher() - -matcher.add('/', 'home') -matcher.add('blog/:slug', 'blog-post') -matcher.add('api(/v:version)/*path', 'api') - -matcher.match('https://example.com/blog/v3') -// { pattern: 'blog/:slug', params: { slug: 'v3' }, data: 'blog-post' } - -matcher.match('https://example.com/api/v2/users/profile') -// { pattern: 'api(/v:version)/*path', params: { version: '2', path: 'users/profile' }, data: 'api' } -``` - -**ArrayMatcher vs TrieMatcher** - -- **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 -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' -``` - -## Specificity - -When multiple patterns match a URL, the most specific pattern wins. - -**Pathname specificity** (left-to-right): - -```ts -import { ArrayMatcher } from '@remix-run/route-pattern' - -let matcher = new ArrayMatcher() -matcher.add('blog/hello', 'static') -matcher.add('blog/:slug', 'variable') -matcher.add('blog/*path', 'wildcard') -matcher.add('*path', 'catch-all') - -matcher.match('https://example.com/blog/hello') -// { pattern: 'blog/hello', params: {}, data: 'static' } -// 'blog/hello' wins: static segments beat variables/wildcards at each position -``` - -**Search parameter specificity**: - -```ts -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 -``` - -## Benchmark - -To run benchmarks comparing `route-pattern` performance with comparable -libraries: - -```sh -pnpm bench bench/comparison.bench.ts -``` - -## Related Work - -- [`path-to-regexp`](https://www.npmjs.com/package/path-to-regexp) -- [`URLPattern`](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) - -## 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.md deleted file mode 100644 index 70a1d766..00000000 --- a/docs/agents/remix/session-middleware.md +++ /dev/null @@ -1,114 +0,0 @@ -# session-middleware - -Source: https://github.com/remix-run/remix/tree/main/packages/session-middleware - -## README - -Middleware for managing sessions with -[`@remix-run/fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) -via securely signed cookies. - -## Installation - -```sh -bun add @remix-run/session-middleware -``` - -## 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' - -let sessionCookie = createCookie('__session', { - secrets: ['s3cr3t'], // session cookies must be signed! - httpOnly: true, - secure: true, - sameSite: 'lax', -}) - -let sessionStorage = createCookieSessionStorage() - -let router = createRouter({ - middleware: [session(sessionCookie, sessionStorage)], -}) - -router.get('/', (context) => { - context.session.set('count', Number(context.session.get('count') ?? 0) + 1) - return new Response(`Count: ${context.session.get('count')}`) -}) -``` - -The middleware: - -- Reads the session from the cookie on incoming requests -- Makes it available as `context.session` -- Automatically saves session changes and sets the cookie on responses - -Note: The session cookie must be signed for security. This prevents tampering -with the session data on the client. - -### Login/Logout Flow - -A basic login/logout flow could look like this: - -```ts -import * as res from '@remix-run/fetch-router/response-helpers' - -router.get('/login', ({ session }) => { - let error = session.get('error') - return res.html(` - - -

    Login

    - ${typeof error === 'string' ?
    ${error}
    : null} -
    - - - -
    - - - `) -}) - -router.post('/login', ({ session, formData }) => { - let username = formData.get('username') - let password = formData.get('password') - - let user = authenticateUser(username, password) - if (!user) { - session.flash('error', 'Invalid username or password') - return res.redirect('/login') - } - - session.regenerateId() - session.set('userId', user.id) - - return res.redirect('/dashboard') -}) - -router.post('/logout', ({ session }) => { - session.destroy() - return res.redirect('/') -}) -``` - -## Related Packages - -- [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - - Router for the web Fetch API -- [`session`](https://github.com/remix-run/remix/tree/main/packages/session) - - Session management and storage -- [`cookie`](https://github.com/remix-run/remix/tree/main/packages/cookie) - - Cookie parsing and serialization - -## 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 c2f8a4bf..00000000 --- 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-redis.md b/docs/agents/remix/session-storage-redis.md deleted file mode 100644 index 96534c44..00000000 --- a/docs/agents/remix/session-storage-redis.md +++ /dev/null @@ -1,43 +0,0 @@ -# session-storage-redis - -Source: -https://github.com/remix-run/remix/tree/main/packages/session-storage-redis - -## README - -Redis-backed session storage for `remix/session`. - -## Installation - -```sh -npm i remix redis -``` - -## Usage - -```ts -import { createClient } from 'redis' -import { createRedisSessionStorage } from 'remix/session-storage-redis' - -let redis = createClient({ url: process.env.REDIS_URL }) -await redis.connect() - -let sessionStorage = createRedisSessionStorage(redis, { - keyPrefix: 'session:', - ttl: 60 * 60 * 24, -}) -``` - -## Options - -- `keyPrefix` (`string`, default: `'session:'`) -- `ttl` (`number` in 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/flash-and-security.md b/docs/agents/remix/session/flash-and-security.md deleted file mode 100644 index 0f71be43..00000000 --- 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 6e758ad1..00000000 --- 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 932ef560..00000000 --- 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 2347c11b..00000000 --- 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.md deleted file mode 100644 index c5f9ac6d..00000000 --- a/docs/agents/remix/static-middleware.md +++ /dev/null @@ -1,109 +0,0 @@ -# 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. - -## Features - -- [ETag support](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) - (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) -- Path traversal protection -- Automatic fall through to next middleware/handler if file not found - -## Installation - -```sh -bun add @remix-run/static-middleware -``` - -## Usage - -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' - -let router = createRouter({ - middleware: [staticFiles('./public')], -}) - -router.get('/', () => new Response('Home')) -``` - -### With Cache Control - -Internally, the `staticFiles()` middleware uses the -[`createFileResponse()` helper from `@remix-run/response`](https://github.com/remix-run/remix/tree/main/packages/response/README.md#file-responses) -to send files with full HTTP semantics. This means it also accepts the same -options as the `createFileResponse()` helper. - -```ts -let router = createRouter({ - middleware: [ - staticFiles('./public', { - cacheControl: 'public, max-age=31536000, immutable', // 1 year - }), - ], -}) -``` - -### Filter Files - -```ts -let router = createRouter({ - middleware: [ - staticFiles('./public', { - filter(path) { - // Don't serve hidden files - return !path.startsWith('.') - }, - }), - ], -}) -``` - -### Multiple Directories - -```ts -let router = createRouter({ - middleware: [ - staticFiles('./public'), - staticFiles('./assets', { - cacheControl: 'public, max-age=31536000', - }), - ], -}) -``` - -## Security - -- Prevents path traversal attacks (e.g., `../../../etc/passwd`) -- Only serves files with GET and HEAD requests -- Respects the configured root directory boundary - -## Related Packages - -- [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - - Router for the web Fetch API -- [`lazy-file`](https://github.com/remix-run/remix/tree/main/packages/lazy-file) - - Used internally for streaming file contents - -## 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.md deleted file mode 100644 index 9f0b68d2..00000000 --- a/docs/agents/remix/tar-parser.md +++ /dev/null @@ -1,104 +0,0 @@ -# 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. - -## Features - -- **Universal Runtime** - Runs anywhere JavaScript runs -- **Web Streams** - Built on the standard - [web Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API), - so it's composable with `fetch()` streams -- **Format Support** - Supports POSIX, GNU, and PAX tar formats -- **Memory Efficient** - Does not buffer anything in normal usage -- **Zero Dependencies** - No external dependencies - -## Installation - -Install from [npm](https://www.npmjs.com/): - -```sh -bun add @remix-run/tar-parser -``` - -## Usage - -The main parser interface is the `parseTar(archive, handler)` function: - -```ts -import { parseTar } from '@remix-run/tar-parser' - -let response = await fetch( - 'https://github.com/remix-run/remix/archive/refs/heads/main.tar.gz', -) - -await parseTar( - response.body.pipeThrough(new DecompressionStream('gzip')), - (entry) => { - console.log(entry.name, entry.size) - }, -) -``` - -If you're parsing an archive with filename encodings other than UTF-8, use the -`filenameEncoding` option: - -```ts -let response = await fetch(/* ... */) - -await parseTar(response.body, { filenameEncoding: 'latin1' }, (entry) => { - console.log(entry.name, entry.size) -}) -``` - -## Benchmark - -`tar-parser` performs on par with other popular tar parsing libraries on -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 - -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' -``` - -## Related Packages - -- [`multipart-parser`](https://github.com/remix-run/remix/tree/main/packages/multipart-parser) - - Fast, streaming multipart parser for JavaScript - -## Credits - -`tar-parser` is based on the excellent -[tar-stream package](https://www.npmjs.com/package/tar-stream) (MIT license) and -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) diff --git a/docs/agents/remix/update.md b/docs/agents/remix/update.md deleted file mode 100644 index 2528c50e..00000000 --- a/docs/agents/remix/update.md +++ /dev/null @@ -1,70 +0,0 @@ -# Updating Remix package docs - -Use this checklist to refresh `docs/agents/remix/**/*.md` from upstream. - -## 1) List packages - -```sh -gh api repos/remix-run/remix/contents/packages --jq '.[].name' -``` - -## 2) Refresh each package README - -For each package name, download the README from upstream: - -```sh -curl -L "https://raw.githubusercontent.com/remix-run/remix/main/packages//README.md" -``` - -Replace the corresponding README content in docs: - -- For single-file packages, update `docs/agents/remix/.md`. -- For split packages, update the README chunks under - `docs/agents/remix//`. - -Keep each Markdown file to roughly 200 lines or fewer. If a README grows beyond -that, split it into multiple files and update the package `index.md` to link the -new chunks. - -## 3) Refresh component docs - -`component` is the only package with a `docs` directory. Sync every file from: - -``` -https://github.com/remix-run/remix/tree/main/packages/component/docs -``` - -Update the split files in `docs/agents/remix/component/` to match upstream. Keep -all docs: `animate`, `components`, `composition`, `context`, `events`, -`getting-started`, `handle`, `interactions`, `patterns`, `spring`, `styling`, -`testing`, `tween`. If any single doc exceeds roughly 200 lines, split it into -multiple files and add links in `component/index.md`. - -## 4) Keep the index current - -If a package is added or removed upstream, update `docs/agents/remix/index.md`: - -- Add/remove package rows in the table. -- Update the "Start here" section if new docs are important. -- If a package moves to a folder, update links to `.//index.md`. - -## 5) Audit export coverage - -Confirm `docs/agents/remix/index.md` package rows still cover all top-level -exports from the installed `remix` package: - -```sh -bun -e "const fs=require('fs');const pkg=JSON.parse(fs.readFileSync('node_modules/remix/package.json','utf8'));const top=[...new Set(Object.keys(pkg.exports).filter(k=>k!=='./package.json').map(k=>k.slice(2).split('/')[0]))].sort();const idx=fs.readFileSync('docs/agents/remix/index.md','utf8');const docs=[...new Set([...idx.matchAll(/^\\|\\s*([a-z0-9-]+)\\s*\\|/gm)].map(m=>m[1]).filter(x=>!['Package','--------------------------'].includes(x)))].sort();const missing=top.filter(x=>!docs.includes(x));console.log(missing.length===0?'No missing package docs in index.':'Missing docs for: '+missing.join(', '));" -``` - -If any package names are missing, add them to `docs/agents/remix/index.md` and -add the corresponding docs file(s). - -## 6) Verify - -Run formatting and validation before committing: - -```sh -bun run format -bun run validate -``` diff --git a/docs/agents/setup.md b/docs/agents/setup.md index 8c7dd4f8..ce6562a5 100644 --- a/docs/agents/setup.md +++ b/docs/agents/setup.md @@ -167,8 +167,8 @@ If you ever need to do the same operations manually, use: - `bun tools/ci/preview-resources.ts cleanup --worker-name ` - `bun tools/ci/production-resources.ts ensure --out-config ` -## Remix package docs +## Remix skill -Use the Remix package index for quick navigation: +Use the packaged Remix skill for Remix-specific implementation guidance: -- `docs/agents/remix/index.md` +- `.agents/skills/remix/SKILL.md` diff --git a/mock-servers/ai/db-tables.ts b/mock-servers/ai/db-tables.ts index 70ba0102..d01b4437 100644 --- a/mock-servers/ai/db-tables.ts +++ b/mock-servers/ai/db-tables.ts @@ -1,17 +1,16 @@ -import { createTable } from 'remix/data-table' -import { number, string } from 'remix/data-schema' +import { column as c, table } from 'remix/data-table' -export const aiCapturedRequestsTable = createTable({ +export const aiCapturedRequestsTable = table({ name: 'ai_captured_requests', columns: { - id: string(), - token_hash: string(), - received_at: number(), - scenario: string(), - last_user_message: string(), - tool_names_json: string(), - request_json: string(), - response_text: string(), + id: c.text(), + token_hash: c.text(), + received_at: c.integer(), + scenario: c.text(), + last_user_message: c.text(), + tool_names_json: c.text(), + request_json: c.text(), + response_text: c.text(), }, primaryKey: 'id', }) diff --git a/mock-servers/resend/db-tables.ts b/mock-servers/resend/db-tables.ts index 2dc050da..18828368 100644 --- a/mock-servers/resend/db-tables.ts +++ b/mock-servers/resend/db-tables.ts @@ -1,17 +1,16 @@ -import { createTable } from 'remix/data-table' -import { number, string } from 'remix/data-schema' +import { column as c, table } from 'remix/data-table' -export const resendCapturedEmailsTable = createTable({ +export const resendCapturedEmailsTable = table({ name: 'resend_captured_emails', columns: { - id: string(), - token_hash: string(), - received_at: number(), - from_email: string(), - to_json: string(), - subject: string(), - html: string(), - payload_json: string(), + id: c.text(), + token_hash: c.text(), + received_at: c.integer(), + from_email: c.text(), + to_json: c.text(), + subject: c.text(), + html: c.text(), + payload_json: c.text(), }, primaryKey: 'id', }) diff --git a/package.json b/package.json index 318e13c4..e91c3463 100644 --- a/package.json +++ b/package.json @@ -14,14 +14,14 @@ "scripts": { "dev": "bun cli.ts", "dev:client": "concurrently --kill-others-on-fail -n web,mcp-apps -c green,cyan \"bun run dev:client:web\" \"bun run dev:mcp-apps\"", - "dev:client:web": "esbuild client/entry.tsx --bundle --format=esm --target=es2022 --outdir=public --entry-names=client-entry --chunk-names=assets/[name] --asset-names=assets/[name] --jsx=automatic --jsx-import-source=remix/component --watch", + "dev:client:web": "esbuild client/entry.tsx --bundle --format=esm --target=es2022 --outdir=public --entry-names=client-entry --chunk-names=assets/[name] --asset-names=assets/[name] --jsx=automatic --jsx-import-source=remix/ui --watch", "dev:mcp-apps": "esbuild client/mcp-apps/calculator-widget.ts --bundle --format=esm --target=es2022 --outdir=public/mcp-apps --platform=browser --watch", "dev:worker": "bun ./wrangler-env.ts dev --local", "dev:mock-resend": "bun ./wrangler-env.ts d1 migrations apply APP_DB --local --config mock-servers/resend/wrangler.jsonc && bun ./wrangler-env.ts dev --local --config mock-servers/resend/wrangler.jsonc", "dev:mock-ai": "bun ./wrangler-env.ts d1 migrations apply APP_DB --local --config mock-servers/ai/wrangler.jsonc && bun ./wrangler-env.ts dev --local --config mock-servers/ai/wrangler.jsonc", "deploy": "bun run build && bun ./wrangler-env.ts deploy", "build:mcp-apps": "esbuild client/mcp-apps/calculator-widget.ts --bundle --format=esm --target=es2022 --outdir=public/mcp-apps --platform=browser", - "build:client:web": "esbuild client/entry.tsx --bundle --format=esm --target=es2022 --outdir=public --entry-names=client-entry --chunk-names=assets/[name] --asset-names=assets/[name] --jsx=automatic --jsx-import-source=remix/component", + "build:client:web": "esbuild client/entry.tsx --bundle --format=esm --target=es2022 --outdir=public --entry-names=client-entry --chunk-names=assets/[name] --asset-names=assets/[name] --jsx=automatic --jsx-import-source=remix/ui", "build:client": "bun run build:client:web && bun run build:mcp-apps", "build": "bun run build:client && bun ./wrangler-env.ts build", "lint": "oxlint .", @@ -69,7 +69,7 @@ "@modelcontextprotocol/sdk": "1.26.0", "agents": "^0.7.6", "get-port": "^7.1.0", - "remix": "3.0.0-alpha.3", + "remix": "3.0.0-beta.0", "workers-ai-provider": "^3.1.2", "zod": "^4.3.6" } diff --git a/server/handlers/account.ts b/server/handlers/account.ts index 6fc3e9b4..907a3078 100644 --- a/server/handlers/account.ts +++ b/server/handlers/account.ts @@ -7,7 +7,7 @@ import { type routes } from '#server/routes.ts' export const account = { middleware: [], - async action({ request }) { + async handler({ request }) { const { session, setCookie } = await readAuthSessionResult(request) if (!session) { diff --git a/server/handlers/auth-handler.test.ts b/server/handlers/auth-handler.test.ts index f56946ca..e5820da8 100644 --- a/server/handlers/auth-handler.test.ts +++ b/server/handlers/auth-handler.test.ts @@ -20,7 +20,7 @@ function createAuthRequest( const context = new RequestContext(request) return { - run: () => handler.action(context), + run: () => handler.handler(context), } } diff --git a/server/handlers/auth-page.ts b/server/handlers/auth-page.ts index 1266aa86..38c2a623 100644 --- a/server/handlers/auth-page.ts +++ b/server/handlers/auth-page.ts @@ -12,7 +12,7 @@ function normalizeRedirectTo(value: string | null) { export function createAuthPageHandler() { return { middleware: [], - async action({ request }: { request: Request }) { + async handler({ request }: { request: Request }) { const { session, setCookie } = await readAuthSessionResult(request) if (session) { const url = new URL(request.url) diff --git a/server/handlers/auth.ts b/server/handlers/auth.ts index edde8955..eaa7eba8 100644 --- a/server/handlers/auth.ts +++ b/server/handlers/auth.ts @@ -31,7 +31,7 @@ export function createAuthHandler(appEnv: AppEnv) { return { middleware: [], - async action({ request, url }) { + async handler({ request, url }) { let body: unknown try { diff --git a/server/handlers/chat-threads.ts b/server/handlers/chat-threads.ts index bcd1c21d..41653526 100644 --- a/server/handlers/chat-threads.ts +++ b/server/handlers/chat-threads.ts @@ -20,7 +20,7 @@ export function createChatThreadsHandler(appEnv: AppEnv) { return { middleware: [], - async action({ request }) { + async handler({ request }) { const user = await readAuthenticatedAppUser(request, appEnv as Env) if (!user) { return jsonResponse( @@ -69,7 +69,7 @@ export function createDeleteChatThreadHandler(appEnv: AppEnv) { return { middleware: [], - async action({ request }) { + async handler({ request }) { const user = await readAuthenticatedAppUser(request, appEnv as Env) if (!user) { return jsonResponse( @@ -122,7 +122,7 @@ export function createUpdateChatThreadHandler(appEnv: AppEnv) { return { middleware: [], - async action({ request }) { + async handler({ request }) { const user = await readAuthenticatedAppUser(request, appEnv as Env) if (!user) { return jsonResponse( diff --git a/server/handlers/chat.ts b/server/handlers/chat.ts index 9a67f5cf..1cd48c5a 100644 --- a/server/handlers/chat.ts +++ b/server/handlers/chat.ts @@ -7,7 +7,7 @@ import { type routes } from '#server/routes.ts' export const chat = { middleware: [], - async action({ request }) { + async handler({ request }) { const { session, setCookie } = await readAuthSessionResult(request) if (!session) { diff --git a/server/handlers/health-handler.test.ts b/server/handlers/health-handler.test.ts index 37b6d7a5..933a41a5 100644 --- a/server/handlers/health-handler.test.ts +++ b/server/handlers/health-handler.test.ts @@ -10,7 +10,7 @@ function createHealthRequestContext() { test('health handler returns ok with null commit SHA when unset', async () => { const handler = createHealthHandler({ APP_COMMIT_SHA: undefined }) - const response = await handler.action(createHealthRequestContext()) + const response = await handler.handler(createHealthRequestContext()) expect(response.status).toBe(200) expect(response.headers.get('Cache-Control')).toBe('no-store') @@ -23,7 +23,7 @@ test('health handler returns the configured commit SHA', async () => { APP_COMMIT_SHA: 'f2d82dba4ba50cf2ad3f56f5c88f7b8ef5f97d8e', }) - const response = await handler.action(createHealthRequestContext()) + const response = await handler.handler(createHealthRequestContext()) expect(response.status).toBe(200) expect(response.headers.get('X-App-Commit-Sha')).toBe( diff --git a/server/handlers/health.ts b/server/handlers/health.ts index e26f2f03..02cc3fce 100644 --- a/server/handlers/health.ts +++ b/server/handlers/health.ts @@ -9,7 +9,7 @@ type HealthEnv = { export function createHealthHandler(appEnv: HealthEnv) { return { middleware: [], - async action() { + async handler() { const commitSha = appEnv.APP_COMMIT_SHA ?? null return Response.json( { ok: true, commitSha }, diff --git a/server/handlers/home.ts b/server/handlers/home.ts index 133fe298..d4aeddba 100644 --- a/server/handlers/home.ts +++ b/server/handlers/home.ts @@ -5,7 +5,7 @@ import { type routes } from '#server/routes.ts' export const home = { middleware: [], - async action() { + async handler() { return render(Layout({})) }, } satisfies BuildAction diff --git a/server/handlers/logout.ts b/server/handlers/logout.ts index 3bd9be62..57169467 100644 --- a/server/handlers/logout.ts +++ b/server/handlers/logout.ts @@ -4,7 +4,7 @@ import { type routes } from '#server/routes.ts' export const logout = { middleware: [], - async action({ request }) { + async handler({ request }) { const cookie = await destroyAuthCookie(isSecureRequest(request)) const location = new URL('/login', request.url) diff --git a/server/handlers/password-reset.ts b/server/handlers/password-reset.ts index f275e1f8..e0ebe2d8 100644 --- a/server/handlers/password-reset.ts +++ b/server/handlers/password-reset.ts @@ -73,7 +73,7 @@ export function createPasswordResetRequestHandler(appEnv: AppEnv) { return { middleware: [], - async action({ request, url }) { + async handler({ request, url }) { let body: unknown try { body = await request.json() @@ -192,7 +192,7 @@ export function createPasswordResetConfirmHandler(appEnv: AppEnv) { return { middleware: [], - async action({ request, url }) { + async handler({ request, url }) { let body: unknown try { body = await request.json() diff --git a/server/handlers/session-handler.test.ts b/server/handlers/session-handler.test.ts index 99c9b984..0956be67 100644 --- a/server/handlers/session-handler.test.ts +++ b/server/handlers/session-handler.test.ts @@ -48,7 +48,7 @@ test('session handler renews remembered sessions after two weeks', async () => { ) const response = await withMockedNow(now, () => - session.action(createSessionRequestContext(cookie)), + session.handler(createSessionRequestContext(cookie)), ) expect(response.status).toBe(200) @@ -68,7 +68,7 @@ test('session handler keeps remembered sessions unchanged before renewal window' ) const response = await withMockedNow(now, () => - session.action(createSessionRequestContext(cookie)), + session.handler(createSessionRequestContext(cookie)), ) expect(response.status).toBe(200) diff --git a/server/handlers/session.ts b/server/handlers/session.ts index 34c47b3b..2c04398f 100644 --- a/server/handlers/session.ts +++ b/server/handlers/session.ts @@ -15,7 +15,7 @@ function jsonResponse(data: unknown, init?: ResponseInit) { export const session = { middleware: [], - async action({ request }) { + async handler({ request }) { const { session, setCookie } = await readAuthSessionResult(request) if (!session) { return jsonResponse({ ok: false }) diff --git a/types/tsconfig-client.json b/types/tsconfig-client.json index b9518f9b..74e53515 100644 --- a/types/tsconfig-client.json +++ b/types/tsconfig-client.json @@ -7,7 +7,7 @@ "lib": ["DOM", "DOM.Iterable", "ES2022"], "types": [], "jsx": "react-jsx", - "jsxImportSource": "remix/component" + "jsxImportSource": "remix/ui" }, "include": ["../client/**/*.ts", "../client/**/*.tsx", "../shared/**/*.ts"] } diff --git a/worker/d1-data-table-adapter.ts b/worker/d1-data-table-adapter.ts index 4b61be60..30c93444 100644 --- a/worker/d1-data-table-adapter.ts +++ b/worker/d1-data-table-adapter.ts @@ -2,9 +2,15 @@ import { getTableName, getTablePrimaryKey, type AdapterCapabilityOverrides, - type AdapterExecuteRequest, - type AdapterResult, - type AdapterStatement, + type AdapterCapabilities, + type DataManipulationOperation, + type DataManipulationRequest, + type DataManipulationResult, + type DataMigrationOperation, + type DataMigrationRequest, + type DataMigrationResult, + type SqlStatement, + type TableRef, type DatabaseAdapter, type TransactionOptions, type TransactionToken, @@ -48,11 +54,7 @@ type D1PreparedQuery = { */ export class D1DataTableAdapter implements DatabaseAdapter { dialect = 'sqlite' - capabilities: { - returning: boolean - savepoints: boolean - upsert: boolean - } + capabilities: AdapterCapabilities #database: D1Database #transactions = new Set() @@ -69,51 +71,73 @@ export class D1DataTableAdapter implements DatabaseAdapter { returning: options?.capabilities?.returning ?? true, savepoints: options?.capabilities?.savepoints ?? false, upsert: options?.capabilities?.upsert ?? true, + transactionalDdl: options?.capabilities?.transactionalDdl ?? false, + migrationLock: options?.capabilities?.migrationLock ?? false, } } - async execute(request: AdapterExecuteRequest): Promise { + compileSql( + operation: DataManipulationOperation | DataMigrationOperation, + ): Array { + const statement = + operation.kind === 'raw' || + operation.kind === 'select' || + operation.kind === 'count' || + operation.kind === 'exists' || + operation.kind === 'insert' || + operation.kind === 'insertMany' || + operation.kind === 'update' || + operation.kind === 'delete' || + operation.kind === 'upsert' + ? compileSqliteStatement(operation) + : compileSqliteMigrationStatement(operation) + return [{ text: statement.text, values: statement.values }] + } + + async execute( + request: DataManipulationRequest, + ): Promise { if ( - request.statement.kind === 'insertMany' && - request.statement.values.length === 0 + request.operation.kind === 'insertMany' && + request.operation.values.length === 0 ) { return { affectedRows: 0, insertId: undefined, - rows: request.statement.returning ? [] : undefined, + rows: request.operation.returning ? [] : undefined, } } - const statement = compileSqliteStatement(request.statement) + const statement = compileSqliteStatement(request.operation) const prepared = this.#database .prepare(statement.text) .bind(...statement.values) as unknown as D1PreparedQuery const shouldReadRows = - request.statement.kind === 'select' || - request.statement.kind === 'count' || - request.statement.kind === 'exists' || - hasReturningClause(request.statement) + request.operation.kind === 'select' || + request.operation.kind === 'count' || + request.operation.kind === 'exists' || + hasReturningClause(request.operation) if (shouldReadRows) { const result = (await prepared.all()) as D1StatementResult let rows = normalizeRows(result.results ?? []) if ( - request.statement.kind === 'count' || - request.statement.kind === 'exists' + request.operation.kind === 'count' || + request.operation.kind === 'exists' ) { rows = normalizeCountRows(rows) } return { rows, affectedRows: normalizeAffectedRowsForReader( - request.statement.kind, + request.operation.kind, rows, result.meta, ), insertId: normalizeInsertIdForReader( - request.statement.kind, - request.statement, + request.operation.kind, + request.operation, rows, result.meta, ), @@ -122,15 +146,43 @@ export class D1DataTableAdapter implements DatabaseAdapter { const result = (await prepared.run()) as D1StatementResult return { - affectedRows: normalizeAffectedRowsForRun(request.statement.kind, result), + affectedRows: normalizeAffectedRowsForRun(request.operation.kind, result), insertId: normalizeInsertIdForRun( - request.statement.kind, - request.statement, + request.operation.kind, + request.operation, result, ), } } + async migrate(request: DataMigrationRequest): Promise { + const statements = this.compileSql(request.operation) + for (const statement of statements) { + await this.#database + .prepare(statement.text) + .bind(...statement.values) + .run() + } + return { affectedOperations: 1 } + } + + async hasTable(table: TableRef): Promise { + const result = await this.#database + .prepare( + "select 1 from sqlite_master where type = 'table' and name = ? limit 1", + ) + .bind(table.name) + .all() + return Boolean(result.results?.length) + } + + async hasColumn(table: TableRef, column: string): Promise { + const result = await this.#database + .prepare(`pragma table_info(${quotePath(table.name)})`) + .all<{ name?: string }>() + return (result.results ?? []).some((field) => field.name === column) + } + async beginTransaction( options?: TransactionOptions, ): Promise { @@ -194,7 +246,7 @@ export function createD1DataTableAdapter( return new D1DataTableAdapter(database, options) } -function hasReturningClause(statement: AdapterStatement) { +function hasReturningClause(statement: DataManipulationOperation) { return ( (statement.kind === 'insert' || statement.kind === 'insertMany' || @@ -237,7 +289,7 @@ function normalizeCountRows(rows: Array>) { } function normalizeAffectedRowsForReader( - kind: AdapterStatement['kind'], + kind: DataManipulationOperation['kind'], rows: Array>, meta?: D1Meta, ) { @@ -251,8 +303,8 @@ function normalizeAffectedRowsForReader( } function normalizeInsertIdForReader( - kind: AdapterStatement['kind'], - statement: AdapterStatement, + kind: DataManipulationOperation['kind'], + statement: DataManipulationOperation, rows: Array>, meta?: D1Meta, ) { @@ -272,7 +324,7 @@ function normalizeInsertIdForReader( } function normalizeAffectedRowsForRun( - kind: AdapterStatement['kind'], + kind: DataManipulationOperation['kind'], result: D1StatementResult, ) { if (kind === 'select' || kind === 'count' || kind === 'exists') { @@ -282,8 +334,8 @@ function normalizeAffectedRowsForRun( } function normalizeInsertIdForRun( - kind: AdapterStatement['kind'], - statement: AdapterStatement, + kind: DataManipulationOperation['kind'], + statement: DataManipulationOperation, result: D1StatementResult, ) { if (!isInsertStatementKind(kind) || !isInsertStatement(statement)) { @@ -295,7 +347,7 @@ function normalizeInsertIdForRun( return result.meta?.last_row_id } -function isWriteStatementKind(kind: AdapterStatement['kind']) { +function isWriteStatementKind(kind: DataManipulationOperation['kind']) { return ( kind === 'insert' || kind === 'insertMany' || @@ -305,14 +357,14 @@ function isWriteStatementKind(kind: AdapterStatement['kind']) { ) } -function isInsertStatementKind(kind: AdapterStatement['kind']) { +function isInsertStatementKind(kind: DataManipulationOperation['kind']) { return kind === 'insert' || kind === 'insertMany' || kind === 'upsert' } function isInsertStatement( - statement: AdapterStatement, + statement: DataManipulationOperation, ): statement is Extract< - AdapterStatement, + DataManipulationOperation, { kind: 'insert' | 'insertMany' | 'upsert' } > { return ( @@ -327,7 +379,7 @@ function isInsertStatement( * adapter self-contained without depending on internal package paths. */ function compileSqliteStatement( - statement: AdapterStatement, + statement: DataManipulationOperation, ): CompiledSqlStatement { if (statement.kind === 'raw') { return { @@ -451,10 +503,138 @@ function compileSqliteStatement( throw new Error('Unsupported statement kind') } +function compileSqliteMigrationStatement( + operation: DataMigrationRequest['operation'], +): CompiledSqlStatement { + if (operation.kind === 'raw') { + return { + text: operation.sql.text, + values: [...operation.sql.values], + } + } + + if (operation.kind === 'createTable') { + const columnDefinitions = Object.entries(operation.columns).map( + ([name, definition]) => + quotePath(name) + ' ' + compileColumnDefinition(definition), + ) + const constraints: Array = [] + if (operation.primaryKey) { + const columns = operation.primaryKey.columns + .map((column) => quotePath(column)) + .join(', ') + const name = operation.primaryKey.name + ? 'constraint ' + quotePath(operation.primaryKey.name) + ' ' + : '' + constraints.push(name + 'primary key (' + columns + ')') + } + for (const unique of operation.uniques ?? []) { + const columns = unique.columns + .map((column) => quotePath(column)) + .join(', ') + const name = unique.name + ? 'constraint ' + quotePath(unique.name) + ' ' + : '' + constraints.push(name + 'unique (' + columns + ')') + } + return { + text: + 'create table ' + + (operation.ifNotExists ? 'if not exists ' : '') + + quoteTableRef(operation.table) + + ' (' + + [...columnDefinitions, ...constraints].join(', ') + + ')', + values: [], + } + } + + if (operation.kind === 'dropTable') { + return { + text: + 'drop table ' + + (operation.ifExists ? 'if exists ' : '') + + quoteTableRef(operation.table), + values: [], + } + } + + if (operation.kind === 'createIndex') { + const index = operation.index + const unique = index.unique ? 'unique ' : '' + const columns = index.columns.map((column) => quotePath(column)).join(', ') + const where = index.where ? ' where ' + index.where : '' + return { + text: + 'create ' + + unique + + 'index ' + + (operation.ifNotExists ? 'if not exists ' : '') + + quotePath(index.name) + + ' on ' + + quoteTableRef(index.table) + + ' (' + + columns + + ')' + + where, + values: [], + } + } + + if (operation.kind === 'dropIndex') { + return { + text: + 'drop index ' + + (operation.ifExists ? 'if exists ' : '') + + quotePath(operation.name), + values: [], + } + } + + if (operation.kind === 'alterTable') { + if (operation.changes.length !== 1) { + throw new Error('D1 adapter supports one alter-table change at a time') + } + const change = operation.changes[0] + if (!change) { + throw new Error('alterTable requires at least one change') + } + if (change.kind === 'addColumn') { + return { + text: + 'alter table ' + + quoteTableRef(operation.table) + + ' add column ' + + quotePath(change.column) + + ' ' + + compileColumnDefinition(change.definition), + values: [], + } + } + if (change.kind === 'renameColumn') { + return { + text: + 'alter table ' + + quoteTableRef(operation.table) + + ' rename column ' + + quotePath(change.from) + + ' to ' + + quotePath(change.to), + values: [], + } + } + } + + throw new Error('Unsupported D1 migration operation: ' + operation.kind) +} + function compileInsertStatement( - table: Extract['table'], + table: Extract['table'], values: Record, - returning: Extract['returning'], + returning: Extract< + DataManipulationOperation, + { kind: 'insert' } + >['returning'], context: SqliteCompileContext, ): CompiledSqlStatement { const columns = Object.keys(values) @@ -484,9 +664,12 @@ function compileInsertStatement( } function compileInsertManyStatement( - table: Extract['table'], + table: Extract['table'], rows: Array>, - returning: Extract['returning'], + returning: Extract< + DataManipulationOperation, + { kind: 'insertMany' } + >['returning'], context: SqliteCompileContext, ): CompiledSqlStatement { if (rows.length === 0) { @@ -536,7 +719,7 @@ function compileInsertManyStatement( } function compileUpsertStatement( - statement: Extract, + statement: Extract, context: SqliteCompileContext, ): CompiledSqlStatement { const insertColumns = Object.keys(statement.values) @@ -597,7 +780,7 @@ function compileUpsertStatement( } function compileFromClause( - table: AdapterStatement extends infer T + table: DataManipulationOperation extends infer T ? T extends { table: infer tableType } ? tableType : never @@ -862,6 +1045,49 @@ function quotePath(path: string) { .join('.') } +function quoteTableRef(table: TableRef) { + return table.schema + ? quotePath(table.schema + '.' + table.name) + : quotePath(table.name) +} + +function compileColumnDefinition(definition: { + type: string + nullable?: boolean + primaryKey?: boolean + unique?: boolean | { name?: string } + default?: unknown + autoIncrement?: boolean + length?: number + precision?: number + scale?: number +}) { + const parts = [compileColumnType(definition)] + if (definition.primaryKey) parts.push('primary key') + if (definition.autoIncrement) parts.push('autoincrement') + if (definition.nullable === false) parts.push('not null') + if (definition.unique) parts.push('unique') + return parts.join(' ') +} + +function compileColumnType(definition: { + type: string + length?: number + precision?: number + scale?: number +}) { + if (definition.type === 'integer' || definition.type === 'bigint') + return 'integer' + if (definition.type === 'decimal') { + return definition.precision === undefined + ? 'real' + : `decimal(${definition.precision}, ${definition.scale ?? 0})` + } + if (definition.type === 'boolean') return 'integer' + if (definition.type === 'binary') return 'blob' + return 'text' +} + function pushValue(context: SqliteCompileContext, value: unknown) { context.values.push(normalizeBoundValue(value)) return '?' diff --git a/worker/db.ts b/worker/db.ts index 346353c3..c6ac0b9b 100644 --- a/worker/db.ts +++ b/worker/db.ts @@ -1,43 +1,42 @@ -import { createDatabase, createTable, sql } from 'remix/data-table' -import { nullable, number, optional, string } from 'remix/data-schema' +import { column as c, createDatabase, table, sql } from 'remix/data-table' import { createD1DataTableAdapter } from './d1-data-table-adapter.ts' -export const usersTable = createTable({ +export const usersTable = table({ name: 'users', columns: { - id: number(), - username: string(), - email: string(), - password_hash: string(), - created_at: string(), - updated_at: string(), + id: c.integer(), + username: c.text(), + email: c.text(), + password_hash: c.text(), + created_at: c.text(), + updated_at: c.text(), }, primaryKey: 'id', }) -export const passwordResetsTable = createTable({ +export const passwordResetsTable = table({ name: 'password_resets', columns: { - id: number(), - user_id: number(), - token_hash: string(), - expires_at: number(), - created_at: string(), + id: c.integer(), + user_id: c.integer(), + token_hash: c.text(), + expires_at: c.integer(), + created_at: c.text(), }, primaryKey: 'id', }) -export const chatThreadsTable = createTable({ +export const chatThreadsTable = table({ name: 'chat_threads', columns: { - id: string(), - user_id: number(), - title: string(), - last_message_preview: string(), - message_count: number(), - created_at: string(), - updated_at: string(), - deleted_at: optional(nullable(string())), + id: c.text(), + user_id: c.integer(), + title: c.text(), + last_message_preview: c.text(), + message_count: c.integer(), + created_at: c.text(), + updated_at: c.text(), + deleted_at: c.text().nullable(), }, primaryKey: 'id', timestamps: {