@remix-run/cli 0.5.0 → 0.6.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 (53) hide show
  1. package/README.md +52 -3
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -0
  5. package/dist/lib/cli-context.d.ts.map +1 -1
  6. package/dist/lib/cli-context.js +5 -2
  7. package/dist/lib/cli.d.ts +1 -1
  8. package/dist/lib/cli.js +5 -1
  9. package/dist/lib/commands/assets.d.ts +4 -0
  10. package/dist/lib/commands/assets.d.ts.map +1 -0
  11. package/dist/lib/commands/assets.js +90 -0
  12. package/dist/lib/commands/db.d.ts.map +1 -1
  13. package/dist/lib/commands/db.js +63 -5
  14. package/dist/lib/commands/help.d.ts.map +1 -1
  15. package/dist/lib/commands/help.js +7 -0
  16. package/dist/lib/completion.d.ts.map +1 -1
  17. package/dist/lib/completion.js +25 -1
  18. package/dist/lib/database-command.d.ts +5 -1
  19. package/dist/lib/database-command.d.ts.map +1 -1
  20. package/dist/lib/database-command.js +1 -0
  21. package/dist/lib/errors.d.ts +6 -0
  22. package/dist/lib/errors.d.ts.map +1 -1
  23. package/dist/lib/errors.js +10 -0
  24. package/dist/lib/remix-config.d.ts +22 -0
  25. package/dist/lib/remix-config.d.ts.map +1 -1
  26. package/dist/lib/remix-config.js +80 -4
  27. package/package.json +8 -7
  28. package/schema/remix.json +49 -1
  29. package/src/index.ts +10 -0
  30. package/src/lib/cli-context.ts +10 -2
  31. package/src/lib/cli.ts +6 -1
  32. package/src/lib/commands/assets.ts +109 -0
  33. package/src/lib/commands/db.ts +75 -5
  34. package/src/lib/commands/help.ts +8 -0
  35. package/src/lib/completion.ts +41 -1
  36. package/src/lib/database-command.ts +6 -1
  37. package/src/lib/errors.ts +11 -0
  38. package/src/lib/remix-config.ts +128 -4
  39. package/template/.agents/skills/remix/SKILL.md +7 -5
  40. package/template/.agents/skills/remix/references/assets-and-browser-modules.md +8 -13
  41. package/template/.agents/skills/remix/references/auth-and-sessions.md +1 -15
  42. package/template/.agents/skills/remix/references/component-model.md +18 -16
  43. package/template/.agents/skills/remix/references/hydration-frames-navigation.md +41 -54
  44. package/template/.agents/skills/remix/references/middleware-and-server.md +4 -1
  45. package/template/.agents/skills/remix/references/mixins-styling-events.md +1 -1
  46. package/template/.agents/skills/remix/references/routing-and-controllers.md +1 -1
  47. package/template/.agents/skills/remix/references/testing-patterns.md +1 -1
  48. package/template/AGENTS.md +2 -3
  49. package/template/README.md +2 -3
  50. package/template/app/actions/controller.tsx +2 -4
  51. package/template/app/assets.ts +4 -7
  52. package/template/app/router.ts +5 -3
  53. package/template/app/middleware/render.tsx +0 -78
@@ -66,31 +66,17 @@ export const Counter = clientEntry(
66
66
  )
67
67
  ```
68
68
 
69
- On the server, provide `resolveClientEntry` to `renderToStream(...)` so source file URLs become browser-loadable asset URLs. Keep this resolution in the render helper so component modules do not hard-code deployment-specific asset paths:
69
+ On the server, pass the asset server to the standard render middleware so source file URLs become browser-loadable asset URLs without hard-coding deployment paths in component modules:
70
70
 
71
71
  ```tsx
