@remix-run/cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/lib/bootstrap-project.d.ts.map +1 -1
  2. package/dist/lib/bootstrap-project.js +32 -9
  3. package/dist/lib/cli.d.ts +29 -0
  4. package/dist/lib/cli.d.ts.map +1 -1
  5. package/dist/lib/cli.js +26 -4
  6. package/dist/lib/commands/completion.d.ts.map +1 -1
  7. package/dist/lib/commands/completion.js +5 -1
  8. package/dist/lib/commands/doctor.js +18 -18
  9. package/dist/lib/commands/routes.js +3 -3
  10. package/dist/lib/completion.js +1 -5
  11. package/dist/lib/controller-files.d.ts +0 -1
  12. package/dist/lib/controller-files.d.ts.map +1 -1
  13. package/dist/lib/controller-files.js +3 -5
  14. package/dist/lib/controller-ownership.d.ts +9 -9
  15. package/dist/lib/controller-ownership.d.ts.map +1 -1
  16. package/dist/lib/controller-ownership.js +56 -91
  17. package/dist/lib/doctor/controller-findings.d.ts +1 -1
  18. package/dist/lib/doctor/controller-findings.d.ts.map +1 -1
  19. package/dist/lib/doctor/controller-findings.js +15 -87
  20. package/dist/lib/doctor/controller-fix-plans.d.ts.map +1 -1
  21. package/dist/lib/doctor/controller-fix-plans.js +13 -24
  22. package/dist/lib/doctor/controller-placeholders.d.ts +2 -3
  23. package/dist/lib/doctor/controller-placeholders.d.ts.map +1 -1
  24. package/dist/lib/doctor/controller-placeholders.js +18 -149
  25. package/dist/lib/doctor/controllers.js +1 -1
  26. package/dist/lib/doctor/project.js +60 -52
  27. package/dist/lib/doctor/types.d.ts +2 -2
  28. package/dist/lib/doctor/types.d.ts.map +1 -1
  29. package/dist/lib/load-route-map-worker.js +17 -9
  30. package/dist/lib/route-map.d.ts +1 -1
  31. package/dist/lib/route-map.d.ts.map +1 -1
  32. package/dist/lib/route-map.js +29 -17
  33. package/package.json +3 -3
  34. package/src/lib/bootstrap-project.ts +39 -13
  35. package/src/lib/cli.ts +39 -4
  36. package/src/lib/commands/completion.ts +6 -1
  37. package/src/lib/commands/doctor.ts +18 -21
  38. package/src/lib/commands/routes.ts +3 -3
  39. package/src/lib/completion.ts +1 -5
  40. package/src/lib/controller-files.ts +4 -8
  41. package/src/lib/controller-ownership.ts +78 -141
  42. package/src/lib/doctor/controller-findings.ts +20 -97
  43. package/src/lib/doctor/controller-fix-plans.ts +13 -29
  44. package/src/lib/doctor/controller-placeholders.ts +17 -189
  45. package/src/lib/doctor/controllers.ts +1 -1
  46. package/src/lib/doctor/project.ts +60 -52
  47. package/src/lib/doctor/types.ts +1 -5
  48. package/src/lib/load-route-map-worker.ts +19 -10
  49. package/src/lib/route-map.ts +61 -16
  50. package/{bootstrap → template}/.agents/skills/remix/SKILL.md +160 -73
  51. package/{bootstrap → template}/.agents/skills/remix/references/assets-and-browser-modules.md +22 -14
  52. package/{bootstrap → template}/.agents/skills/remix/references/auth-and-sessions.md +41 -18
  53. package/{bootstrap → template}/.agents/skills/remix/references/data-and-validation.md +21 -5
  54. package/{bootstrap → template}/.agents/skills/remix/references/middleware-and-server.md +43 -53
  55. package/{bootstrap → template}/.agents/skills/remix/references/routing-and-controllers.md +111 -44
  56. package/{bootstrap → template}/.agents/skills/remix/references/testing-patterns.md +17 -1
  57. package/{bootstrap → template}/AGENTS.md +10 -9
  58. package/template/README.md +29 -0
  59. package/template/app/actions/controller.tsx +18 -0
  60. package/template/app/assets/entry.ts +8 -0
  61. package/{bootstrap/app/ui → template/app/assets}/prompt-button.tsx +2 -1
  62. package/{bootstrap → template}/app/assets.ts +5 -3
  63. package/template/app/middleware/render.tsx +43 -0
  64. package/template/app/router.ts +20 -0
  65. package/{bootstrap → template}/app/routes.ts +1 -2
  66. package/{bootstrap → template}/app/ui/document.tsx +11 -4
  67. package/{bootstrap → template}/app/ui/scaffold-home-page.tsx +39 -38
  68. package/template/gitignore +4 -0
  69. package/{bootstrap → template}/package.json +5 -6
  70. package/template/public/favicon.svg +11 -0
  71. package/{bootstrap → template}/server.ts +10 -11
  72. package/{bootstrap → template}/tsconfig.json +3 -2
  73. package/bootstrap/README.md +0 -27
  74. package/bootstrap/app/assets/entry.ts +0 -19
  75. package/bootstrap/app/controllers/auth.tsx +0 -21
  76. package/bootstrap/app/controllers/home.tsx +0 -11
  77. package/bootstrap/app/router.ts +0 -16
  78. package/bootstrap/app/ui/layout.tsx +0 -22
  79. package/bootstrap/app/utils/render.tsx +0 -26
  80. /package/{bootstrap → template}/.agents/skills/remix/references/animate-elements.md +0 -0
  81. /package/{bootstrap → template}/.agents/skills/remix/references/component-model.md +0 -0
  82. /package/{bootstrap → template}/.agents/skills/remix/references/create-mixins.md +0 -0
  83. /package/{bootstrap → template}/.agents/skills/remix/references/hydration-frames-navigation.md +0 -0
  84. /package/{bootstrap → template}/.agents/skills/remix/references/mixins-styling-events.md +0 -0
