@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.
- package/README.md +52 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/lib/cli-context.d.ts.map +1 -1
- package/dist/lib/cli-context.js +5 -2
- package/dist/lib/cli.d.ts +1 -1
- package/dist/lib/cli.js +5 -1
- package/dist/lib/commands/assets.d.ts +4 -0
- package/dist/lib/commands/assets.d.ts.map +1 -0
- package/dist/lib/commands/assets.js +90 -0
- package/dist/lib/commands/db.d.ts.map +1 -1
- package/dist/lib/commands/db.js +63 -5
- package/dist/lib/commands/help.d.ts.map +1 -1
- package/dist/lib/commands/help.js +7 -0
- package/dist/lib/completion.d.ts.map +1 -1
- package/dist/lib/completion.js +25 -1
- package/dist/lib/database-command.d.ts +5 -1
- package/dist/lib/database-command.d.ts.map +1 -1
- package/dist/lib/database-command.js +1 -0
- package/dist/lib/errors.d.ts +6 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +10 -0
- package/dist/lib/remix-config.d.ts +22 -0
- package/dist/lib/remix-config.d.ts.map +1 -1
- package/dist/lib/remix-config.js +80 -4
- package/package.json +8 -7
- package/schema/remix.json +49 -1
- package/src/index.ts +10 -0
- package/src/lib/cli-context.ts +10 -2
- package/src/lib/cli.ts +6 -1
- package/src/lib/commands/assets.ts +109 -0
- package/src/lib/commands/db.ts +75 -5
- package/src/lib/commands/help.ts +8 -0
- package/src/lib/completion.ts +41 -1
- package/src/lib/database-command.ts +6 -1
- package/src/lib/errors.ts +11 -0
- package/src/lib/remix-config.ts +128 -4
- package/template/.agents/skills/remix/SKILL.md +7 -5
- package/template/.agents/skills/remix/references/assets-and-browser-modules.md +8 -13
- package/template/.agents/skills/remix/references/auth-and-sessions.md +1 -15
- package/template/.agents/skills/remix/references/component-model.md +18 -16
- package/template/.agents/skills/remix/references/hydration-frames-navigation.md +41 -54
- package/template/.agents/skills/remix/references/middleware-and-server.md +4 -1
- package/template/.agents/skills/remix/references/mixins-styling-events.md +1 -1
- package/template/.agents/skills/remix/references/routing-and-controllers.md +1 -1
- package/template/.agents/skills/remix/references/testing-patterns.md +1 -1
- package/template/AGENTS.md +2 -3
- package/template/README.md +2 -3
- package/template/app/actions/controller.tsx +2 -4
- package/template/app/assets.ts +4 -7
- package/template/app/router.ts +5 -3
- package/template/app/middleware/render.tsx +0 -78
package/src/lib/completion.ts
CHANGED
|
@@ -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 },
|
package/src/lib/remix-config.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
-
|
|
126
|
-
-
|
|
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
|
|
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,
|
|
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
|
|
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`, `
|
|
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
|
|
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
|
|
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
|
-
- `
|
|
73
|
-
-
|
|
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
|
|
82
|
-
let entryPreloads = await
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 `
|
|
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 {
|
|
252
|
+
import type { Handle } from 'remix/ui'
|
|
257
253
|
|
|
258
|
-
function
|
|
259
|
-
let width
|
|
254
|
+
function ViewportWidth(handle: Handle) {
|
|
255
|
+
let width: number | undefined
|
|
260
256
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
```
|