@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.
- package/dist/lib/bootstrap-project.d.ts.map +1 -1
- package/dist/lib/bootstrap-project.js +32 -9
- package/dist/lib/cli.d.ts +29 -0
- package/dist/lib/cli.d.ts.map +1 -1
- package/dist/lib/cli.js +26 -4
- package/dist/lib/commands/completion.d.ts.map +1 -1
- package/dist/lib/commands/completion.js +5 -1
- package/dist/lib/commands/doctor.js +18 -18
- package/dist/lib/commands/routes.js +3 -3
- package/dist/lib/completion.js +1 -5
- package/dist/lib/controller-files.d.ts +0 -1
- package/dist/lib/controller-files.d.ts.map +1 -1
- package/dist/lib/controller-files.js +3 -5
- package/dist/lib/controller-ownership.d.ts +9 -9
- package/dist/lib/controller-ownership.d.ts.map +1 -1
- package/dist/lib/controller-ownership.js +56 -91
- package/dist/lib/doctor/controller-findings.d.ts +1 -1
- package/dist/lib/doctor/controller-findings.d.ts.map +1 -1
- package/dist/lib/doctor/controller-findings.js +15 -87
- package/dist/lib/doctor/controller-fix-plans.d.ts.map +1 -1
- package/dist/lib/doctor/controller-fix-plans.js +13 -24
- package/dist/lib/doctor/controller-placeholders.d.ts +2 -3
- package/dist/lib/doctor/controller-placeholders.d.ts.map +1 -1
- package/dist/lib/doctor/controller-placeholders.js +18 -149
- package/dist/lib/doctor/controllers.js +1 -1
- package/dist/lib/doctor/project.js +60 -52
- package/dist/lib/doctor/types.d.ts +2 -2
- package/dist/lib/doctor/types.d.ts.map +1 -1
- package/dist/lib/load-route-map-worker.js +17 -9
- package/dist/lib/route-map.d.ts +1 -1
- package/dist/lib/route-map.d.ts.map +1 -1
- package/dist/lib/route-map.js +29 -17
- package/package.json +3 -3
- package/src/lib/bootstrap-project.ts +39 -13
- package/src/lib/cli.ts +39 -4
- package/src/lib/commands/completion.ts +6 -1
- package/src/lib/commands/doctor.ts +18 -21
- package/src/lib/commands/routes.ts +3 -3
- package/src/lib/completion.ts +1 -5
- package/src/lib/controller-files.ts +4 -8
- package/src/lib/controller-ownership.ts +78 -141
- package/src/lib/doctor/controller-findings.ts +20 -97
- package/src/lib/doctor/controller-fix-plans.ts +13 -29
- package/src/lib/doctor/controller-placeholders.ts +17 -189
- package/src/lib/doctor/controllers.ts +1 -1
- package/src/lib/doctor/project.ts +60 -52
- package/src/lib/doctor/types.ts +1 -5
- package/src/lib/load-route-map-worker.ts +19 -10
- package/src/lib/route-map.ts +61 -16
- package/{bootstrap → template}/.agents/skills/remix/SKILL.md +160 -73
- package/{bootstrap → template}/.agents/skills/remix/references/assets-and-browser-modules.md +22 -14
- package/{bootstrap → template}/.agents/skills/remix/references/auth-and-sessions.md +41 -18
- package/{bootstrap → template}/.agents/skills/remix/references/data-and-validation.md +21 -5
- package/{bootstrap → template}/.agents/skills/remix/references/middleware-and-server.md +43 -53
- package/{bootstrap → template}/.agents/skills/remix/references/routing-and-controllers.md +111 -44
- package/{bootstrap → template}/.agents/skills/remix/references/testing-patterns.md +17 -1
- package/{bootstrap → template}/AGENTS.md +10 -9
- package/template/README.md +29 -0
- package/template/app/actions/controller.tsx +18 -0
- package/template/app/assets/entry.ts +8 -0
- package/{bootstrap/app/ui → template/app/assets}/prompt-button.tsx +2 -1
- package/{bootstrap → template}/app/assets.ts +5 -3
- package/template/app/middleware/render.tsx +43 -0
- package/template/app/router.ts +20 -0
- package/{bootstrap → template}/app/routes.ts +1 -2
- package/{bootstrap → template}/app/ui/document.tsx +11 -4
- package/{bootstrap → template}/app/ui/scaffold-home-page.tsx +39 -38
- package/template/gitignore +4 -0
- package/{bootstrap → template}/package.json +5 -6
- package/template/public/favicon.svg +11 -0
- package/{bootstrap → template}/server.ts +10 -11
- package/{bootstrap → template}/tsconfig.json +3 -2
- package/bootstrap/README.md +0 -27
- package/bootstrap/app/assets/entry.ts +0 -19
- package/bootstrap/app/controllers/auth.tsx +0 -21
- package/bootstrap/app/controllers/home.tsx +0 -11
- package/bootstrap/app/router.ts +0 -16
- package/bootstrap/app/ui/layout.tsx +0 -22
- package/bootstrap/app/utils/render.tsx +0 -26
- /package/{bootstrap → template}/.agents/skills/remix/references/animate-elements.md +0 -0
- /package/{bootstrap → template}/.agents/skills/remix/references/component-model.md +0 -0
- /package/{bootstrap → template}/.agents/skills/remix/references/create-mixins.md +0 -0
- /package/{bootstrap → template}/.agents/skills/remix/references/hydration-frames-navigation.md +0 -0
- /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
|
|
87
|
+
### Table lifecycle hooks
|
|
88
88
|
|
|
89
|
-
Tables can define
|
|
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
|
|
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/
|
|
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
|
|
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
|
-
-
|
|
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/
|
|
26
|
-
import { compression } from 'remix/compression
|
|
27
|
-
import { formData } from 'remix/form-data
|
|
28
|
-
import { logger } from 'remix/logger
|
|
29
|
-
import { methodOverride } from 'remix/method-override
|
|
30
|
-
import { session } from 'remix/session
|
|
31
|
-
import { staticFiles } from 'remix/static
|
|
32
|
-
import { asyncContext } from 'remix/async-context
|
|
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
|
|
57
|
-
| `compression()` | `remix/compression
|
|
58
|
-
| `logger()` | `remix/logger
|
|
59
|
-
| `cors(opts?)` | `remix/cors
|
|
60
|
-
| `cop(opts?)` | `remix/cop
|
|
61
|
-
| `formData(opts?)` | `remix/form-data
|
|
62
|
-
| `methodOverride()` | `remix/method-override
|
|
63
|
-
| `session(cookie, storage)` | `remix/session
|
|
64
|
-
| `csrf(opts?)` | `remix/csrf
|
|
65
|
-
| `asyncContext()` | `remix/async-context
|
|
66
|
-
| `auth({ schemes })` | `remix/auth
|
|
67
|
-
| `requireAuth()` | `remix/auth
|
|
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)
|
|
124
|
-
|
|
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/
|
|
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
|
|
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
|
|
167
|
-
import { Auth } from 'remix/auth
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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/
|
|
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/
|
|
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
|
|
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
|
|
100
|
+
import { createAction } from 'remix/router'
|
|
99
101
|
|
|
100
|
-
|
|
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
|
|
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`
|
|
199
|
-
|
|
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
|
|
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
|
|
220
|
-
|
|
221
|
-
`
|
|
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
|
|
225
|
-
|
|
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
|
-
}
|
|
252
|
+
})
|
|
243
253
|
```
|
|
244
254
|
|
|
245
|
-
###
|
|
255
|
+
### Root controller
|
|
246
256
|
|
|
247
|
-
|
|
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
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
290
|
+
```typescript
|
|
291
|
+
// app/actions/account/controller.tsx
|
|
292
|
+
export default createController(routes.account, {
|
|
264
293
|
middleware: [requireAuth()],
|
|
265
294
|
actions: {
|
|
266
|
-
index() {
|
|
267
|
-
|
|
268
|
-
|
|
295
|
+
index() {
|
|
296
|
+
return render(<AccountPage />)
|
|
297
|
+
},
|
|
269
298
|
},
|
|
270
|
-
}
|
|
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
|
|
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
|
-
}
|
|
340
|
+
})
|
|
285
341
|
```
|
|
286
342
|
|
|
287
343
|
## Registering Routes
|
|
288
344
|
|
|
289
|
-
Use `router.map` for route maps
|
|
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
|
|
351
|
+
// Route maps → controllers
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
|
16
|
+
Refer to ./.agents/skills/remix/SKILL.md
|
|
17
17
|
|
|
18
18
|
## Starter Layout
|
|
19
19
|
|
|
20
|
-
- `app/
|
|
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/
|
|
25
|
-
- `app/
|
|
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
|
-
-
|
|
31
|
-
-
|
|
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/
|
|
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
|
|
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
|
+
})
|
|
@@ -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
|
-
|
|
14
|
+
import.meta.url,
|
|
14
15
|
function PromptButton(handle: Handle<PromptButtonProps>) {
|
|
15
16
|
let state: CopyState = 'idle'
|
|
16
17
|
|