@remix-run/cli 0.1.0 → 0.2.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/README.md +0 -3
- package/bootstrap/.agents/skills/remix/SKILL.md +501 -0
- package/bootstrap/.agents/skills/remix/references/animate-elements.md +195 -0
- package/bootstrap/.agents/skills/remix/references/assets-and-browser-modules.md +122 -0
- package/bootstrap/.agents/skills/remix/references/auth-and-sessions.md +420 -0
- package/bootstrap/.agents/skills/remix/references/component-model.md +282 -0
- package/bootstrap/.agents/skills/remix/references/create-mixins.md +158 -0
- package/bootstrap/.agents/skills/remix/references/data-and-validation.md +363 -0
- package/bootstrap/.agents/skills/remix/references/hydration-frames-navigation.md +297 -0
- package/bootstrap/.agents/skills/remix/references/middleware-and-server.md +243 -0
- package/bootstrap/.agents/skills/remix/references/mixins-styling-events.md +213 -0
- package/bootstrap/.agents/skills/remix/references/routing-and-controllers.md +324 -0
- package/bootstrap/.agents/skills/remix/references/testing-patterns.md +156 -0
- package/bootstrap/AGENTS.md +4 -0
- package/bootstrap/app/assets/entry.ts +19 -0
- package/bootstrap/app/assets.ts +18 -0
- package/bootstrap/app/controllers/auth.tsx +2 -2
- package/bootstrap/app/controllers/home.tsx +3 -18
- package/bootstrap/app/router.ts +6 -0
- package/bootstrap/app/routes.ts +2 -1
- package/bootstrap/app/ui/document.tsx +6 -1
- package/bootstrap/app/ui/prompt-button.tsx +162 -0
- package/bootstrap/app/ui/scaffold-home-page.tsx +526 -0
- package/bootstrap/app/utils/render.tsx +22 -3
- package/bootstrap/server.ts +13 -13
- package/bootstrap/tsconfig.json +0 -1
- package/dist/lib/cli.d.ts.map +1 -1
- package/dist/lib/cli.js +7 -10
- package/dist/lib/commands/help.d.ts.map +1 -1
- package/dist/lib/commands/help.js +9 -33
- package/dist/lib/commands/test.d.ts +1 -1
- package/dist/lib/commands/test.d.ts.map +1 -1
- package/dist/lib/commands/test.js +8 -4
- package/dist/lib/completion.d.ts.map +1 -1
- package/dist/lib/completion.js +3 -101
- package/dist/lib/errors.d.ts +0 -6
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +0 -11
- package/package.json +3 -4
- package/src/lib/cli.ts +7 -11
- package/src/lib/commands/help.ts +9 -43
- package/src/lib/commands/test.ts +10 -4
- package/src/lib/completion.ts +3 -146
- package/src/lib/errors.ts +0 -12
- package/dist/lib/commands/skills.d.ts +0 -6
- package/dist/lib/commands/skills.d.ts.map +0 -1
- package/dist/lib/commands/skills.js +0 -222
- package/dist/lib/skills-cache.d.ts +0 -19
- package/dist/lib/skills-cache.d.ts.map +0 -1
- package/dist/lib/skills-cache.js +0 -89
- package/dist/lib/skills.d.ts +0 -30
- package/dist/lib/skills.d.ts.map +0 -1
- package/dist/lib/skills.js +0 -441
- package/src/lib/commands/skills.ts +0 -306
- package/src/lib/skills-cache.ts +0 -140
- package/src/lib/skills.ts +0 -706
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# Hydration, Frames, and Navigation
|
|
2
|
+
|
|
3
|
+
## What This Covers
|
|
4
|
+
|
|
5
|
+
How server-rendered UI becomes interactive in the browser, and how the page updates without a full
|
|
6
|
+
navigation. Read this when the task involves:
|
|
7
|
+
|
|
8
|
+
- Marking a component for client-side hydration with `clientEntry`
|
|
9
|
+
- Booting the client runtime with `run`
|
|
10
|
+
- Streaming server content into a region of the page with `<Frame>` and reloading those regions
|
|
11
|
+
- Triggering Navigation API transitions with `navigate(...)` or `link(...)`
|
|
12
|
+
- Server rendering with `renderToStream` or `renderToString`
|
|
13
|
+
- Managing the document `<head>`
|
|
14
|
+
|
|
15
|
+
For component-local state and updates, see `component-model.md`. For host-element behavior and
|
|
16
|
+
events, see `mixins-styling-events.md`.
|
|
17
|
+
|
|
18
|
+
## Server First, Then Hydrate
|
|
19
|
+
|
|
20
|
+
Make the server route correct before adding `clientEntry(...)`. A POST should already do the right
|
|
21
|
+
thing on its own — return HTML, a redirect, or an error response — and a GET should already render
|
|
22
|
+
the page the user expects. `clientEntry` exists to layer interactivity on top of UI that already
|
|
23
|
+
works without it.
|
|
24
|
+
|
|
25
|
+
When server state changes after a mutation, prefer reloading a `<Frame>` when the UI region already
|
|
26
|
+
maps cleanly to a server-rendered route. Frames re-fetch the same route, so the rendering logic
|
|
27
|
+
stays in one place and the client does not need a parallel "state" API.
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
on('submit', async (event, signal) => {
|
|
31
|
+
event.preventDefault()
|
|
32
|
+
await fetch(routes.cart.add.href(), {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
body: new FormData(event.currentTarget),
|
|
35
|
+
signal,
|
|
36
|
+
})
|
|
37
|
+
if (signal.aborted) return
|
|
38
|
+
await handle.frames.get('cart-summary')?.reload()
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Use polling or a small JSON state endpoint when the data changes outside this page, or when a tiny
|
|
43
|
+
shared widget would be heavier to model as a frame. Pick the lightest sync mechanism that preserves
|
|
44
|
+
clear ownership of rendering logic.
|
|
45
|
+
|
|
46
|
+
## Client Entries
|
|
47
|
+
|
|
48
|
+
Use `clientEntry` to mark a component for client-side hydration. In source-served apps, prefer the
|
|
49
|
+
source module's `import.meta.url` as the entry ID and let server rendering map it to the public
|
|
50
|
+
asset URL:
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
import { clientEntry, on, type Handle } from 'remix/ui'
|
|
54
|
+
|
|
55
|
+
export const Counter = clientEntry(
|
|
56
|
+
import.meta.url,
|
|
57
|
+
function Counter(handle: Handle<{ initialCount: number; label: string }>) {
|
|
58
|
+
let count = handle.props.initialCount
|
|
59
|
+
|
|
60
|
+
return () => (
|
|
61
|
+
<div>
|
|
62
|
+
<span>
|
|
63
|
+
{handle.props.label}: {count}
|
|
64
|
+
</span>
|
|
65
|
+
<button
|
|
66
|
+
mix={on('click', () => {
|
|
67
|
+
count++
|
|
68
|
+
handle.update()
|
|
69
|
+
})}
|
|
70
|
+
>
|
|
71
|
+
+
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
On the server, provide `resolveClientEntry` to `renderToStream(...)` so source file URLs become
|
|
80
|
+
browser-loadable asset URLs. Keep this resolution in the render helper so component modules do not
|
|
81
|
+
hard-code deployment-specific asset paths:
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
let stream = renderToStream(<App />, {
|
|
85
|
+
async resolveClientEntry(entryId, component) {
|
|
86
|
+
let exportName = entryId.split('#')[1] || component.name
|
|
87
|
+
if (!exportName) {
|
|
88
|
+
throw new Error(`Unable to resolve client entry export for ${entryId}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
href: await assetServer.getHref(entryId),
|
|
93
|
+
exportName,
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
If the module export name differs from the component function name, include `#ExportName` in the
|
|
100
|
+
entry ID or return the exact export name from `resolveClientEntry`. A render helper that only
|
|
101
|
+
supports source-owned entries can also fail fast when `entryId` is not a `file://` URL.
|
|
102
|
+
|
|
103
|
+
On the server, `clientEntry` components render like any other component. The server wraps their
|
|
104
|
+
output in comment markers and serializes props into a `<script type="application/json">` tag.
|
|
105
|
+
|
|
106
|
+
Client entry props must be serializable: strings, numbers, booleans, `null`, `undefined`, plain
|
|
107
|
+
objects/arrays of the above, JSX elements, and `<Frame>` elements. Functions and class instances
|
|
108
|
+
cannot be passed.
|
|
109
|
+
|
|
110
|
+
## Booting the Client
|
|
111
|
+
|
|
112
|
+
Use `run` to start the client runtime. It scans the document for client entry markers, loads
|
|
113
|
+
modules, and hydrates each one:
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
import { run } from 'remix/ui'
|
|
117
|
+
|
|
118
|
+
let app = run({
|
|
119
|
+
async loadModule(moduleUrl, exportName) {
|
|
120
|
+
let mod = await import(moduleUrl)
|
|
121
|
+
return mod[exportName]
|
|
122
|
+
},
|
|
123
|
+
async resolveFrame(src, signal, target) {
|
|
124
|
+
let headers = new Headers({ accept: 'text/html' })
|
|
125
|
+
if (target) headers.set('x-remix-target', target)
|
|
126
|
+
let response = await fetch(src, { headers, signal })
|
|
127
|
+
return response.body ?? (await response.text())
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
app.addEventListener('error', (event) => {
|
|
132
|
+
console.error('Component error:', event.error)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
await app.ready()
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### `run` options
|
|
139
|
+
|
|
140
|
+
- **`loadModule(moduleUrl, exportName)`** (required) — return the component function for each
|
|
141
|
+
client entry. Typically uses dynamic `import()`.
|
|
142
|
+
- **`resolveFrame(src, signal, target)`** (optional) — called when a `<Frame>` loads or reloads
|
|
143
|
+
content. `target` is available when frame targeting matters.
|
|
144
|
+
|
|
145
|
+
### `app` methods
|
|
146
|
+
|
|
147
|
+
- **`app.ready()`** — resolves when all initial client entries are hydrated
|
|
148
|
+
- **`app.flush()`** — synchronously flushes all pending updates
|
|
149
|
+
- **`app.dispose()`** — tears down all hydrated components
|
|
150
|
+
|
|
151
|
+
`app` is an `EventTarget` that emits `error` events from any hydrated component.
|
|
152
|
+
|
|
153
|
+
## Frames
|
|
154
|
+
|
|
155
|
+
A `<Frame>` renders server content into the page. Frames stream after the initial HTML, nest inside
|
|
156
|
+
other frames, contain client entries, and can be reloaded without full page navigation.
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
import { Frame } from 'remix/ui'
|
|
160
|
+
|
|
161
|
+
function App() {
|
|
162
|
+
return () => (
|
|
163
|
+
<div>
|
|
164
|
+
<Frame src="/sidebar" fallback={<div>Loading...</div>} />
|
|
165
|
+
<Frame name="main" src="/main-content" />
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Frame props
|
|
172
|
+
|
|
173
|
+
- **`src`** (required) — URL to fetch the frame content from
|
|
174
|
+
- **`fallback`** (optional) — content to show while loading; determines streaming behavior
|
|
175
|
+
- **`name`** (optional) — registers the frame for lookup via `handle.frames.get(name)`
|
|
176
|
+
- **`on`** (optional) — event handlers for events dispatched from the frame element
|
|
177
|
+
|
|
178
|
+
### Blocking vs non-blocking
|
|
179
|
+
|
|
180
|
+
- **Without `fallback`** (blocking) — the server waits for frame content before sending the initial
|
|
181
|
+
HTML chunk
|
|
182
|
+
- **With `fallback`** (non-blocking) — the fallback renders immediately; real content streams in
|
|
183
|
+
later and replaces it
|
|
184
|
+
|
|
185
|
+
### Reloading frames
|
|
186
|
+
|
|
187
|
+
Client entries inside a frame can trigger a reload:
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
// Reload the containing frame
|
|
191
|
+
handle.frame.reload()
|
|
192
|
+
|
|
193
|
+
// Reload an adjacent named frame
|
|
194
|
+
await handle.frames.get('cart-summary')?.reload()
|
|
195
|
+
|
|
196
|
+
// Reload the entire page/frame tree
|
|
197
|
+
handle.frames.top.reload()
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
When a frame reloads, matching DOM nodes are updated in place. Client entries receive updated props
|
|
201
|
+
while preserving their local component state.
|
|
202
|
+
|
|
203
|
+
### Nested frames
|
|
204
|
+
|
|
205
|
+
Frames can nest. Each frame owns its own DOM region and hydrates client entries independently.
|
|
206
|
+
During SSR, `handle.frame.src` points at the frame being rendered, while
|
|
207
|
+
`handle.frames.top.src` stays fixed at the outer document URL.
|
|
208
|
+
|
|
209
|
+
## Server Rendering
|
|
210
|
+
|
|
211
|
+
### `renderToStream`
|
|
212
|
+
|
|
213
|
+
Renders a component tree to a `ReadableStream<Uint8Array>`. Sends initial HTML immediately and
|
|
214
|
+
streams frame content as it resolves:
|
|
215
|
+
|
|
216
|
+
```tsx
|
|
217
|
+
import { renderToStream } from 'remix/ui/server'
|
|
218
|
+
|
|
219
|
+
let stream = renderToStream(<App />, {
|
|
220
|
+
frameSrc: request.url,
|
|
221
|
+
resolveFrame(src, target, context) {
|
|
222
|
+
let frameUrl = new URL(src, context?.currentFrameSrc ?? request.url)
|
|
223
|
+
return fetchHtml(frameUrl)
|
|
224
|
+
},
|
|
225
|
+
onError(error) {
|
|
226
|
+
console.error(error)
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
return new Response(stream, {
|
|
231
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
232
|
+
})
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Options:
|
|
236
|
+
|
|
237
|
+
- **`frameSrc`** — seeds SSR frame state; populates `handle.frame.src` and `handle.frames.top.src`
|
|
238
|
+
- **`topFrameSrc`** — overrides the root frame URL for nested frame renders (carry forward from
|
|
239
|
+
`resolveFrame` context)
|
|
240
|
+
- **`resolveFrame(src, target, context)`** — return HTML string, `ReadableStream<Uint8Array>`, or a
|
|
241
|
+
promise of either. `context.currentFrameSrc` is the containing frame URL; `context.topFrameSrc`
|
|
242
|
+
is the outer document URL
|
|
243
|
+
- **`onError(error)`** — called on rendering errors
|
|
244
|
+
|
|
245
|
+
### `renderToString`
|
|
246
|
+
|
|
247
|
+
Renders a component tree to a complete HTML string. Use for static pages or embedding HTML:
|
|
248
|
+
|
|
249
|
+
```tsx
|
|
250
|
+
import { renderToString } from 'remix/ui/server'
|
|
251
|
+
let html = await renderToString(<App />)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### CSS in SSR
|
|
255
|
+
|
|
256
|
+
Components using the `css` mixin have styles collected during rendering and emitted as a single
|
|
257
|
+
`<style>` tag in `<head>`. No client-side style injection needed.
|
|
258
|
+
|
|
259
|
+
## Navigation
|
|
260
|
+
|
|
261
|
+
Use real anchors for normal document navigation. For app-driven navigation:
|
|
262
|
+
|
|
263
|
+
- `navigate(href, options?)` — performs a Navigation API transition
|
|
264
|
+
- `link(href, options?)` mixin — makes any element behave like a navigation link
|
|
265
|
+
|
|
266
|
+
```tsx
|
|
267
|
+
import { navigate } from 'remix/ui'
|
|
268
|
+
navigate('/dashboard', { history: 'replace' })
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Options: `src`, `target`, `history` (`'push' | 'replace'`), `resetScroll`.
|
|
272
|
+
|
|
273
|
+
Attributes understood by the runtime: `rmx-target`, `rmx-src`, `rmx-document`.
|
|
274
|
+
|
|
275
|
+
## Head Management
|
|
276
|
+
|
|
277
|
+
Manage document head with an explicit `<head>` in your document structure:
|
|
278
|
+
|
|
279
|
+
```tsx
|
|
280
|
+
function App() {
|
|
281
|
+
return () => (
|
|
282
|
+
<html>
|
|
283
|
+
<head>
|
|
284
|
+
<title>Dashboard</title>
|
|
285
|
+
<meta name="description" content="Team dashboard" />
|
|
286
|
+
<link rel="stylesheet" href="/styles/app.css" />
|
|
287
|
+
</head>
|
|
288
|
+
<body>
|
|
289
|
+
<main>...</main>
|
|
290
|
+
</body>
|
|
291
|
+
</html>
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Put `title`, `meta`, `link`, and `style` tags inside an explicit `<head>`. Bare head-like tags
|
|
297
|
+
rendered outside `<head>` stay where they are — they are not moved into the document head for you.
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# Middleware and Server Setup
|
|
2
|
+
|
|
3
|
+
## What This Covers
|
|
4
|
+
|
|
5
|
+
How to compose the request lifecycle and bridge the router to a runtime. Read this when the task
|
|
6
|
+
involves:
|
|
7
|
+
|
|
8
|
+
- Choosing or ordering built-in middleware in the root stack
|
|
9
|
+
- Writing custom middleware that sets typed context values
|
|
10
|
+
- Adding fast-exit handling (static files, CORS preflights) versus request-enriching layers
|
|
11
|
+
(sessions, auth, data loading)
|
|
12
|
+
- Booting a Node `http` server with `createRequestListener`
|
|
13
|
+
|
|
14
|
+
For data and persistence specifics, see `data-and-validation.md`. For session and auth specifics,
|
|
15
|
+
see `auth-and-sessions.md`.
|
|
16
|
+
|
|
17
|
+
## Middleware Stack
|
|
18
|
+
|
|
19
|
+
Middleware runs in order for every request. Place fast-exit middleware (static files) early and
|
|
20
|
+
request-enriching middleware (session, auth) later.
|
|
21
|
+
|
|
22
|
+
Recommended ordering:
|
|
23
|
+
|
|
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'
|
|
33
|
+
|
|
34
|
+
let middleware = []
|
|
35
|
+
|
|
36
|
+
if (process.env.NODE_ENV === 'development') {
|
|
37
|
+
middleware.push(logger())
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
middleware.push(compression())
|
|
41
|
+
middleware.push(staticFiles('./public'))
|
|
42
|
+
middleware.push(formData())
|
|
43
|
+
middleware.push(methodOverride())
|
|
44
|
+
middleware.push(session(cookie, storage))
|
|
45
|
+
middleware.push(asyncContext())
|
|
46
|
+
middleware.push(loadDatabase())
|
|
47
|
+
middleware.push(loadAuth())
|
|
48
|
+
|
|
49
|
+
let router = createRouter({ middleware })
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Built-in middleware catalog
|
|
53
|
+
|
|
54
|
+
| Middleware | Import | Use when | Notes |
|
|
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 |
|
|
68
|
+
|
|
69
|
+
### Static files vs browser modules
|
|
70
|
+
|
|
71
|
+
- Use `staticFiles()` for files that should be served directly from disk, such as images, fonts,
|
|
72
|
+
or already-built assets in `public/`
|
|
73
|
+
- Use `remix/assets` when browser modules should be compiled and served from source files with
|
|
74
|
+
import rewriting, preloads, or fingerprinted URLs
|
|
75
|
+
|
|
76
|
+
### Ordering notes
|
|
77
|
+
|
|
78
|
+
- Put fast exits early: `staticFiles()`, `cors()` preflight handling, and `cop()` when used
|
|
79
|
+
- Parse request bodies before middleware that depends on them, such as `methodOverride()` and form
|
|
80
|
+
field token extraction in `csrf()`
|
|
81
|
+
- Run `session()` before `csrf()` and before session-backed `auth()`
|
|
82
|
+
- Add `asyncContext()` before helpers or shared code call `getContext()`
|
|
83
|
+
- Keep route protection like `requireAuth()` at controller or action scope unless the entire app is
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
### Common stacks
|
|
87
|
+
|
|
88
|
+
- **Session-backed HTML app** -> `compression()`, `staticFiles()`, optional `cop()`, `formData()`,
|
|
89
|
+
`methodOverride()`, `session()`, optional `csrf()`, `asyncContext()`, `auth({ schemes })`
|
|
90
|
+
- **Cross-origin API** -> `compression()`, `cors()`, optional `asyncContext()`, optional
|
|
91
|
+
`auth({ schemes })`
|
|
92
|
+
- **Upload flow** -> `compression()`, `staticFiles()`, `formData({ uploadHandler })`, then
|
|
93
|
+
sessions, auth, and data-loading middleware as needed
|
|
94
|
+
|
|
95
|
+
### Middleware with options
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
// Static files with cache headers
|
|
99
|
+
staticFiles('./public', {
|
|
100
|
+
cacheControl: 'no-store, must-revalidate',
|
|
101
|
+
etag: false,
|
|
102
|
+
lastModified: false,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// Form data with upload handler
|
|
106
|
+
import { FileUpload } from 'remix/form-data-parser'
|
|
107
|
+
import { createFsFileStorage } from 'remix/file-storage/fs'
|
|
108
|
+
|
|
109
|
+
let fileStorage = createFsFileStorage('./tmp/uploads')
|
|
110
|
+
|
|
111
|
+
formData({
|
|
112
|
+
uploadHandler(fileUpload: FileUpload) {
|
|
113
|
+
return fileStorage.set(fileUpload.name, fileUpload)
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Errors thrown or rejected by `uploadHandler` propagate directly. Catch domain-specific upload
|
|
119
|
+
errors at the route boundary when they should become user-facing `Response` objects.
|
|
120
|
+
|
|
121
|
+
## Writing Custom Middleware
|
|
122
|
+
|
|
123
|
+
Middleware is a function that receives `(context, next)` and returns a `Response`. Call `next()` to
|
|
124
|
+
continue the chain.
|
|
125
|
+
|
|
126
|
+
### Setting context values
|
|
127
|
+
|
|
128
|
+
Use `context.set(key, value)` to add typed values accessible downstream via `context.get(key)`.
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
import type { Middleware } from 'remix/fetch-router'
|
|
132
|
+
import { Database } from 'remix/data-table'
|
|
133
|
+
|
|
134
|
+
export function loadDatabase(): Middleware {
|
|
135
|
+
return async (context, next) => {
|
|
136
|
+
context.set(Database, db)
|
|
137
|
+
return next()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Guarding routes
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import { Auth } from 'remix/auth-middleware'
|
|
146
|
+
|
|
147
|
+
export function requireAdmin(): Middleware {
|
|
148
|
+
return (context, next) => {
|
|
149
|
+
let auth = context.get(Auth)
|
|
150
|
+
if (auth.identity?.role !== 'admin') {
|
|
151
|
+
return new Response('Forbidden', { status: 403 })
|
|
152
|
+
}
|
|
153
|
+
return next()
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Async context for helpers
|
|
159
|
+
|
|
160
|
+
`asyncContext()` stores the request context in `AsyncLocalStorage` so helpers can reach it
|
|
161
|
+
without the context being threaded through every call. Wrap `getContext()` in app-specific
|
|
162
|
+
helpers:
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
// app/utils/context.ts
|
|
166
|
+
import { getContext } from 'remix/async-context-middleware'
|
|
167
|
+
import { Auth } from 'remix/auth-middleware'
|
|
168
|
+
import { Database } from 'remix/data-table'
|
|
169
|
+
import { Session } from 'remix/session'
|
|
170
|
+
|
|
171
|
+
export function getCurrentDb() {
|
|
172
|
+
return getContext().get(Database)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function getCurrentSession() {
|
|
176
|
+
return getContext().get(Session)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function getCurrentUser() {
|
|
180
|
+
let auth = getContext().get(Auth)
|
|
181
|
+
if (!auth.ok) {
|
|
182
|
+
throw new Error('Expected an authenticated user. Run requireAuth() before this code.')
|
|
183
|
+
}
|
|
184
|
+
return auth.identity
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function getCurrentUserSafely() {
|
|
188
|
+
let auth = getContext().get(Auth)
|
|
189
|
+
return auth.ok ? auth.identity : null
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Middleware Layers
|
|
194
|
+
|
|
195
|
+
Middleware can be applied at three levels:
|
|
196
|
+
|
|
197
|
+
1. **Router-level** — runs for every request:
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
let router = createRouter({ middleware: [...] })
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
2. **Controller-level** — runs for all actions in a controller subtree:
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
export default {
|
|
207
|
+
middleware: [requireAuth()],
|
|
208
|
+
actions: { ... },
|
|
209
|
+
} satisfies Controller<typeof routes.account>
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
3. **Action-level** — runs for a single route:
|
|
213
|
+
```typescript
|
|
214
|
+
router.get(routes.account, {
|
|
215
|
+
middleware: [requireAuth()],
|
|
216
|
+
handler: accountAction.handler,
|
|
217
|
+
})
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Node Server Setup
|
|
221
|
+
|
|
222
|
+
Use `createRequestListener` to bridge Node's `http` module to the Fetch API router:
|
|
223
|
+
|
|
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
|
+
```
|