@@ -84,9 +84,16 @@ runtime only needs the column shape and validation hooks. Two valid patterns:
84
84
 
85
85
  Pick one and apply it consistently across the app.
86
86
 
87
- ### Table validation hooks
87
+ ### Table lifecycle hooks
88
88
 
89
- Tables can define `validate`, `beforeWrite`, and `afterRead` hooks:
89
+ Tables can define validation and lifecycle hooks:
90
+
91
+ - `validate` runs before `create` and `update` writes and should return either `{ value }` or
92
+ `{ issues }`
93
+ - `beforeWrite` can normalize or veto `create`/`update` values
94
+ - `afterWrite` observes completed `create`/`update` operations
95
+ - `beforeDelete` and `afterDelete` observe or veto deletes
96
+ - `afterRead` can normalize or reject row values after reads
90
97
 
91
98
  ```typescript
92
99
  export const books = table({
@@ -94,6 +101,12 @@ export const books = table({
94
101
  columns: {
95
102
  /* ... */
96
103
  },
104
+ beforeWrite({ value }) {
105
+ if (typeof value.slug === 'string') {
106
+ return { value: { ...value, slug: value.slug.trim().toLowerCase() } }
107
+ }
108
+ return { value }
109
+ },
97
110
  validate({ operation, value }) {
98
111
  let issues = []
99
112
  if (operation === 'create' && !value.slug) {
@@ -101,6 +114,9 @@ export const books = table({
101
114
  }
102
115
  return issues.length > 0 ? { issues } : { value }
103
116
  },
117
+ afterRead({ value }) {
118
+ return { value }
119
+ },
104
120
  })
105
121
  ```
106
122
 
@@ -111,7 +127,7 @@ Create a database with an adapter and expose it via middleware:
111
127
  ```typescript
112
128
  import BetterSqlite3 from 'better-sqlite3'
113
129
  import { createDatabase, Database } from 'remix/data-table'
114
- import { createSqliteDatabaseAdapter } from 'remix/data-table-sqlite'
130
+ import { createSqliteDatabaseAdapter } from 'remix/data-table/sqlite'
115
131
 
116
132
  let sqlite = new BetterSqlite3('./db/app.db')
117
133
  sqlite.pragma('foreign_keys = ON')
@@ -126,7 +142,7 @@ client fits the runtime instead of assuming `better-sqlite3` is required.
126
142
  ### Database middleware
127
143
 
128
144
  ```typescript
129
- import type { Middleware } from 'remix/fetch-router'
145
+ import type { Middleware } from 'remix/router'
130
146
  import { Database } from 'remix/data-table'
131
147
 