72
- let stream = renderToStream(<App />, {
73
- async resolveClientEntry(entryId, component) {
74
- let exportName = entryId.split('#')[1] || component.name
75
- if (!exportName) {
76
- throw new Error(`Unable to resolve client entry export for ${entryId}`)
77
- }
78
-
79
- let [href, preloads] = await Promise.all([
80
- assetServer.getHref(entryId),
81
- assetServer.getPreloads(entryId),
82
- ])
83
-
84
- return {
85
- href,
86
- exportName,
87
- preloads,
88
- }
89
- },
72
+ import { render } from 'remix/middleware/render'
73
+
74
+ let router = createRouter({
75
+ middleware: [render({ assets: assetServer })],
90
76
  })
91
77
  ```
92
78
 
93
- If the module export name differs from the component function name, include `#ExportName` in the entry ID or return the exact export name from `resolveClientEntry`. A render helper that only supports source-owned entries can also fail fast when `entryId` is not a `file://` URL.
79
+ If the module export name differs from the component function name, include `#ExportName` in the entry ID. Custom rendering pipelines may instead provide the exact export name through `renderToStream({ resolveClientEntry })`.
94
80
 
95
81
  On the server, `clientEntry` components render like any other component. The server wraps their output in comment markers and serializes props into a `<script type="application/json">` tag.
96
82
 
@@ -103,7 +89,6 @@ The resolved `preloads` array contains browser module hrefs. During server rende
103
89
  Use `run` to start the client runtime. It scans the document for client entry markers, loads modules, and hydrates each one:
104
90
 
105
91
  ```tsx
106
- import type { ResolveFrameOptions } from 'remix/ui'
107
92
  import { run } from 'remix/ui'
108
93
 
109
94
  const app = run({
@@ -111,31 +96,8 @@ const app = run({
111
96
  let mod = await import(moduleUrl)
112
97
  return mod[exportName]
113
98
  },
114
- async resolveFrame(src, options) {
115
- let headers = new Headers({ accept: 'text/html', 'x-remix-frame': 'true' })
116
- if (options?.target) headers.set('x-remix-target', options.target)
117
- let response = await fetch(src, {
118
- body: getRequestBody(options),
119
- headers,
120
- method: options?.method,
121
- signal: options?.signal,
122
- })
123
- return response.body ?? (await response.text())
124
- },
125
99
  })
126
100
 
127
- function getRequestBody(options?: ResolveFrameOptions): BodyInit | undefined {
128
- let formData = options?.formData
129
- if (!formData) return
130
- if (options.encType !== 'application/x-www-form-urlencoded') return formData
131
-
132
- let body = new URLSearchParams()
133
- for (let [name, value] of formData) {
134
- body.append(name, typeof value === 'string' ? value : value.name)
135
- }
136
- return body
137
- }
138
-
139
101
  app.addEventListener('error', (event) => {
140
102
  console.error('Component error:', event.error)
141
103
  })
@@ -143,10 +105,22 @@ app.addEventListener('error', (event) => {
143
105
  await app.ready()
144
106
  ```
145
107
 
108
+ By default, `run()` resolves frames with `fetch()`, requests `text/html`, and forwards the submitted
109
+ method and abort signal. GET form values are already encoded in `src`; non-GET submissions use
110
+ `URLSearchParams` for `application/x-www-form-urlencoded`, CRLF-delimited text for `text/plain`, and
111
+ `FormData` for `multipart/form-data`. Provide `resolveFrame` when an app needs additional headers,
112
+ another body encoding, or a different response policy.
113
+
114
+ Add `data-rmx-document` to a link or form to leave its navigation to the browser.
115
+
116
+ The default resolver rejects non-OK responses with an error containing their status and status text.
117
+ A custom `resolveFrame` may return a `Response` with any status when it wants the runtime to render
118
+ the response body.
119
+
146
120
  ### `run` options
147
121
 
148
122
  - **`loadModule(moduleUrl, exportName)`** (required) — return the component function for each client entry. Typically uses dynamic `import()`.
149
- - **`resolveFrame(src, options)`** (optional) — called when a `<Frame>` loads or reloads content and for intercepted link and form navigations. `options` may contain `signal` and `target`; non-GET forms also provide `formData`, `method`, and `encType`.
123
+ - **`resolveFrame(src, options)`** (optional) — overrides the default `fetch()` resolver when a `<Frame>` loads or reloads content and for intercepted link and form navigations. `options` may contain `signal` and `target`; non-GET forms also provide `formData`, `method`, and `encType`.
150
124
 
151
125
  ### `app` methods
152
126
 
@@ -217,17 +191,17 @@ When a frame reloads, matching DOM nodes are updated in place. Client entries re
217
191
 
218
192
  ### Form navigation
219
193
 
220
- When `run({ resolveFrame })` is active, eligible same-origin forms progressively enhance into frame navigations. Native validation and the form's `submit` event still run first.
194
+ When `run()` is active, eligible same-origin forms progressively enhance into frame navigations. Native validation and the form's `submit` event still run first.
221
195
 
222
196
  - Forms target `handle.frames.top` by default.
223
- - `rmx-target` selects a named frame.
224
- - `rmx-src` selects a different frame request URL while preserving the form action as the navigation destination.
225
- - `rmx-history="push|replace"` overrides how the navigation updates history.
226
- - `rmx-reset-scroll="false"` preserves scroll position.
227
- - `rmx-document` opts back into a document submission.
197
+ - `data-rmx-target` selects a named frame.
198
+ - `data-rmx-src` selects a different frame request URL while preserving the form action as the navigation destination.
199
+ - `data-rmx-history="push|replace"` overrides how the navigation updates history.
200
+ - `data-rmx-reset-scroll="false"` preserves scroll position.
201
+ - `data-rmx-document` opts back into a document submission.
228
202
  - Cross-origin forms, `method="dialog"`, and `target="_blank"` remain browser-owned.
229
203
 
230
- GET controls are already encoded in `src`, so GET forms reach the resolver like links. Non-GET forms provide their native `FormData`, effective method, and encoding. The resolver owns body encoding and method-override conventions. Non-GET submissions to the current URL replace its history entry; GET submissions and submissions to a different URL push one. The `rmx-history` attribute overrides those defaults.
204
+ GET controls are already encoded in `src`, so GET forms reach the resolver like links. Non-GET forms provide their native `FormData`, effective method, and encoding. The resolver owns body encoding and method-override conventions. Non-GET submissions to the current URL replace its history entry; GET submissions and submissions to a different URL push one. The `data-rmx-history` attribute overrides those defaults.
231
205
 
232
206
  ### Nested frames
233
207
 
@@ -235,9 +209,22 @@ Frames can nest. Each frame owns its own DOM region and hydrates client entries
235
209
 
236
210
  ## Server Rendering
237
211
 
212
+ Normal applications install the conventional middleware and render at the action boundary:
213
+
214
+ ```tsx
215
+ import { render } from 'remix/middleware/render'
216
+ import { createRouter } from 'remix/router'
217
+
218
+ let router = createRouter({ middleware: [render()] })
219
+
220
+ router.get('/', (context) => context.render(<App />, { status: 200 }))
221
+ ```
222
+
223
+ The middleware seeds frame URLs from the request, resolves nested and targeted frames through the current router, forwards session and authentication headers safely, follows frame redirects, preserves application error bodies, and cancels rendering with the request.
224
+
238
225
  ### `renderToStream`
239
226
 
240
- Renders a component tree to a `ReadableStream<Uint8Array>`. Sends initial HTML immediately and streams frame content as it resolves:
227
+ Use this low-level API when replacing the standard response pipeline. It renders a component tree to a `ReadableStream<Uint8Array>`, sends initial HTML immediately, and streams frame content as it resolves:
241
228
 
242
229
  ```tsx
243
230
  import { renderToStream } from 'remix/ui/server'
@@ -292,7 +279,7 @@ navigate('/dashboard', { history: 'replace' })
292
279
 
293
280
  Options: `src`, `target`, `history` (`'push' | 'replace'`), `resetScroll`.
294
281
 
295
- Attributes understood by the runtime: `rmx-target`, `rmx-src`, `rmx-history`, `rmx-reset-scroll`, `rmx-document`.
282
+ Attributes understood by the runtime: `data-rmx-target`, `data-rmx-src`, `data-rmx-history`, `data-rmx-reset-scroll`, `data-rmx-document`.
296
283
 
297
284
  ## Head Management
298
285
 
@@ -24,6 +24,7 @@ import { compression } from 'remix/middleware/compression'
24
24
  import { formData } from 'remix/middleware/form-data'
25
25
  import { logger } from 'remix/middleware/logger'
26
26
  import { methodOverride } from 'remix/middleware/method-override'
27
+ import { render } from 'remix/middleware/render'
27
28
  import { session } from 'remix/middleware/session'
28
29
  import { staticFiles } from 'remix/middleware/static'
29
30
  import { asyncContext } from 'remix/middleware/async-context'
@@ -42,6 +43,7 @@ middleware.push(session(cookie, storage))
42
43
  middleware.push(asyncContext())
43
44
  middleware.push(loadDatabase())
44
45
  middleware.push(loadAuth())
46
+ middleware.push(render({ assets }))
45
47
 
46
48
  let router = createRouter({ middleware })
47
49
  ```
@@ -62,6 +64,7 @@ let router = createRouter({ middleware })
62
64
  | `asyncContext()` | `remix/middleware/async-context` | Helpers outside handlers need request context via `getContext()` | Add before helpers rely on it |
63
65
  | `auth({ schemes })` | `remix/middleware/auth` | Resolve auth state into `context.get(Auth)` | Run after `session()` for session-backed auth |
64
66
  | `requireAuth()` | `remix/middleware/auth` | A controller or action must reject anonymous access | Usually controller middleware or action middleware |
67
+ | `render({ assets? })` | `remix/middleware/render` | Actions render Remix UI through `context.render(node, init)` | Pass the asset server for source-based client entries |
65
68
 
66
69
  ### Static files vs browser modules
67
70
 
@@ -79,7 +82,7 @@ let router = createRouter({ middleware })
79
82
 
80
83
  ### Common stacks
81
84
 
82
- - **Session-backed HTML app** -> `compression()`, `staticFiles()`, optional `cop()`, `formData()`, `methodOverride()`, `session()`, optional `csrf()`, `asyncContext()`, `auth({ schemes })`
85
+ - **Session-backed HTML app** -> `compression()`, `staticFiles()`, optional `cop()`, `formData()`, `methodOverride()`, `session()`, optional `csrf()`, `asyncContext()`, `auth({ schemes })`, `render({ assets })`
83
86
  - **Cross-origin API** -> `compression()`, `cors()`, optional `asyncContext()`, optional `auth({ schemes })`
84
87
  - **Upload flow** -> `compression()`, `staticFiles()`, `formData({ uploadHandler })`, then sessions, auth, and data-loading middleware as needed
85
88
  - **Optional development HMR** -> keep `server.ts` as the child app server, add `hmr.ts` for `remix/node-hmr`, and proxy public requests through `createHmrReadyFetch()`
@@ -115,7 +115,7 @@ Adds client-side navigation behavior to any element. Makes non-anchor elements b
115
115
 
116
116
  Options match `NavigationOptions`: `src`, `target`, `history` (`'push' | 'replace'`), `resetScroll`.
117
117
 
118
- On a native anchor, the `history` option renders the corresponding `rmx-history="push|replace"` attribute so the enhanced navigation uses the same history behavior.
118
+ On a native anchor, the `history` option renders the corresponding `data-rmx-history="push|replace"` attribute so the enhanced navigation uses the same history behavior.
119
119
 
120
120
  ## Native press and keyboard interactions
121
121
 
@@ -252,7 +252,7 @@ export const routes = route({
252
252
  export default createController(routes, {
253
253
  actions: {
254
254
  async assets({ request }) {
255
- return (await assetServer.fetch(request)) ?? new Response('Not Found', { status: 404 })
255
+ return (await assets.fetch(request)) ?? new Response('Not Found', { status: 404 })
256
256
  },
257
257
  home() {
258
258
  return render(<HomePage />)
@@ -74,7 +74,7 @@ Configure discovery and coverage in the `test` section of `remix.json` or with C
74
74
 
75
75
  ```jsonc
76
76
  {
77
- "$schema": "https://remix.run/schemas/remix.json",
77
+ "$schema": "./node_modules/remix/schema/remix.json",
78
78
  "test": {
79
79
  "files": ["**/*.test{,.e2e}.{ts,tsx}"],
80
80
  "e2eFiles": ["**/*.test.e2e.{ts,tsx}"],
@@ -23,9 +23,8 @@ Refer to ./.agents/skills/remix/SKILL.md
23
23
  - `app/actions/home-page.tsx` and `app/actions/document.tsx` render the route-owned starter UI
24
24
  - `app/actions/public/` contains the browser runtime entry and interactive prompt button
25
25
  - `app/routes.ts` defines the shared route contract used by server and browser modules for type-safe hrefs
26
- - `app/router.ts` wires routes to route handlers
27
- - `app/middleware/render.tsx` installs the request-scoped renderer used by actions
28
- - `app/assets.ts` owns the server-side asset pipeline used by the asset route and renderer
26
+ - `app/router.ts` wires routes to route handlers and installs the standard Remix UI renderer used by actions
27
+ - `app/assets.ts` owns the server-side asset pipeline used by the asset route and render middleware
29
28
  - Root `public/` contains static files served unchanged from the app root
30
29
 
31
30
  ## Route Ownership
@@ -8,9 +8,8 @@ A minimal Remix application starter with a home page.
8
8
  - `app/actions/home-page.tsx` and `app/actions/document.tsx` render the route-owned starter UI.
9
9
  - `app/actions/public/` contains the browser runtime entry and interactive prompt button.
10
10
  - `app/routes.ts` defines the shared route contract used by server and browser modules for type-safe hrefs.
11
- - `app/router.ts` wires routes to handlers.
12
- - `app/middleware/render.tsx` installs the request-scoped renderer used by actions.
13
- - `app/assets.ts` owns the server-side asset pipeline used by the asset route and renderer.
11
+ - `app/router.ts` wires routes to handlers and installs the standard Remix UI renderer used by actions.
12
+ - `app/assets.ts` owns the server-side asset pipeline used by the asset route and render middleware.
14
13
  - Root `public/` contains static files served unchanged from the app root.
15
14
 
16
15
  ## Growing The App
@@ -1,15 +1,13 @@
1
1
  import { createController } from 'remix/router'
2
2
 
3
- import { assetServer } from '../assets.ts'
3
+ import { assets } from '../assets.ts'
4
4
  import { routes } from '../routes.ts'
5
5
  import { HomePage } from './home-page.tsx'
6
6
 
7
7
  export default createController(routes, {
8
8
  actions: {
9
9
  async assets(context) {
10
- return (
11
- (await assetServer.fetch(context.request)) ?? new Response('Not Found', { status: 404 })
12
- )
10
+ return (await assets.fetch(context.request)) ?? new Response('Not Found', { status: 404 })
13
11
  },
14
12
  home(context) {
15
13
  return context.render(<HomePage />)
@@ -6,13 +6,10 @@ const nodeEnv = process.env.NODE_ENV ?? 'development'
6
6
  const isDevelopment = nodeEnv === 'development'
7
7
  const isHmr = Boolean(isDevelopment && process.env.REMIX_NODE_HMR)
8
8
 
9
- export const assetServer = createAssetServer({
9
+ export const assets = createAssetServer({
10
10
  basePath: '/assets',
11
11
  rootDir,
12
- fileMap: {
13
- 'app/*path': 'app/*path',
14
- 'node_modules/*path': 'node_modules/*path',
15
- },
12
+
16
13
  allowFiles: ['app/routes.ts', 'app/**/public/**'],
17
14
  allowPackages: ['remix'],
18
15
  denyFiles: ['app/**/*.test.*'],
@@ -27,5 +24,5 @@ export const assetServer = createAssetServer({
27
24
 
28
25
  const entry = 'app/actions/public/entry.ts'
29
26
 
30
- export const entryHref = await assetServer.getHref(entry)
31
- export const entryPreloads = await assetServer.getPreloads(entry)
27
+ export const entryHref = await assets.getHref(entry)
28
+ export const entryPreloads = await assets.getPreloads(entry)
@@ -1,11 +1,13 @@
1
1
  import { createRouter, type MiddlewareContext } from 'remix/router'
2
+ import { render } from 'remix/middleware/render'
2
3
  import { staticFiles } from 'remix/middleware/static'
3
4
 
4
5
  import controller from './actions/controller.tsx'
5
- import { render } from './middleware/render.tsx'
6
+ import { assets } from './assets.ts'
6
7
  import { routes } from './routes.ts'
7
8
 
8
- type AppContext = MiddlewareContext<[ReturnType<typeof render>]>
9
+ const renderMiddleware = render({ assets })
10
+ type AppContext = MiddlewareContext<[typeof renderMiddleware]>
9
11
 
10
12
  declare module 'remix/router' {
11
13
  interface RouterTypes {
@@ -14,7 +16,7 @@ declare module 'remix/router' {
14
16
  }
15
17
 
16
18
  export const router = createRouter<AppContext>({
17
- middleware: [staticFiles('./public', { index: false }), render()],
19
+ middleware: [staticFiles('./public', { index: false }), renderMiddleware],
18
20
  })
19
21
 
20
22
  router.map(routes, controller)
@@ -1,78 +0,0 @@
1
- import * as path from 'node:path'
2
-
3
- import type { Router } from 'remix/router'
4
- import { renderWith } from 'remix/middleware/render'
5
- import { createHtmlResponse } from 'remix/response/html'
6
- import type { RemixNode } from 'remix/ui'
7
- import { renderToStream } from 'remix/ui/server'
8
-
9
- import { assetServer } from '../assets.ts'
10
-
11
- export function render() {
12
- return renderWith(
13
- ({ request, router }) =>
14
- function render(node: RemixNode, init?: ResponseInit) {
15
- let stream = renderToStream(node, {
16
- frameSrc: request.url,
17
- signal: request.signal,
18
- resolveFrame: (src) => resolveFrame(router, request, src),
19
- // Server rendering turns client entries into browser module URLs and preloads.
20
- async resolveClientEntry(entryId, component) {
21
- if (!entryId.startsWith('file://')) {
22
- throw new Error(
23
- `Expected \`import.meta.url\` for clientEntry ID, received '${entryId}'`,
24
- )
25
- }
26
-
27
- let [href, preloads] = await Promise.all([
28
- assetServer.getHref(entryId),
29
- assetServer.getPreloads(entryId),
30
- ])
31
-
32
- return {
33
- href,
34
- exportName: entryId.split('#')[1] || component.name || titleCaseFileName(entryId),
35
- preloads,
36
- }
37
- },
38
- })
39
-
40
- return createHtmlResponse(stream, init)
41
- },
42
- )
43
- }
44
-
45
- async function resolveFrame(router: Router, request: Request, src: string) {
46
- let url = new URL(src, request.url)
47
-
48
- let headers = new Headers()
49
- headers.set('Accept', 'text/html')
50
-
51
- let cookie = request.headers.get('Cookie')
52
- if (cookie) headers.set('Cookie', cookie)
53
-
54
- let response = await router.fetch(
55
- new Request(url, {
56
- method: 'GET',
57
- headers,
58
- signal: request.signal,
59
- }),
60
- )
61
-
62
- if (!response.ok) {
63
- return `<pre>Frame error: ${response.status} ${response.statusText}</pre>`
64
- }
65
-
66
- if (response.body) return response.body
67
- return await response.text()
68
- }
69
-
70
- function titleCaseFileName(fileUrl: string): string {
71
- let url = new URL(fileUrl)
72
- let fileName = path.basename(url.pathname, path.extname(url.pathname))
73
- return fileName
74
- .split(/[^A-Za-z0-9]+/)
75
- .filter(Boolean)
76
- .map((segment) => segment[0]!.toUpperCase() + segment.slice(1))
77
- .join('')
78
- }