@remix-run/cli 0.4.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 +43 -48
  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 -72
@@ -66,36 +66,29 @@ 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
- return {
80
- href: await assetServer.getHref(entryId),
81
- exportName,
82
- }
83
- },
72
+ import { render } from 'remix/middleware/render'
73
+
74
+ let router = createRouter({
75
+ middleware: [render({ assets: assetServer })],
84
76
  })
85
77
  ```
86
78
 
87
- 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 })`.
88
80
 
89
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.
90
82
 
91
83
  Client entry props must be serializable: strings, numbers, booleans, `null`, `undefined`, plain objects/arrays of the above, JSX elements, and `<Frame>` elements. Functions and class instances cannot be passed.
92
84
 
85
+ The resolved `preloads` array contains browser module hrefs. During server rendering these are emitted as `<link rel="modulepreload">` tags, including preloads discovered in blocking frames. When a later frame response introduces a client entry, its preloads start before the entry module is loaded.
86
+
93
87
  ## Booting the Client
94
88
 
95
89
  Use `run` to start the client runtime. It scans the document for client entry markers, loads modules, and hydrates each one:
96
90
 
97
91
  ```tsx
98
- import type { ResolveFrameOptions } from 'remix/ui'
99
92
  import { run } from 'remix/ui'
100
93
 
101
94
  const app = run({
@@ -103,31 +96,8 @@ const app = run({
103
96
  let mod = await import(moduleUrl)
104
97
  return mod[exportName]
105
98
  },
106
- async resolveFrame(src, options) {
107
- let headers = new Headers({ accept: 'text/html', 'x-remix-frame': 'true' })
108
- if (options?.target) headers.set('x-remix-target', options.target)
109
- let response = await fetch(src, {
110
- body: getRequestBody(options),
111
- headers,
112
- method: options?.method,
113
- signal: options?.signal,
114
- })
115
- return response.body ?? (await response.text())
116
- },
117
99
  })
118
100
 
119
- function getRequestBody(options?: ResolveFrameOptions): BodyInit | undefined {
120
- let formData = options?.formData
121
- if (!formData) return
122
- if (options.encType !== 'application/x-www-form-urlencoded') return formData
123
-
124
- let body = new URLSearchParams()
125
- for (let [name, value] of formData) {
126
- body.append(name, typeof value === 'string' ? value : value.name)
127
- }
128
- return body
129
- }
130
-
131
101
  app.addEventListener('error', (event) => {
132
102
  console.error('Component error:', event.error)
133
103
  })
@@ -135,10 +105,22 @@ app.addEventListener('error', (event) => {
135
105
  await app.ready()
136
106
  ```
137
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
+
138
120
  ### `run` options
139
121
 
140
122
  - **`loadModule(moduleUrl, exportName)`** (required) — return the component function for each client entry. Typically uses dynamic `import()`.
141
- - **`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`.
142
124
 
143
125
  ### `app` methods
144
126
 
@@ -209,17 +191,17 @@ When a frame reloads, matching DOM nodes are updated in place. Client entries re
209
191
 
210
192
  ### Form navigation
211
193
 
212
- 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.
213
195
 
214
196
  - Forms target `handle.frames.top` by default.
215
- - `rmx-target` selects a named frame.
216
- - `rmx-src` selects a different frame request URL while preserving the form action as the navigation destination.
217
- - `rmx-history="push|replace"` overrides how the navigation updates history.
218
- - `rmx-reset-scroll="false"` preserves scroll position.
219
- - `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.
220
202
  - Cross-origin forms, `method="dialog"`, and `target="_blank"` remain browser-owned.
221
203
 
222
- 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.
223
205
 
224
206
  ### Nested frames
225
207
 
@@ -227,9 +209,22 @@ Frames can nest. Each frame owns its own DOM region and hydrates client entries
227
209
 
228
210
  ## Server Rendering
229
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
+
230
225
  ### `renderToStream`
231
226
 
232
- 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:
233
228
 
234
229
  ```tsx
235
230
  import { renderToStream } from 'remix/ui/server'
@@ -284,7 +279,7 @@ navigate('/dashboard', { history: 'replace' })
284
279
 
285
280
  Options: `src`, `target`, `history` (`'push' | 'replace'`), `resetScroll`.
286
281
 
287
- 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`.
288
283
 
289
284
  ## Head Management
290
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,72 +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.
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
- return {
28
- href: await assetServer.getHref(entryId),
29
- exportName: entryId.split('#')[1] || component.name || titleCaseFileName(entryId),
30
- }
31
- },
32
- })
33
-
34
- return createHtmlResponse(stream, init)
35
- },
36
- )
37
- }
38
-
39
- async function resolveFrame(router: Router, request: Request, src: string) {
40
- let url = new URL(src, request.url)
41
-
42
- let headers = new Headers()
43
- headers.set('Accept', 'text/html')
44
-
45
- let cookie = request.headers.get('Cookie')
46
- if (cookie) headers.set('Cookie', cookie)
47
-
48
- let response = await router.fetch(
49
- new Request(url, {
50
- method: 'GET',
51
- headers,
52
- signal: request.signal,
53
- }),
54
- )
55
-
56
- if (!response.ok) {
57
- return `<pre>Frame error: ${response.status} ${response.statusText}</pre>`
58
- }
59
-
60
- if (response.body) return response.body
61
- return await response.text()
62
- }
63
-
64
- function titleCaseFileName(fileUrl: string): string {
65
- let url = new URL(fileUrl)
66
- let fileName = path.basename(url.pathname, path.extname(url.pathname))
67
- return fileName
68
- .split(/[^A-Za-z0-9]+/)
69
- .filter(Boolean)
70
- .map((segment) => segment[0]!.toUpperCase() + segment.slice(1))
71
- .join('')
72
- }