132
148
  export function loadDatabase(): Middleware {
@@ -284,7 +300,7 @@ The recommended way: register `formData()` middleware in the root stack and read
284
300
  the context system. This also lets `methodOverride()` and CSRF middleware work uniformly.
285
301
 
286
302
  ```typescript
287
- import { formData } from 'remix/form-data-middleware'
303
+ import { formData } from 'remix/middleware/form-data'
288
304
 
289
305
  let router = createRouter({
290
306
  middleware: [, /* ... */ formData() /* ... */],
@@ -9,7 +9,7 @@ involves:
9
9
  - Writing custom middleware that sets typed context values
10
10
  - Adding fast-exit handling (static files, CORS preflights) versus request-enriching layers
11
11
  (sessions, auth, data loading)
12
- - Booting a Node `http` server with `createRequestListener`
12
+ - Choosing when to keep the generated Node server versus switching server adapters
13
13
 
14
14
  For data and persistence specifics, see `data-and-validation.md`. For session and auth specifics,
15
15
  see `auth-and-sessions.md`.
@@ -22,14 +22,14 @@ request-enriching middleware (session, auth) later.
22
22
  Recommended ordering:
23
23
 
24
24
  ```typescript
25
- import { createRouter } from 'remix/fetch-router'
26
- import { compression } from 'remix/compression-middleware'
27
- import { formData } from 'remix/form-data-middleware'
28
- import { logger } from 'remix/logger-middleware'
29
- import { methodOverride } from 'remix/method-override-middleware'
30
- import { session } from 'remix/session-middleware'
31
- import { staticFiles } from 'remix/static-middleware'
32
- import { asyncContext } from 'remix/async-context-middleware'
25
+ import { createRouter } from 'remix/router'
26
+ import { compression } from 'remix/middleware/compression'
27
+ import { formData } from 'remix/middleware/form-data'
28
+ import { logger } from 'remix/middleware/logger'
29
+ import { methodOverride } from 'remix/middleware/method-override'
30
+ import { session } from 'remix/middleware/session'
31
+ import { staticFiles } from 'remix/middleware/static'
32
+ import { asyncContext } from 'remix/middleware/async-context'
33
33
 
34
34
  let middleware = []
35
35
 
@@ -53,18 +53,18 @@ let router = createRouter({ middleware })
53
53
 
54
54
  | Middleware | Import | Use when | Notes |
55
55
  | -------------------------- | ---------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------- |
56
- | `staticFiles(dir, opts?)` | `remix/static-middleware` | Serve files from `public/` or another directory exactly as they exist on disk | Fast exit; usually near the top |
57
- | `compression()` | `remix/compression-middleware` | Compress text-like responses | Usually global |
58
- | `logger()` | `remix/logger-middleware` | Log requests and responses | Often development-only; `colors` can force color output on/off |
59
- | `cors(opts?)` | `remix/cors-middleware` | Endpoints must serve cross-origin browsers or preflight `OPTIONS` requests | Usually early so preflights can short-circuit |
60
- | `cop(opts?)` | `remix/cop-middleware` | Reject unsafe cross-origin browser requests without synchronizer tokens | Put before session or CSRF when used |
61
- | `formData(opts?)` | `remix/form-data-middleware` | Parse `FormData` bodies, especially forms and uploads | Needed for `_csrf` form field extraction |
62
- | `methodOverride()` | `remix/method-override-middleware` | HTML forms need `PUT`, `PATCH`, or `DELETE` semantics | Run after form parsing |
63
- | `session(cookie, storage)` | `remix/session-middleware` | Cookie-backed sessions | Must run before session-backed auth or CSRF |
64
- | `csrf(opts?)` | `remix/csrf-middleware` | Session-backed form workflows need synchronizer-token CSRF protection | Requires `session()` before it |
65
- | `asyncContext()` | `remix/async-context-middleware` | Helpers outside handlers need request context via `getContext()` | Add before helpers rely on it |
66
- | `auth({ schemes })` | `remix/auth-middleware` | Resolve auth state into `context.get(Auth)` | Run after `session()` for session-backed auth |
67
- | `requireAuth()` | `remix/auth-middleware` | A controller or action must reject anonymous access | Usually controller-level or action-level, not global |
56
+ | `staticFiles(dir, opts?)` | `remix/middleware/static` | Serve files from `public/` or another directory exactly as they exist on disk | Fast exit; usually near the top |
57
+ | `compression()` | `remix/middleware/compression` | Compress text-like responses | Usually global |
58
+ | `logger()` | `remix/middleware/logger` | Log requests and responses | Often development-only; `colors` can force color output on/off |
59
+ | `cors(opts?)` | `remix/middleware/cors` | Endpoints must serve cross-origin browsers or preflight `OPTIONS` requests | Usually early so preflights can short-circuit |
60
+ | `cop(opts?)` | `remix/middleware/cop` | Reject unsafe cross-origin browser requests without synchronizer tokens | Put before session or CSRF when used |
61
+ | `formData(opts?)` | `remix/middleware/form-data` | Parse `FormData` bodies, especially forms and uploads | Needed for `_csrf` form field extraction |
62
+ | `methodOverride()` | `remix/middleware/method-override` | HTML forms need `PUT`, `PATCH`, or `DELETE` semantics | Run after form parsing |
63
+ | `session(cookie, storage)` | `remix/middleware/session` | Cookie-backed sessions | Must run before session-backed auth or CSRF |
64
+ | `csrf(opts?)` | `remix/middleware/csrf` | Session-backed form workflows need synchronizer-token CSRF protection | Requires `session()` before it |
65
+ | `asyncContext()` | `remix/middleware/async-context` | Helpers outside handlers need request context via `getContext()` | Add before helpers rely on it |
66
+ | `auth({ schemes })` | `remix/middleware/auth` | Resolve auth state into `context.get(Auth)` | Run after `session()` for session-backed auth |
67
+ | `requireAuth()` | `remix/middleware/auth` | A controller or action must reject anonymous access | Usually controller-level or action-level, not global |
68
68
 
69
69
  ### Static files vs browser modules
70
70
 
@@ -103,7 +103,7 @@ staticFiles('./public', {
103
103
  })
104
104
 
105
105
  // Form data with upload handler
106
- import { FileUpload } from 'remix/form-data-parser'
106
+ import type { FileUpload } from 'remix/form-data-parser'
107
107
  import { createFsFileStorage } from 'remix/file-storage/fs'
108
108
 
109
109
  let fileStorage = createFsFileStorage('./tmp/uploads')
@@ -120,15 +120,16 @@ errors at the route boundary when they should become user-facing `Response` obje
120
120
 
121
121
  ## Writing Custom Middleware
122
122
 
123
- Middleware is a function that receives `(context, next)` and returns a `Response`. Call `next()` to
124
- continue the chain.
123
+ Middleware is a function that receives `(context, next)`. Return a `Response` to short-circuit, call
124
+ and return `next()` when you need the downstream response, or return nothing when you only set
125
+ context and want the router to continue automatically.
125
126
 
126
127
  ### Setting context values
127
128
 
128
129
  Use `context.set(key, value)` to add typed values accessible downstream via `context.get(key)`.
129
130
 
130
131
  ```typescript
131
- import type { Middleware } from 'remix/fetch-router'
132
+ import type { Middleware } from 'remix/router'
132
133
  import { Database } from 'remix/data-table'
133
134
 
134
135
  export function loadDatabase(): Middleware {
@@ -142,7 +143,7 @@ export function loadDatabase(): Middleware {
142
143
  ### Guarding routes
143
144
 
144
145
  ```typescript
145
- import { Auth } from 'remix/auth-middleware'
146
+ import { Auth } from 'remix/middleware/auth'
146
147
 
147
148
  export function requireAdmin(): Middleware {
148
149
  return (context, next) => {
@@ -163,8 +164,8 @@ helpers:
163
164
 
164
165
  ```typescript
165
166
  // app/utils/context.ts
166
- import { getContext } from 'remix/async-context-middleware'
167
- import { Auth } from 'remix/auth-middleware'
167
+ import { getContext } from 'remix/middleware/async-context'
168
+ import { Auth } from 'remix/middleware/auth'
168
169
  import { Database } from 'remix/data-table'
169
170
  import { Session } from 'remix/session'
170
171
 
@@ -200,18 +201,21 @@ Middleware can be applied at three levels:
200
201
  let router = createRouter({ middleware: [...] })
201
202
  ```
202
203
 
203
- 2. **Controller-level** — runs for all actions in a controller subtree:
204
+ 2. **Controller-level** — runs for the direct actions in one controller:
204
205
 
205
206
  ```typescript
206
- export default {
207
+ export default createController(routes.account, {
207
208
  middleware: [requireAuth()],
208
209
  actions: { ... },
209
- } satisfies Controller<typeof routes.account>
210
+ })
210
211
  ```
211
212
 
213
+ Controller middleware does not flow into other controllers. Add the middleware to each
214
+ controller that needs it.
215
+
212
216
  3. **Action-level** — runs for a single route:
213
217
  ```typescript
214
- router.get(routes.account, {
218
+ router.get(routes.account.index, {
215
219
  middleware: [requireAuth()],
216
220
  handler: accountAction.handler,
217
221
  })
@@ -219,25 +223,11 @@ Middleware can be applied at three levels:
219
223
 
220
224
  ## Node Server Setup
221
225
 
222
- Use `createRequestListener` to bridge Node's `http` module to the Fetch API router:
226
+ New apps already include a `server.ts` that adapts the app router with
227
+ `remix/node-fetch-server`. Keep that generated server unless the task specifically needs to change
228
+ runtime behavior such as host/protocol handling, TLS, HTTP/2, WebSockets, deployment lifecycle, or
229
+ test-only server setup.
223
230
 
224
- ```typescript
225
- import * as http from 'node:http'
226
- import { createRequestListener } from 'remix/node-fetch-server'
227
-
228
- let server = http.createServer(
229
- createRequestListener(async (request) => {
230
- try {
231
- return await router.fetch(request)
232
- } catch (error) {
233
- console.error(error)
234
- return new Response('Internal Server Error', { status: 500 })
235
- }
236
- }),
237
- )
238
-
239
- let port = Number(process.env.PORT) || 3000
240
- server.listen(port, () => {
241
- console.log(`http://localhost:${port}`)
242
- })
243
- ```
231
+ Use `remix/node-fetch-server` when you want to keep owning a standard Node `http`, `https`, or
232
+ `http2` server directly. Use `remix/node-serve` and `serve()` when you want a managed
233
+ high-performance server with `server.ready`, TLS options, or uWebSockets.js setup.
@@ -17,7 +17,7 @@ data is `data-and-validation.md`. For request lifecycle and middleware ordering,
17
17
 
18
18
  ## Route Builders
19
19
 
20
- Import all route builders from `remix/fetch-router/routes`.
20
+ Import all route builders from `remix/routes`.
21
21
 
22
22
  ### `route(prefix, map)` — nested route group
23
23
 
@@ -27,7 +27,7 @@ call (when you want a shared URL prefix) or a plain object literal (when each le
27
27
  its absolute path).
28
28
 
29
29
  ```typescript
30
- import { route, get, post } from 'remix/fetch-router/routes'
30
+ import { route, get, post } from 'remix/routes'
31
31
 
32
32
  export const routes = route({
33
33
  home: '/',
@@ -92,18 +92,22 @@ redirect(routes.account.orders.show.href({ orderId: '42' }))
92
92
 
93
93
  ## Actions
94
94
 
95
- An action is a handler for a single leaf route. Type it with `BuildAction`:
95
+ An action is the handler for one leaf route. In Remix app code, actions should live in controllers.
96
+ Use `Action` only when a reusable helper needs to type one action before it is added to a
97
+ controller or when you are doing low-level router wiring outside the `app/actions` convention:
96
98
 
97
99
  ```typescript
98
- import type { BuildAction } from 'remix/fetch-router'
100
+ import { createAction } from 'remix/router'
99
101
 
100
- export const search: BuildAction<'GET', typeof routes.search> = {
102
+ import { routes } from '../routes.ts'
103
+
104
+ export const search = createAction(routes.search, {
101
105
  async handler({ url }) {
102
106
  let query = url.searchParams.get('q') ?? ''
103
107
  let results = await searchIndex(query)
104
108
  return render(<SearchPage query={query} results={results} />)
105
109
  },
106
- }
110
+ })
107
111
  ```
108
112
 
109
113
  The handler receives a context object with:
@@ -116,9 +120,9 @@ The handler receives a context object with:
116
120
  Actions with inline middleware:
117
121
 
118
122
  ```typescript
119
- import { requireAuth } from 'remix/auth-middleware'
123
+ import { requireAuth } from 'remix/middleware/auth'
120
124
 
121
- router.get(routes.account, {
125
+ router.get(routes.account.index, {
122
126
  middleware: [requireAuth()],
123
127
  handler: accountAction.handler,
124
128
  })
@@ -195,17 +199,18 @@ if (!parsed.success) {
195
199
  ### Return JSON
196
200
 
197
201
  For routes consumed by client code rather than rendered as a page (autocomplete endpoints, polling
198
- APIs, inter-service calls), return a JSON `Response`. Use `remix/headers` for cache headers
199
- instead of hand-formatting strings:
202
+ APIs, inter-service calls), return a JSON `Response`. Use `SuperHeaders` from `remix/headers` when
203
+ typed header accessors make the response clearer:
200
204
 
201
205
  ```typescript
202
- import { CacheControl } from 'remix/headers'
206
+ import Headers from 'remix/headers'
207
+
208
+ let headers = new Headers()
209
+ headers.contentType = { mediaType: 'application/json', charset: 'utf-8' }
210
+ headers.cacheControl = { noStore: true }
203
211
 
204
212
  return new Response(JSON.stringify({ results }), {
205
- headers: {
206
- 'Content-Type': 'application/json; charset=utf-8',
207
- 'Cache-Control': new CacheControl({ noStore: true }).toString(),
208
- },
213
+ headers,
209
214
  })
210
215
  ```
211
216
 
@@ -216,15 +221,20 @@ with the server.
216
221
 
217
222
  ## Controllers
218
223
 
219
- A controller mirrors a route map. Each key in `actions` matches a key in the route definition.
220
- Pass `AppContext` as the second generic to `Controller` so `get(Database)`, `get(Session)`,
221
- `get(Auth)`, etc. are typed against your middleware stack.
224
+ A controller owns the direct leaf routes in one route map. Each key in `actions` matches a direct
225
+ leaf route key in the route definition passed to `router.map(...)`. Nested route-map keys do not
226
+ belong inside a controller's `actions`; map those route maps with their own controllers.
227
+
228
+ Configure `RouterTypes.context` with your app context in the router module, then use
229
+ `createController()` so `get(Database)`, `get(Session)`, `get(Auth)`, etc. are typed against your
230
+ middleware stack without repeating a type clause on every controller.
222
231
 
223
232
  ```typescript
224
- import type { Controller } from 'remix/fetch-router'
225
- import type { AppContext } from '../router.ts'
233
+ import { createController } from 'remix/router'
234
+
235
+ import { routes } from '../routes.ts'
226
236
 
227
- export default {
237
+ export default createController(routes.books, {
228
238
  actions: {
229
239
  async index({ get }) {
230
240
  let db = get(Database)
@@ -239,74 +249,125 @@ export default {
239
249
  return render(<ShowPage book={book} />)
240
250
  },
241
251
  },
242
- } satisfies Controller<typeof routes.books, AppContext>
252
+ })
243
253
  ```
244
254
 
245
- ### Nested controllers
255
+ ### Root controller
246
256
 
247
- When a route map contains nested maps, the controller nests too:
257
+ The root route map uses `app/actions/controller.tsx` and owns only top-level leaf routes:
248
258
 
249
259
  ```typescript
250
260
  // routes.ts
251
261
  export const routes = route({
262
+ assets: get('/assets/*path'),
263
+ home: '/',
252
264
  account: route('account', {
253
265
  index: '/',
254
266
  settings: form('settings', { formMethod: 'PUT', names: { action: 'update' } }),
255
- orders: resources('orders', { only: ['index', 'show'], param: 'orderId' }),
256
267
  }),
257
268
  })
258
269
 
259
- // controllers/account/controller.tsx
260
- import settingsController from './settings/controller.tsx'
261
- import ordersController from './orders/controller.tsx'
270
+ // app/actions/controller.tsx
271
+ export default createController(routes, {
272
+ actions: {
273
+ async assets({ request }) {
274
+ return (await assetServer.fetch(request)) ?? new Response('Not Found', { status: 404 })
275
+ },
276
+ home() {
277
+ return render(<HomePage />)
278
+ },
279
+ },
280
+ })
281
+ ```
282
+
283
+ Because `account` is a nested route map, it is not an action key in the root controller.
284
+
285
+ ### Nested route maps
286
+
287
+ Nested route maps use their own controllers under `app/actions/<route-key>/controller.tsx`.
288
+ Directory names under `app/actions/` are route-map keys, not URL path segments.
262
289
 
263
- export default {
290
+ ```typescript
291
+ // app/actions/account/controller.tsx
292
+ export default createController(routes.account, {
264
293
  middleware: [requireAuth()],
265
294
  actions: {
266
- index() { return render(<AccountPage />) },
267
- settings: settingsController,
268
- orders: ordersController,
295
+ index() {
296
+ return render(<AccountPage />)
297
+ },
269
298
  },
270
- } satisfies Controller<typeof routes.account, AppContext>
299
+ })
300
+
301
+ // app/actions/account/settings/controller.tsx
302
+ export default createController(routes.account.settings, {
303
+ middleware: [requireAuth()],
304
+ actions: {
305
+ index() {
306
+ return render(<SettingsPage />)
307
+ },
308
+ update() {
309
+ return redirect(routes.account.index.href(), 303)
310
+ },
311
+ },
312
+ })
313
+ ```
314
+
315
+ Then map each route map explicitly:
316
+
317
+ ```typescript
318
+ import rootController from './actions/controller.tsx'
319
+ import accountController from './actions/account/controller.tsx'
320
+ import accountSettingsController from './actions/account/settings/controller.tsx'
321
+
322
+ let router = createRouter({ middleware })
323
+
324
+ router.map(routes, rootController)
325
+ router.map(routes.account, accountController)
326
+ router.map(routes.account.settings, accountSettingsController)
271
327
  ```
272
328
 
273
329
  ### Controller middleware
274
330
 
275
- The `middleware` array on a controller runs for every action in that subtree, before action-level
276
- middleware:
331
+ The `middleware` array on a controller runs only for the direct actions in that controller, before
332
+ action-level middleware. It does not apply to other controllers.
277
333
 
278
334
  ```typescript
279
- export default {
335
+ export default createController(routes.admin, {
280
336
  middleware: [requireAuth(), requireAdmin()],
281
337
  actions: {
282
338
  /* all actions require auth + admin */
283
339
  },
284
- } satisfies Controller<typeof routes.admin, AppContext>
340
+ })
285
341
  ```
286
342
 
287
343
  ## Registering Routes
288
344
 
289
- Use `router.map` for route maps (controllers) and verb methods for leaf routes:
345
+ Use `router.map` for route maps and controllers. Map each nested route map explicitly. Use verb
346
+ methods only for low-level router wiring outside the `app/actions` controller convention.
290
347
 
291
348
  ```typescript
292
349
  let router = createRouter({ middleware })
293
350
 
294
- // Route mapcontroller
351
+ // Route mapscontrollers
352
+ router.map(routes, rootController)
353
+ router.map(routes.contact, contactController)
295
354
  router.map(routes.auth, authController)
355
+ router.map(routes.auth.login, authLoginController)
296
356
  router.map(routes.admin, adminController)
357
+ router.map(routes.admin.books, adminBooksController)
297
358
 
298
- // Leaf route → action
299
- router.map(routes.home, home)
359
+ // Leaf route → one-off action
300
360
  router.get(routes.search, searchAction)
301
361
  router.post(routes.logout, logoutAction)
302
362
  ```
303
363
 
304
364
  ## Typed Context
305
365
 
306
- Define an `AppContext` type from your middleware stack for use in actions and controllers:
366
+ Define an `AppContext` type from your middleware stack, then make it the default context used by
367
+ `createAction()` and `createController()`:
307
368
 
308
369
  ```typescript
309
- import type { MiddlewareContext, WithParams, AnyParams } from 'remix/fetch-router'
370
+ import type { MiddlewareContext, ContextWithParams, AnyParams } from 'remix/router'
310
371
 
311
372
  type RootMiddleware = [
312
373
  ReturnType<typeof formData>,
@@ -315,10 +376,16 @@ type RootMiddleware = [
315
376
  ReturnType<typeof loadAuth>,
316
377
  ]
317
378
 
318
- export type AppContext<params extends AnyParams = AnyParams> = WithParams<
379
+ export type AppContext<params extends AnyParams = {}> = ContextWithParams<
319
380
  MiddlewareContext<RootMiddleware>,
320
381
  params
321
382
  >
383
+
384
+ declare module 'remix/router' {
385
+ interface RouterTypes {
386
+ context: AppContext
387
+ }
388
+ }
322
389
  ```
323
390
 
324
391
  This gives typed `context.get(Database)`, `context.get(Session)`, `context.get(Auth)`, etc.
@@ -9,6 +9,7 @@ the task involves:
9
9
  - Building a fresh router per test for session, storage, or database isolation
10
10
  - Rendering components into a real DOM with `render(...)` or `createRoot(...)`
11
11
  - Configuring `remix test` discovery, excludes, and coverage
12
+ - Using adjacent CLI checks such as `remix routes`, `remix doctor`, and `remix version`
12
13
  - Choosing which layer to test for a given behavior
13
14
 
14
15
  For session and auth test setup, see `auth-and-sessions.md`. For component lifecycle, see
@@ -54,7 +55,7 @@ a known session, swap in `createMemorySessionStorage()` and a test cookie when c
54
55
  router.
55
56
 
56
57
  ```ts
57
- import { createMemorySessionStorage } from 'remix/session/memory-storage'
58
+ import { createMemorySessionStorage } from 'remix/session-storage/memory'
58
59
  import { createCookie } from 'remix/cookie'
59
60
 
60
61
  let router = createBookstoreRouter({
@@ -63,6 +64,21 @@ let router = createBookstoreRouter({
63
64
  })
64
65
  ```
65
66
 
67
+ Use `createTestServer` from `remix/node-fetch-server/test` when the behavior depends on a real
68
+ HTTP origin, redirects, streaming, cookies through a network boundary, or browser-style `fetch`:
69
+
70
+ ```ts
71
+ import { createTestServer } from 'remix/node-fetch-server/test'
72
+
73
+ let server = await createTestServer((request) => router.fetch(request))
74
+ try {
75
+ let response = await fetch(new URL(routes.home.href(), server.baseUrl))
76
+ assert.equal(response.status, 200)
77
+ } finally {
78
+ await server.close()
79
+ }
80
+ ```
81
+
66
82
  ## Test Runner Config
67
83
 
68
84
  Configure discovery and coverage in `remix-test.config.ts` or with CLI flags:
@@ -13,27 +13,28 @@ npm run typecheck
13
13
 
14
14
  ## Building Features
15
15
 
16
- Refer to ./agents/skills/remix/SKILL.md
16
+ Refer to ./.agents/skills/remix/SKILL.md
17
17
 
18
18
  ## Starter Layout
19
19
 
20
- - `app/controllers/home.tsx` owns the home page
21
- - `app/controllers/auth.tsx` owns the auth page
20
+ - `app/actions/controller.tsx` owns the top-level route actions
22
21
  - `app/routes.ts` defines the route contract
23
22
  - `app/router.ts` wires routes to route handlers
24
- - `app/ui/` holds the shared document and layout wrappers
25
- - `app/utils/render.tsx` centralizes HTML response rendering
23
+ - `app/middleware/render.tsx` installs the request-scoped renderer used by actions
24
+ - `app/ui/` holds the shared document shell and home page UI
25
+ - `app/assets.ts` owns the server-side asset pipeline used by the asset route and renderer
26
+ - `public/` contains static files served from the app root
26
27
 
27
28
  ## Route Ownership
28
29
 
29
30
  - Start from `app/routes.ts` and map each route to the narrowest owner on disk.
30
- - Keep simple pages in flat files like `app/controllers/home.tsx` and `app/controllers/auth.tsx`.
31
- - Promote a route into a controller folder with `controller.tsx` only when it gains nested routes, multiple actions, or route-owned modules.
31
+ - Put top-level route actions in `app/actions/controller.tsx`.
32
+ - Add `app/actions/<route-key>/controller.tsx` for nested route maps that need their own actions or middleware.
32
33
  - Keep route-owned page modules next to the route that owns them.
33
- - Move shared UI to `app/ui/`, not `app/controllers/`.
34
+ - Move shared UI to `app/ui/`, not `app/actions/`.
34
35
 
35
36
  ## Build-Out Notes
36
37
 
37
- - This starter intentionally begins small; add directories like `app/data/`, `app/middleware/`, `public/`, and `test/` only when you need them.
38
+ - This starter intentionally begins small; add directories like `app/data/` and `test/` only when you need them.
38
39
  - Prefer putting code in the narrowest owner before introducing shared modules.
39
40
  - Avoid generic dumping-ground directories like `app/lib/` or `app/components/`.
@@ -0,0 +1,29 @@
1
+ # %%RMX_APP_DISPLAY_NAME%%
2
+
3
+ A minimal Remix application starter with a home page.
4
+
5
+ ## Starter Shape
6
+
7
+ - `app/actions/controller.tsx` owns the top-level route actions.
8
+ - `app/routes.ts` defines the route contract.
9
+ - `app/router.ts` wires routes to handlers.
10
+ - `app/middleware/render.tsx` installs the request-scoped renderer used by actions.
11
+ - `app/ui/` holds the shared document shell and home page UI.
12
+ - `app/assets.ts` owns the server-side asset pipeline used by the asset route and renderer.
13
+ - `public/` contains static files served from the app root.
14
+
15
+ ## Growing The App
16
+
17
+ - Put top-level route actions in `app/actions/controller.tsx`.
18
+ - Add `app/actions/<route-key>/controller.tsx` when a nested route map needs its own actions or middleware.
19
+ - Add directories like `app/data/` or `test/` when the app actually needs them.
20
+ - Move shared UI into `app/ui/` once more than one route needs it.
21
+
22
+ ## Commands
23
+
24
+ ```sh
25
+ npm i
26
+ npm run start
27
+ npm test
28
+ npm run typecheck
29
+ ```
@@ -0,0 +1,18 @@
1
+ import { createController } from 'remix/router'
2
+
3
+ import { assetServer } from '../assets.ts'
4
+ import { routes } from '../routes.ts'
5
+ import { HomePage } from '../ui/scaffold-home-page.tsx'
6
+
7
+ export default createController(routes, {
8
+ actions: {
9
+ async assets(context) {
10
+ return (
11
+ (await assetServer.fetch(context.request)) ?? new Response('Not Found', { status: 404 })
12
+ )
13
+ },
14
+ home(context) {
15
+ return context.render(<HomePage />)
16
+ },
17
+ },
18
+ })
@@ -0,0 +1,8 @@
1
+ import { run } from 'remix/ui'
2
+
3
+ run({
4
+ async loadModule(moduleUrl, exportName) {
5
+ let mod = await import(moduleUrl)
6
+ return mod[exportName]
7
+ },
8
+ })
@@ -9,8 +9,9 @@ interface PromptButtonProps extends SerializableProps {
9
9
  text: string
10
10
  }
11
11
 
12
+ // This component hydrates independently; the rest of the page stays static HTML.
12
13
  export const PromptButton = clientEntry(
13
- '/assets/app/ui/prompt-button.tsx#PromptButton',
14
+ import.meta.url,
14
15
  function PromptButton(handle: Handle<PromptButtonProps>) {
15
16
  let state: CopyState = 'idle'
16
17