@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
@@ -6,8 +6,9 @@ export interface CompletionResult {
6
6
  }
7
7
 
8
8
  const COMPLETION_SHELLS = ['bash', 'zsh'] as const
9
- const DB_COMMANDS = ['migrate', 'reset', 'seed', 'status', 'wipe'] as const
9
+ const DB_COMMANDS = ['migrate', 'reset', 'rollback', 'seed', 'status', 'wipe'] as const
10
10
  const HELP_COMMANDS = [
11
+ 'assets',
11
12
  'completion',
12
13
  'db',
13
14
  'doctor',
@@ -18,6 +19,7 @@ const HELP_COMMANDS = [
18
19
  'version',
19
20
  ] as const
20
21
  const ROOT_COMMANDS = [
22
+ 'assets',
21
23
  'completion',
22
24
  'db',
23
25
  'doctor',
@@ -225,6 +227,10 @@ function completeCommand(
225
227
  return completeHelp(tokens, currentWord, usedGlobalFlags)
226
228
  }
227
229
 
230
+ if (command === 'assets') {
231
+ return completeAssets(tokens, currentWord, usedGlobalFlags)
232
+ }
233
+
228
234
  if (command === 'new') {
229
235
  return completeNew(tokens, currentWord, usedGlobalFlags)
230
236
  }
@@ -261,6 +267,30 @@ function completeCommand(
261
267
  return completeValues([], currentWord)
262
268
  }
263
269
 
270
+ function completeAssets(
271
+ tokens: string[],
272
+ currentWord: string,
273
+ usedGlobalFlags: Set<string>,
274
+ ): CompletionResult {
275
+ let filteredTokens = filterGlobalCommandTokens(tokens, usedGlobalFlags)
276
+ if (filteredTokens == null) {
277
+ return completeValues([], currentWord)
278
+ }
279
+
280
+ if (filteredTokens.length === 0) {
281
+ return completeValues(withHelpFlags(['inspect'], usedGlobalFlags), currentWord)
282
+ }
283
+
284
+ let [subcommand, ...rest] = filteredTokens
285
+ if (subcommand !== 'inspect' || rest.length > 0) {
286
+ return completeValues([], currentWord)
287
+ }
288
+
289
+ return currentWord.startsWith('-')
290
+ ? completeValues(withHelpFlags([], usedGlobalFlags), currentWord)
291
+ : { mode: 'files' }
292
+ }
293
+
264
294
  function completeTest(
265
295
  tokens: string[],
266
296
  currentWord: string,
@@ -533,6 +563,16 @@ function completeDb(
533
563
  )
534
564
  }
535
565
 
566
+ if (subcommand === 'rollback') {
567
+ return completeDbOptions(
568
+ rest,
569
+ currentWord,
570
+ usedGlobalFlags,
571
+ ['--dry-run'],
572
+ ['--connection-env', '--journal-table', '--migrations', '--step', '--to'],
573
+ )
574
+ }
575
+
536
576
  if (subcommand === 'wipe') {
537
577
  return completeDbOptions(rest, currentWord, usedGlobalFlags, ['--force'], ['--connection-env'])
538
578
  }
@@ -1,22 +1,26 @@
1
1
  import type { RemixDbAdapterConfig } from './remix-config.ts'
2
2
 
3
- export type DatabaseCommand = 'migrate' | 'reset' | 'seed' | 'status' | 'wipe'
3
+ export type DatabaseCommand = 'migrate' | 'reset' | 'rollback' | 'seed' | 'status' | 'wipe'
4
4
 
5
5
  export interface DatabaseCommandInvocation {
6
6
  command: DatabaseCommand
7
7
  connectionEnv?: string
8
+ dryRun?: boolean
8
9
  journalTable?: string
9
10
  migrations?: string
10
11
  seed?: string
12
+ step?: number
11
13
  to?: string
12
14
  }
13
15
 
14
16
  export interface DatabaseCommandPlan {
15
17
  adapter: RemixDbAdapterConfig
16
18
  command: DatabaseCommand
19
+ dryRun?: boolean
17
20
  journalTable?: string
18
21
  migrations?: string
19
22
  seed?: string
23
+ step?: number
20
24
  to?: string
21
25
  }
22
26
 
@@ -24,6 +28,7 @@ export function isDatabaseCommand(value: unknown): value is DatabaseCommand {
24
28
  return (
25
29
  value === 'migrate' ||
26
30
  value === 'reset' ||
31
+ value === 'rollback' ||
27
32
  value === 'seed' ||
28
33
  value === 'status' ||
29
34
  value === 'wipe'
package/src/lib/errors.ts CHANGED
@@ -28,6 +28,11 @@ export const CLI_ERROR_DEFINITIONS = {
28
28
  title: 'Could not determine an app name',
29
29
  fix: 'Pass --app-name or choose a target directory name that can become an app name.',
30
30
  },
31
+ assetsConfigRequired: {
32
+ code: 'RMX_ASSETS_CONFIG_REQUIRED',
33
+ title: 'Asset configuration is required',
34
+ fix: 'Add an assets configuration to remix.json.',
35
+ },
31
36
  dbConfigRequired: {
32
37
  code: 'RMX_DB_CONFIG_REQUIRED',
33
38
  title: 'Database configuration is required',
@@ -194,6 +199,12 @@ export function appNameUnavailable(targetDir?: string): UsageError {
194
199
  })
195
200
  }
196
201
 
202
+ export function assetsConfigRequired(): CliError {
203
+ return createCliError(CLI_ERROR_DEFINITIONS.assetsConfigRequired, {
204
+ message: 'Asset configuration is missing from remix.json.',
205
+ })
206
+ }
207
+
197
208
  export function dbConfigRequired(filePath: string): CliError {
198
209
  return createCliError(CLI_ERROR_DEFINITIONS.dbConfigRequired, {
199
210
  context: { filePath },
@@ -1,5 +1,7 @@
1
1
  import * as fs from 'node:fs/promises'
2
2
  import * as path from 'node:path'
3
+ import * as process from 'node:process'
4
+ import { fileURLToPath } from 'node:url'
3
5
  import {
4
6
  findNodeAtLocation,
5
7
  getNodeValue,
@@ -8,8 +10,10 @@ import {
8
10
  type Node as JsonNode,
9
11
  type ParseError,
10
12
  } from 'jsonc-parser'
13
+ import type { AssetServerOptions } from '@remix-run/assets'
11
14
  import type { RemixTestPool } from '@remix-run/test/cli'
12
15
 
16
+ import { findAppRoot } from './app-root.ts'
13
17
  import { invalidRemixConfig, remixConfigNotFound } from './errors.ts'
14
18
 
15
19
  const reporters = ['spec', 'files', 'tap', 'dot'] as const
@@ -21,12 +25,29 @@ type TestPool = RemixTestPool
21
25
  type TestType = (typeof testTypes)[number]
22
26
  type JsonPath = Array<number | string>
23
27
 
28
+ /** Validated configuration loaded from a Remix project config file. */
24
29
  export interface RemixConfig {
30
+ /** Shared asset mapping and browser access configuration. */
31
+ assets?: RemixAssetsConfig
32
+ /** Database command configuration. */
25
33
  db?: RemixDbCommandConfig
34
+ /** Project health-check configuration. */
26
35
  doctor?: RemixDoctorCommandConfig
36
+ /** Test runner configuration. */
27
37
  test?: RemixTestCommandConfig
28
38
  }
29
39
 
40
+ /** JSON-compatible asset server configuration loaded from `remix.json`. */
41
+ export interface RemixAssetsConfig extends Pick<
42
+ AssetServerOptions,
43
+ 'allowFiles' | 'allowPackages' | 'basePath' | 'denyFiles' | 'mounts'
44
+ > {
45
+ /** Leaf file asset configuration. */
46
+ files?: Pick<NonNullable<AssetServerOptions['files']>, 'extensions'>
47
+ /** Absolute root directory used to resolve asset file paths. */
48
+ rootDir: string
49
+ }
50
+
30
51
  export type RemixDbString = string | { env: string; default?: string }
31
52
 
32
53
  export type RemixDbAdapterConfig =
@@ -99,6 +120,36 @@ interface ConfigSource {
99
120
  text: string
100
121
  }
101
122
 
123
+ /**
124
+ * Loads the nearest Remix project configuration or an explicitly selected config file.
125
+ *
126
+ * @param from A config file or directory from which to search upward for `remix.json`. Defaults to
127
+ * `process.cwd()`.
128
+ * @returns The validated Remix project configuration, or an empty object when no config is found.
129
+ */
130
+ export async function loadConfig(from: string | URL = process.cwd()): Promise<RemixConfig> {
131
+ let fromPath = path.resolve(from instanceof URL ? fileURLToPath(from) : from)
132
+ let stat
133
+
134
+ try {
135
+ stat = await fs.stat(fromPath)
136
+ } catch (error) {
137
+ if (isNodeError(error) && error.code === 'ENOENT') throw remixConfigNotFound(fromPath)
138
+ throw error
139
+ }
140
+
141
+ if (stat.isFile()) {
142
+ return loadRemixConfig(path.dirname(fromPath), path.basename(fromPath))
143
+ }
144
+
145
+ if (!stat.isDirectory()) {
146
+ throw new TypeError(`Expected a Remix config file or directory: ${fromPath}`)
147
+ }
148
+
149
+ let configDir = await findAppRoot(fromPath, 'remix.json')
150
+ return configDir === null ? {} : loadRemixConfig(configDir, undefined)
151
+ }
152
+
102
153
  export async function loadRemixConfig(
103
154
  cwd: string,
104
155
  configPath: string | undefined,
@@ -153,7 +204,7 @@ function parseConfig(
153
204
  cwd: string,
154
205
  ): RemixConfig {
155
206
  let object = requireObject(value, source, [])
156
- requireKnownProperties(object, ['$schema', 'db', 'doctor', 'test'], source, [])
207
+ requireKnownProperties(object, ['$schema', 'assets', 'db', 'doctor', 'test'], source, [])
157
208
 
158
209
  if (object.$schema !== undefined) {
159
210
  requireString(object.$schema, source, ['$schema'])
@@ -161,6 +212,10 @@ function parseConfig(
161
212
 
162
213
  let config: RemixConfig = {}
163
214
 
215
+ if (object.assets !== undefined) {
216
+ config.assets = parseAssetsConfig(object.assets, source, configDir)
217
+ }
218
+
164
219
  if (object.db !== undefined) {
165
220
  config.db = parseDbConfig(object.db, source, configDir)
166
221
  }
@@ -176,6 +231,54 @@ function parseConfig(
176
231
  return config
177
232
  }
178
233
 
234
+ function parseAssetsConfig(
235
+ value: unknown,
236
+ source: ConfigSource,
237
+ configDir: string,
238
+ ): RemixAssetsConfig {
239
+ let objectPath = ['assets']
240
+ let object = requireObject(value, source, objectPath)
241
+ requireKnownProperties(
242
+ object,
243
+ ['allowFiles', 'allowPackages', 'basePath', 'denyFiles', 'files', 'mounts', 'rootDir'],
244
+ source,
245
+ objectPath,
246
+ )
247
+
248
+ let config: RemixAssetsConfig = {
249
+ allowFiles: requireStringArray(object.allowFiles, source, [...objectPath, 'allowFiles']),
250
+ basePath: requireString(object.basePath, source, [...objectPath, 'basePath']),
251
+ rootDir: path.resolve(
252
+ configDir,
253
+ optionalString(object.rootDir, source, [...objectPath, 'rootDir']) ?? '.',
254
+ ),
255
+ }
256
+ let allowPackages = optionalStringArray(object.allowPackages, source, [
257
+ ...objectPath,
258
+ 'allowPackages',
259
+ ])
260
+ let denyFiles = optionalStringArray(object.denyFiles, source, [...objectPath, 'denyFiles'])
261
+ let mounts =
262
+ object.mounts === undefined
263
+ ? undefined
264
+ : requireStringRecord(object.mounts, source, [...objectPath, 'mounts'])
265
+
266
+ if (allowPackages !== undefined) config.allowPackages = allowPackages
267
+ if (denyFiles !== undefined) config.denyFiles = denyFiles
268
+ if (mounts !== undefined) config.mounts = mounts
269
+
270
+ if (object.files !== undefined) {
271
+ let filesPath = [...objectPath, 'files']
272
+ let files = requireObject(object.files, source, filesPath)
273
+ requireKnownProperties(files, ['extensions'], source, filesPath)
274
+ config.files = {
275
+ extensions: requireStringArray(files.extensions, source, [...filesPath, 'extensions']),
276
+ }
277
+ }
278
+
279
+ return config
280
+ }
281
+
179
282
  function parseDbConfig(
180
283
  value: unknown,
181
284
  source: ConfigSource,
@@ -507,15 +610,36 @@ function requireString(value: unknown, source: ConfigSource, propertyPath: JsonP
507
610
  return value
508
611
  }
509
612
 
613
+ function requireStringArray(
614
+ value: unknown,
615
+ source: ConfigSource,
616
+ propertyPath: JsonPath,
617
+ ): string[] {
618
+ if (!Array.isArray(value)) throwConfigError(source, propertyPath, 'Expected an array of strings')
619
+ return value.map((item, index) => requireString(item, source, [...propertyPath, index]))
620
+ }
621
+
622
+ function requireStringRecord(
623
+ value: unknown,
624
+ source: ConfigSource,
625
+ propertyPath: JsonPath,
626
+ ): Record<string, string> {
627
+ let object = requireObject(value, source, propertyPath)
628
+ return Object.fromEntries(
629
+ Object.entries(object).map(([key, item]) => [
630
+ key,
631
+ requireString(item, source, [...propertyPath, key]),
632
+ ]),
633
+ )
634
+ }
635
+
510
636
  function optionalStringArray(
511
637
  value: unknown,
512
638
  source: ConfigSource,
513
639
  propertyPath: JsonPath,
514
640
  ): string[] | undefined {
515
641
  if (value === undefined) return undefined
516
- if (!Array.isArray(value)) throwConfigError(source, propertyPath, 'Expected an array of strings')
517
-
518
- return value.map((item, index) => requireString(item, source, [...propertyPath, index]))
642
+ return requireStringArray(value, source, propertyPath)
519
643
  }
520
644
 
521
645
  function optionalEnum<const value extends string>(
@@ -122,8 +122,9 @@ When code could live in multiple places:
122
122
 
123
123
  ### Response Rendering And Utilities
124
124
 
125
- - Treat response rendering as action-layer code: modules that return `Response`, choose HTTP status or headers, call `redirect(...)`, or call the local `render(...)` helper belong in `app/actions`
126
- - Keep `app/actions/render.tsx` small; it should adapt `remix/ui/server` output to `createHtmlResponse(...)`. Route-specific response assembly can live in flat action modules, but directories under `app/actions/` must still match route-map keys
125
+ - Install `render()` from `remix/middleware/render` in the router middleware stack for normal Remix UI applications. Pass `render({ assets })` when source-based `clientEntry()` modules need browser URLs
126
+ - Render UI responses at the action boundary with `context.render(node, init)`. Status, headers, and other response policy remain explicit in the action
127
+ - Use `renderWith(...)`, `renderToStream(...)`, and `createHtmlResponse(...)` only when an application intentionally owns a custom renderer contract or replaces the standard UI response pipeline
127
128
  - Put pure support code in focused `app/utils/<topic>.ts` modules. Formatting, MIME classification, path parsing, sorting, and normalization should be testable without a router, request context, or `Response`, and should not import from `app/actions`, `remix/ui/server`, or `remix/response/*`
128
129
  - Do not introduce page-data intermediary shapes only to keep route-specific renderers away from `render(...)`; keep response assembly in actions and extract only the pure helpers
129
130
 
@@ -210,7 +211,7 @@ Use this map to find the right package quickly. Each entry says what the package
210
211
  - `remix/node-hmr` — optional development Node HMR runner for rapid UI edits. Use `run` in `hmr.ts` to supervise `server.ts` behind an `hmr` script, and use `createHmrReadyFetch` when a stable public proxy should wait for child server readiness during updates
211
212
  - `remix/node-hmr/runtime` — child-process runtime API for code running under `remix/node-hmr`. Use to create browser HMR channels for asset servers and to emit server readiness after the child server starts listening
212
213
  - `remix/node-hmr/types` — type-only entry for `import.meta.hot` in Node modules
213
- - `remix/assets` — browser asset server. Use for `createAssetServer` when serving compiled scripts and styles, getting public hrefs, emitting preloads, and wiring browser HMR. Configure a `basePath`, keep `fileMap` URL patterns relative to it, use `allowFiles`/`denyFiles` for path and glob rules, and use exact package names in `allowPackages` for package-level access. Shared compiler options such as `target`, `sourceMaps`, `sourceMapSourcePaths`, and `minify` live at the top level
214
+ - `remix/assets` — browser asset server. Use for `createAssetServer` when serving compiled scripts and styles, getting public hrefs, emitting preloads, and wiring browser HMR. Configure a `basePath`; use optional directory `mounts` configuration when the default mounts that serve `app` at `app` and `node_modules` at `npm` are not enough; use `allowFiles`/`denyFiles` for path and glob rules; and use exact package names in `allowPackages` for package-level access. Shared compiler options such as `target`, `sourceMaps`, `sourceMapSourcePaths`, and `minify` live at the top level
214
215
  - `remix/assets/types/hmr` — type-only entry for `import.meta.hot` in browser modules compiled by `remix/assets`
215
216
  - `remix/headers` — `SuperHeaders` plus typed header parsers and builders. Use the default export when you want a `Headers` subclass with typed accessors like `headers.contentType`, `headers.cacheControl`, and `headers.setCookie`; use named classes such as `CacheControl`, `ContentDisposition`, and `Vary` when working with individual header values
216
217
  - `remix/response/redirect` — `redirect(href, status?)`. Use for the canonical "POST then redirect" pattern and other location changes
@@ -243,13 +244,13 @@ Use this map to find the right package quickly. Each entry says what the package
243
244
  - `remix/session-storage/redis` — Redis-backed storage. Use for multi-process or multi-host deployments
244
245
  - `remix/session-storage/memcache` — Memcache-backed storage. Same multi-host use case as Redis
245
246
  - `remix/cookie` — `createCookie` for plain signed/unsigned cookies. Use for non-sensitive preferences where the client is allowed to control the value (theme, locale, dismissed banner). For state where tampering matters, prefer `remix/session`
246
- - `remix/auth` — credentials, OAuth, OIDC, and Atmosphere providers. Use to define how identity is verified, start/finish external login, and refresh stored OAuth/OIDC token bundles with `refreshExternalAuth(...)`
247
+ - `remix/auth` — credentials, OAuth, and OIDC providers. Use to define how identity is verified, start/finish external login, and refresh stored OAuth/OIDC token bundles with `refreshExternalAuth(...)`
247
248
  - `remix/middleware/auth` — `auth({ schemes })`, `requireAuth`, the `Auth` context key. Use to resolve identity into the request context and to gate routes
248
249
 
249
250
  ### UI, Hydration, and Browser Behavior
250
251
 
251
252
  - `remix/ui` — the component runtime: components, core mixins, `clientEntry`, `run`, `<Frame>`, navigation helpers, and `createRoot`. Use for app UI behavior
252
- - `remix/ui/server` — server rendering: `renderToStream`, `renderToString`. Use in the `app/actions/render.tsx` helper that returns HTML responses
253
+ - `remix/ui/server` — low-level server rendering with `renderToStream` and `renderToString`. Normal apps should install `render()` from `remix/middleware/render`; use this subpath for custom pipelines and static string rendering
253
254
  - `remix/ui-hmr` — direct Remix UI component HMR transforms. Use only when writing a custom module hook or build integration
254
255
  - `remix/ui-hmr/node` — Node import hook for Remix UI component HMR. Use with `--import remix/ui-hmr/node` in development servers that run through `remix/node-hmr`
255
256
  - `remix/ui-hmr/assets` — `remix/assets` loader for Remix UI component HMR. Use `uiHmr()` in `createAssetServer({ scripts: { loaders } })` during development
@@ -264,6 +265,7 @@ Use this map to find the right package quickly. Each entry says what the package
264
265
 
265
266
  ### Middleware
266
267
 
268
+ - `remix/middleware/render` — `render({ assets?, onError? })` for the standard Remix UI renderer and `renderWith(factory)` for custom request-scoped renderers. Normal UI actions return `context.render(node, init)`
267
269
  - `remix/middleware/static` — `staticFiles(dir)`. Use to serve files from `public/` exactly as they exist on disk
268
270
  - `remix/middleware/form-data` — `formData()`. Use to parse `FormData` once and expose it via `get(FormData)` instead of calling `await request.formData()` in each action
269
271
  - `remix/form-data-parser` — lower-level `parseFormData`, `FileUpload`. Use when implementing custom upload handlers. Upload handler errors propagate directly
@@ -4,7 +4,7 @@
4
4
 
5
5
  How to serve browser scripts and styles from source. Read this when the task involves:
6
6
 
7
- - Configuring `createAssetServer` (`basePath`, `fileMap`, `allowFiles`, `allowPackages`, `denyFiles`, fingerprinting, compiler options)
7
+ - Configuring `createAssetServer` (`basePath`, `mounts`, `allowFiles`, `allowPackages`, `denyFiles`, fingerprinting, compiler options)
8
8
  - Choosing between `staticFiles()` for already-built files and `createAssetServer()` for source assets that need import rewriting, preloads, or fingerprinted URLs
9
9
  - Generating script URLs or `<link rel="modulepreload">` tags for a client entry
10
10
  - Enabling browser HMR for source-served modules
@@ -29,13 +29,9 @@ export const routes = route({
29
29
  assets: get('/assets/*path'),
30
30
  })
31
31
 
32
- let assetServer = createAssetServer({
32
+ let assets = createAssetServer({
33
33
  basePath: '/assets',
34
34
  rootDir: process.cwd(),
35
- fileMap: {
36
- 'app/*path': 'app/*path',
37
- 'node_modules/*path': 'node_modules/*path',
38
- },
39
35
  allowFiles: ['app/routes.ts', 'app/**/public/**'],
40
36
  allowPackages: ['remix'],
41
37
  denyFiles: ['app/**/*.test.*'],
@@ -52,7 +48,7 @@ let assetServer = createAssetServer({
52
48
  export default createController(routes, {
53
49
  actions: {
54
50
  async assets({ request }) {
55
- return (await assetServer.fetch(request)) ?? new Response('Not Found', { status: 404 })
51
+ return (await assets.fetch(request)) ?? new Response('Not Found', { status: 404 })
56
52
  },
57
53
  },
58
54
  })
@@ -69,8 +65,8 @@ export default createController(routes, {
69
65
  - `denyFiles` takes precedence over both file and package allow rules.
70
66
  - Set `rootDir` explicitly in monorepos so relative paths resolve from the intended project root.
71
67
  - `basePath` is the public URL namespace handled by the asset server.
72
- - `fileMap` keys are URL patterns relative to `basePath`, and values are root-relative file path patterns. They use `route-pattern` syntax on both sides.
73
- - Keep the same wildcard params on both sides of a `fileMap` entry so import rewriting can map source files back to public URLs.
68
+ - The default mounts serve the `app` directory at `/app` and `node_modules` at `/npm`. Use `mounts` to replace these defaults when the app needs different public or root-relative directory roots.
69
+ - Mounts preserve every path segment beneath their public and filesystem roots. Do not configure overlapping public or filesystem roots.
74
70
  - CSS files are compiled and served alongside scripts. Local CSS `@import` rules are rewritten and fingerprinted with the same asset server routing rules.
75
71
 
76
72
  ## Rendering HTML
@@ -78,13 +74,13 @@ export default createController(routes, {
78
74
  Use `getHref()` when you need the public URL for one module, and `getPreloads()` when you want `<link rel="modulepreload">` tags or `Link` headers for one or more entrypoints and their dependencies.
79
75
 
80
76
  ```typescript
81
- let entryHref = await assetServer.getHref('app/actions/public/entry.ts')
82
- let entryPreloads = await assetServer.getPreloads('app/actions/public/entry.ts')
77
+ let entryHref = await assets.getHref('app/actions/public/entry.ts')
78
+ let entryPreloads = await assets.getPreloads('app/actions/public/entry.ts')
83
79
  ```
84
80
 
85
81
  Use this when rendering documents or layouts that boot browser behavior with a known client entry.
86
82
 
87
- When resolving hydrated client entries during server rendering, pass the source entry ID from `clientEntry(import.meta.url, ...)` to `getHref()` and `getPreloads()` inside `resolveClientEntry`. Return those preload hrefs through the entry's `preloads` property so the browser can fetch the module graph without walking it serially. Keep this resolution in the shared render helper rather than hard-coding public asset URLs in source-owned component modules.
83
+ For normal Remix applications, pass the asset server to `render({ assets })` from `remix/middleware/render`. The middleware resolves source entry IDs from `clientEntry(import.meta.url, ...)` with `getHref()` and `getPreloads()` and applies the UI renderer's explicit-hash or named-component export rules. Use a custom `resolveClientEntry` callback only when building a custom rendering pipeline.
88
84
 
89
85
  ## Development vs Deployment
90
86
 
@@ -117,7 +113,6 @@ const isHmr = Boolean(isDevelopment && process.env.REMIX_NODE_HMR)
117
113
 
118
114
  const assetServer = createAssetServer({
119
115
  basePath: '/assets',
120
- fileMap: { '/app/*path': 'app/*path' },
121
116
  allowFiles: ['app/routes.ts', 'app/**/public/**'],
122
117
  denyFiles: ['app/**/*.test.*'],
123
118
  watch: isDevelopment,
@@ -247,7 +247,6 @@ function logout(context) {
247
247
 
248
248
  ```typescript
249
249
  import {
250
- createAtmosphereAuthProvider,
251
250
  createGoogleAuthProvider,
252
251
  createGitHubAuthProvider,
253
252
  startExternalAuth,
@@ -267,21 +266,8 @@ let githubProvider = createGitHubAuthProvider({
267
266
  clientSecret: process.env.GITHUB_CLIENT_SECRET,
268
267
  redirectUri: new URL(routes.auth.github.callback.href(), origin),
269
268
  })
270
-
271
- let atmosphereSessionSecret = process.env.ATMOSPHERE_SESSION_SECRET
272
- if (!atmosphereSessionSecret && process.env.NODE_ENV !== 'test') {
273
- throw new Error('ATMOSPHERE_SESSION_SECRET is required')
274
- }
275
-
276
- let atmosphereProvider = createAtmosphereAuthProvider({
277
- clientId: 'https://app.example.com/oauth/client-metadata.json',
278
- redirectUri: new URL(routes.auth.atmosphere.callback.href(), origin),
279
- sessionSecret: atmosphereSessionSecret ?? 'test-only-secret',
280
- })
281
269
  ```
282
270
 
283
- For Atmosphere-compatible atproto OAuth, create the provider once, call `atmosphereProvider.prepare(handleOrDid)` before `startExternalAuth(...)`, then pass the same module-scope provider to `finishExternalAuth(...)` and `refreshExternalAuth(...)`.
284
-
285
271
  ### OAuth controller
286
272
 
287
273
  ```typescript
@@ -318,7 +304,7 @@ export default createController(routes.auth.google, {
318
304
 
319
305
  ### Refresh stored provider tokens
320
306
 
321
- Use `refreshExternalAuth(provider, tokens)` when an app has stored OAuth/OIDC tokens and needs a fresh access token from a refresh token. Built-in OIDC providers, X, and Atmosphere support refresh-token exchange. If the provider does not rotate the refresh token, the refreshed bundle preserves the current one.
307
+ Use `refreshExternalAuth(provider, tokens)` when an app has stored OAuth/OIDC tokens and needs a fresh access token from a refresh token. Built-in OIDC providers and X support refresh-token exchange. If the provider does not rotate the refresh token, the refreshed bundle preserves the current one.
322
308
 
323
309
  ```typescript
324
310
  async function refreshGoogleTokens({ get }) {
@@ -210,7 +210,7 @@ function ThemedContent(handle: Handle) {
210
210
  For granular updates without re-rendering the full subtree, use `TypedEventTarget`:
211
211
 
212
212
  ```tsx
213
- import { TypedEventTarget, addEventListeners } from 'remix/ui'
213
+ import { TypedEventTarget } from 'remix/ui'
214
214
 
215
215
  class Theme extends TypedEventTarget<{ change: Event }> {
216
216
  #value: 'light' | 'dark' = 'light'
@@ -239,32 +239,34 @@ function ThemeProvider(handle: Handle<{ children?: RemixNode }, Theme>) {
239
239
 
240
240
  function ThemedContent(handle: Handle) {
241
241
  let theme = handle.context.get(ThemeProvider)
242
- addEventListeners(theme, handle.signal, {
243
- change() {
244
- handle.update()
245
- },
246
- })
242
+ theme.addEventListener('change', () => handle.update(), { signal: handle.signal })
247
243
  return () => <div>Theme: {theme.value}</div>
248
244
  }
249
245
  ```
250
246
 
251
247
  ## Global Events
252
248
 
253
- Use `addEventListeners(target, handle.signal, listeners)` to listen to global targets with automatic cleanup when the component disconnects:
249
+ Use `on(...)` for element events. For browser globals such as `window` or `document`, schedule setup with `handle.queueTask()` and pass `handle.signal` to `addEventListener()` so the listener is removed when the component disconnects:
254
250
 
255
251
  ```tsx
256
- import { addEventListeners, type Handle } from 'remix/ui'
252
+ import type { Handle } from 'remix/ui'
257
253
 
258
- function ResizeTracker(handle: Handle) {
259
- let width = window.innerWidth
254
+ function ViewportWidth(handle: Handle) {
255
+ let width: number | undefined
260
256
 
261
- addEventListeners(window, handle.signal, {
262
- resize() {
263
- width = window.innerWidth
264
- handle.update()
265
- },
257
+ handle.queueTask(() => {
258
+ width = window.innerWidth
259
+ window.addEventListener(
260
+ 'resize',
261
+ () => {
262
+ width = window.innerWidth
263
+ handle.update()
264
+ },
265
+ { signal: handle.signal },
266
+ )
267
+ handle.update()
266
268
  })
267
269
 
268
- return () => <div>{width}</div>
270
+ return () => <div>{width === undefined ? 'Measuring…' : `${width}px`}</div>
269
271
  }
270
272
  ```