@remix-run/assets 0.5.0 → 0.7.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 (84) hide show
  1. package/README.md +185 -104
  2. package/dist/assets.d.ts +4 -1
  3. package/dist/assets.d.ts.map +1 -1
  4. package/dist/lib/access.d.ts +29 -2
  5. package/dist/lib/access.d.ts.map +1 -1
  6. package/dist/lib/access.js +52 -26
  7. package/dist/lib/asset-server.d.ts +61 -25
  8. package/dist/lib/asset-server.d.ts.map +1 -1
  9. package/dist/lib/asset-server.js +127 -55
  10. package/dist/lib/compilation-error.d.ts +1 -1
  11. package/dist/lib/compilation-error.d.ts.map +1 -1
  12. package/dist/lib/files/compiler.d.ts +1 -1
  13. package/dist/lib/files/compiler.d.ts.map +1 -1
  14. package/dist/lib/files/compiler.js +28 -31
  15. package/dist/lib/files/config.d.ts +6 -0
  16. package/dist/lib/files/config.d.ts.map +1 -1
  17. package/dist/lib/files/config.js +9 -0
  18. package/dist/lib/fingerprint.d.ts +0 -4
  19. package/dist/lib/fingerprint.d.ts.map +1 -1
  20. package/dist/lib/fingerprint.js +0 -4
  21. package/dist/lib/hmr.d.ts +7 -0
  22. package/dist/lib/hmr.d.ts.map +1 -1
  23. package/dist/lib/hmr.js +198 -25
  24. package/dist/lib/injected-packages.d.ts +3 -2
  25. package/dist/lib/injected-packages.d.ts.map +1 -1
  26. package/dist/lib/injected-packages.js +11 -8
  27. package/dist/lib/inspection.d.ts +39 -0
  28. package/dist/lib/inspection.d.ts.map +1 -0
  29. package/dist/lib/inspection.js +160 -0
  30. package/dist/lib/routes.d.ts +12 -3
  31. package/dist/lib/routes.d.ts.map +1 -1
  32. package/dist/lib/routes.js +124 -80
  33. package/dist/lib/scripts/compiler.d.ts +8 -2
  34. package/dist/lib/scripts/compiler.d.ts.map +1 -1
  35. package/dist/lib/scripts/compiler.js +188 -38
  36. package/dist/lib/scripts/emit.d.ts +2 -1
  37. package/dist/lib/scripts/emit.d.ts.map +1 -1
  38. package/dist/lib/scripts/emit.js +25 -16
  39. package/dist/lib/scripts/resolve.d.ts +7 -1
  40. package/dist/lib/scripts/resolve.d.ts.map +1 -1
  41. package/dist/lib/scripts/resolve.js +119 -12
  42. package/dist/lib/scripts/specifiers.d.ts +2 -0
  43. package/dist/lib/scripts/specifiers.d.ts.map +1 -0
  44. package/dist/lib/scripts/specifiers.js +9 -0
  45. package/dist/lib/scripts/transform.d.ts +2 -3
  46. package/dist/lib/scripts/transform.d.ts.map +1 -1
  47. package/dist/lib/scripts/transform.js +4 -18
  48. package/dist/lib/styles/compiler.d.ts +0 -1
  49. package/dist/lib/styles/compiler.d.ts.map +1 -1
  50. package/dist/lib/styles/compiler.js +105 -25
  51. package/dist/lib/styles/emit.d.ts +2 -1
  52. package/dist/lib/styles/emit.d.ts.map +1 -1
  53. package/dist/lib/styles/emit.js +12 -10
  54. package/dist/lib/styles/resolve.d.ts +0 -1
  55. package/dist/lib/styles/resolve.d.ts.map +1 -1
  56. package/dist/lib/styles/resolve.js +8 -9
  57. package/dist/lib/styles/transform.d.ts +0 -2
  58. package/dist/lib/styles/transform.d.ts.map +1 -1
  59. package/dist/lib/styles/transform.js +2 -9
  60. package/dist/lib/virtual-store.d.ts +8 -0
  61. package/dist/lib/virtual-store.d.ts.map +1 -0
  62. package/dist/lib/virtual-store.js +100 -0
  63. package/package.json +6 -6
  64. package/src/assets.ts +9 -1
  65. package/src/lib/access.ts +83 -30
  66. package/src/lib/asset-server.ts +224 -94
  67. package/src/lib/compilation-error.ts +4 -3
  68. package/src/lib/files/compiler.ts +32 -42
  69. package/src/lib/files/config.ts +17 -0
  70. package/src/lib/fingerprint.ts +0 -9
  71. package/src/lib/hmr.ts +210 -26
  72. package/src/lib/injected-packages.ts +16 -10
  73. package/src/lib/inspection.ts +229 -0
  74. package/src/lib/routes.ts +158 -126
  75. package/src/lib/scripts/compiler.ts +240 -54
  76. package/src/lib/scripts/emit.ts +34 -19
  77. package/src/lib/scripts/resolve.ts +175 -13
  78. package/src/lib/scripts/specifiers.ts +11 -0
  79. package/src/lib/scripts/transform.ts +6 -25
  80. package/src/lib/styles/compiler.ts +137 -39
  81. package/src/lib/styles/emit.ts +18 -13
  82. package/src/lib/styles/resolve.ts +8 -10
  83. package/src/lib/styles/transform.ts +2 -12
  84. package/src/lib/virtual-store.ts +124 -0
@@ -0,0 +1,229 @@
1
+ import * as fs from 'node:fs'
2
+ import * as fsPromises from 'node:fs/promises'
3
+ import * as path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ import type { AccessPolicy, AssetAccessDetails } from './access.ts'
7
+ import { parseFingerprintSuffix } from './fingerprint.ts'
8
+ import { getInjectedPackageRoots } from './injected-packages.ts'
9
+ import { isAbsoluteFilePath, normalizeFilePath, resolveFilePath } from './paths.ts'
10
+ import type { AssetRouteMatch, CompiledRoutes } from './routes.ts'
11
+ import { supportedScriptExtensions } from './scripts/resolve.ts'
12
+ import { isStyleFilePath } from './styles/compiler.ts'
13
+
14
+ const scriptExtensions = new Set<string>(supportedScriptExtensions)
15
+ const globSyntaxPattern = /[*?[\]{}()!+@]/
16
+
17
+ /** How the asset server handles an inspected file. */
18
+ export type AssetKind = 'file' | 'script' | 'style' | 'unsupported'
19
+
20
+ /** Browser-reachability result for an inspected asset. */
21
+ export type AssetStatus =
22
+ | 'denied'
23
+ | 'missing'
24
+ | 'not-allowed'
25
+ | 'reachable'
26
+ | 'unmapped'
27
+ | 'unsupported'
28
+
29
+ /** Diagnostic information about a configured asset URL or file path. */
30
+ export interface AssetDetails {
31
+ /** Access-control decision and the rules responsible for it. */
32
+ access?: AssetAccessDetails
33
+ /** Absolute mapped file path. */
34
+ filePath?: string
35
+ /** Configured filesystem mount root that matched the asset. */
36
+ fileRoot?: string
37
+ /** Browser-reachability result. */
38
+ status: AssetStatus
39
+ /** How the asset server handles the file. */
40
+ type?: AssetKind
41
+ /** Stable public URL pathname for the asset. */
42
+ url?: string
43
+ /** Public mount root that matched the asset. */
44
+ urlRoot?: string
45
+ }
46
+
47
+ interface AssetInspectorOptions {
48
+ accessPolicy: AccessPolicy
49
+ allowFiles: readonly string[]
50
+ fileExtensions: readonly string[]
51
+ rootDir: string
52
+ routes: CompiledRoutes
53
+ }
54
+
55
+ export interface AssetInspector {
56
+ /** Returns diagnostic information for a public URL or file path. */
57
+ getAssetDetails(input: string): Promise<AssetDetails>
58
+ /** Returns every file currently reachable through the configured asset server. */
59
+ getAssets(): Promise<AssetDetails[]>
60
+ }
61
+
62
+ export function createAssetInspector(options: AssetInspectorOptions): AssetInspector {
63
+ let fileExtensions = new Set(options.fileExtensions.map((extension) => extension.toLowerCase()))
64
+
65
+ return {
66
+ async getAssetDetails(input) {
67
+ let routeMatch = await resolveInput(input, options)
68
+ if (routeMatch === null) return { status: 'unmapped' }
69
+ return inspectRouteMatch(routeMatch, options, fileExtensions)
70
+ },
71
+ async getAssets() {
72
+ let filePaths = await discoverFilePaths(options)
73
+ let assets: AssetDetails[] = []
74
+
75
+ for (let filePath of filePaths) {
76
+ let routeMatch = options.routes.matchFilePath(filePath)
77
+ if (routeMatch === null) continue
78
+
79
+ let details = await inspectRouteMatch(routeMatch, options, fileExtensions)
80
+ if (details.status === 'reachable') assets.push(details)
81
+ }
82
+
83
+ assets.sort((left, right) => {
84
+ let urlOrder = (left.url ?? '').localeCompare(right.url ?? '')
85
+ return urlOrder === 0 ? (left.filePath ?? '').localeCompare(right.filePath ?? '') : urlOrder
86
+ })
87
+ return assets
88
+ },
89
+ }
90
+ }
91
+
92
+ async function resolveInput(
93
+ input: string,
94
+ options: Pick<AssetInspectorOptions, 'rootDir' | 'routes'>,
95
+ ): Promise<AssetRouteMatch | null> {
96
+ if (input.startsWith('file://')) {
97
+ return options.routes.matchFilePath(fileURLToPath(input))
98
+ }
99
+
100
+ if (/^[A-Za-z][A-Za-z\d+.-]*:\/\//.test(input)) {
101
+ let pathname = parseFingerprintSuffix(new URL(input).pathname).pathname
102
+ return options.routes.matchUrlPathname(pathname)
103
+ }
104
+
105
+ let filePath = resolveFilePath(options.rootDir, input)
106
+ if (!input.startsWith('/') || isAbsoluteFilePath(input)) {
107
+ if (await pathExists(filePath)) return options.routes.matchFilePath(filePath)
108
+ if (!input.startsWith('/')) return options.routes.matchFilePath(filePath)
109
+ }
110
+
111
+ let pathname = parseFingerprintSuffix(new URL(input, 'http://remix.run').pathname).pathname
112
+ return options.routes.matchUrlPathname(pathname)
113
+ }
114
+
115
+ async function inspectRouteMatch(
116
+ routeMatch: AssetRouteMatch,
117
+ options: Pick<AssetInspectorOptions, 'accessPolicy' | 'rootDir'>,
118
+ fileExtensions: ReadonlySet<string>,
119
+ ): Promise<AssetDetails> {
120
+ let exists = await pathExists(routeMatch.filePath)
121
+ let identityPath = exists ? fs.realpathSync(routeMatch.filePath) : routeMatch.filePath
122
+ let normalizedIdentityPath = normalizeFilePath(identityPath)
123
+ let access = options.accessPolicy.inspect(normalizedIdentityPath)
124
+
125
+ let type = getAssetKind(routeMatch.filePath, fileExtensions)
126
+ let details = {
127
+ access,
128
+ filePath: routeMatch.filePath,
129
+ fileRoot: routeMatch.fileRoot,
130
+ type,
131
+ url: routeMatch.urlPathname,
132
+ urlRoot: routeMatch.urlRoot,
133
+ }
134
+
135
+ if (!exists) return { ...details, status: 'missing' }
136
+ if (!access.allowed) {
137
+ return { ...details, status: access.deniedBy === undefined ? 'not-allowed' : 'denied' }
138
+ }
139
+ if (type === 'unsupported') return { ...details, status: 'unsupported' }
140
+ return { ...details, status: 'reachable' }
141
+ }
142
+
143
+ function getAssetKind(filePath: string, fileExtensions: ReadonlySet<string>): AssetKind {
144
+ let extension = path.extname(filePath).toLowerCase()
145
+ if (scriptExtensions.has(extension)) return 'script'
146
+ if (isStyleFilePath(filePath)) return 'style'
147
+ if (fileExtensions.has(extension)) return 'file'
148
+ return 'unsupported'
149
+ }
150
+
151
+ async function discoverFilePaths(options: AssetInspectorOptions): Promise<string[]> {
152
+ let roots = new Set<string>()
153
+
154
+ for (let pattern of options.allowFiles) {
155
+ roots.add(resolveDiscoveryRoot(options.rootDir, pattern))
156
+ }
157
+ for (let packageRoot of options.accessPolicy.getAllowedPackageRoots()) {
158
+ roots.add(packageRoot)
159
+ }
160
+ for (let packageRoot of getInjectedPackageRoots()) {
161
+ roots.add(packageRoot)
162
+ }
163
+
164
+ let filePaths = new Set<string>()
165
+ for (let root of roots) {
166
+ await collectFiles(root, filePaths)
167
+ }
168
+
169
+ return [...filePaths]
170
+ }
171
+
172
+ function resolveDiscoveryRoot(rootDir: string, pattern: string): string {
173
+ let dynamicIndex = pattern.search(globSyntaxPattern)
174
+ if (dynamicIndex === -1) return resolveFilePath(rootDir, pattern)
175
+
176
+ let rawStaticPrefix = pattern.slice(0, dynamicIndex)
177
+ let staticPrefix = rawStaticPrefix.replace(/[/\\]+$/, '')
178
+ if (staticPrefix.length === 0) return rootDir
179
+ return resolveFilePath(
180
+ rootDir,
181
+ /[/\\]$/.test(rawStaticPrefix) ? staticPrefix : path.dirname(staticPrefix),
182
+ )
183
+ }
184
+
185
+ async function collectFiles(root: string, filePaths: Set<string>): Promise<void> {
186
+ let stat
187
+ try {
188
+ stat = await fsPromises.stat(root)
189
+ } catch (error) {
190
+ if (isPathNotFoundError(error)) return
191
+ throw error
192
+ }
193
+
194
+ if (stat.isFile()) {
195
+ filePaths.add(normalizeFilePath(root))
196
+ return
197
+ }
198
+ if (!stat.isDirectory()) return
199
+
200
+ let entries = await fsPromises.readdir(root, { withFileTypes: true })
201
+ for (let entry of entries) {
202
+ let entryPath = path.join(root, entry.name)
203
+ if (entry.isDirectory()) {
204
+ await collectFiles(entryPath, filePaths)
205
+ } else if (entry.isFile()) {
206
+ filePaths.add(normalizeFilePath(entryPath))
207
+ }
208
+ }
209
+ }
210
+
211
+ async function pathExists(filePath: string): Promise<boolean> {
212
+ try {
213
+ await fsPromises.access(filePath)
214
+ return true
215
+ } catch (error) {
216
+ if (isPathNotFoundError(error)) return false
217
+ throw error
218
+ }
219
+ }
220
+
221
+ function isPathNotFoundError(
222
+ error: unknown,
223
+ ): error is NodeJS.ErrnoException & { code: 'ENOENT' | 'ENOTDIR' } {
224
+ return (
225
+ error instanceof Error &&
226
+ 'code' in error &&
227
+ (error.code === 'ENOENT' || error.code === 'ENOTDIR')
228
+ )
229
+ }
package/src/lib/routes.ts CHANGED
@@ -1,186 +1,218 @@
1
- import {
2
- getRoutePatternCaptures,
3
- RoutePattern,
4
- type RoutePatternCapture,
5
- } from '@remix-run/route-pattern'
6
- import { createHref } from '@remix-run/route-pattern/href'
7
- import { createMatcher, type Matcher } from '@remix-run/route-pattern/match'
1
+ import * as fs from 'node:fs'
8
2
 
9
3
  import {
10
- getRelativeFilePath,
11
4
  isAbsoluteFilePath,
12
5
  normalizeFilePath,
13
6
  normalizePathname,
14
7
  resolveFilePath,
15
8
  } from './paths.ts'
16
9
 
17
- interface AssetRouteDefinition {
18
- urlPattern: string
19
- filePattern: string
20
- }
21
-
22
- interface RouteConfig {
23
- fileMap: Readonly<Record<string, string>>
10
+ interface MountConfig {
11
+ mounts: Readonly<Record<string, string>>
24
12
  rootDir: string
25
13
  }
26
14
 
27
- interface CompiledRoute {
28
- rootDir: string
29
- urlPattern: RoutePattern
30
- urlMatcher: Matcher
31
- filePattern: RoutePattern
32
- fileMatcher: Matcher
15
+ interface CompiledMount {
16
+ fileRoot: string
17
+ fileRootValue: string
18
+ urlRoot: string
19
+ urlRootKey: string
33
20
  }
34
21
 
35
22
  export interface CompiledRoutes {
36
23
  resolveUrlPathname(pathname: string): string | null
24
+ matchUrlPathname(pathname: string): AssetRouteMatch | null
37
25
  toUrlPathname(filePath: string): string | null
26
+ matchFilePath(filePath: string): AssetRouteMatch | null
38
27
  }
39
28
 
40
- function normalizeFilePattern(pattern: string): string {
41
- if (isAbsoluteFilePath(pattern)) {
42
- throw new Error(
43
- `File route patterns must be relative to the asset server root.\nPattern: ${pattern}`,
44
- )
45
- }
46
-
47
- return normalizePathname(pattern)
29
+ export interface AssetRouteMatch {
30
+ filePath: string
31
+ fileRoot: string
32
+ urlPathname: string
33
+ urlRoot: string
48
34
  }
49
35
 
50
36
  export function compileRoutes(
51
37
  basePath: string,
52
- routeConfigs: readonly RouteConfig[],
38
+ mountConfigs: readonly MountConfig[],
53
39
  ): CompiledRoutes {
54
- if (routeConfigs.every((routeConfig) => Object.keys(routeConfig.fileMap).length === 0)) {
55
- throw new Error('createAssetServer() requires at least one configured fileMap entry.')
56
- }
57
-
58
- let compiledRoutes = routeConfigs.flatMap((routeConfig) =>
59
- Object.entries(routeConfig.fileMap).map(([urlPattern, filePattern]) =>
60
- compileRoute(
61
- {
62
- filePattern,
63
- urlPattern,
64
- },
65
- {
66
- basePath,
67
- rootDir: routeConfig.rootDir,
68
- },
69
- ),
70
- ),
71
- )
40
+ let compiledMounts = mountConfigs.flatMap((mountConfig) => {
41
+ let configMounts = Object.entries(mountConfig.mounts).map(([urlRoot, fileRoot]) =>
42
+ compileMount(urlRoot, fileRoot, {
43
+ basePath,
44
+ rootDir: mountConfig.rootDir,
45
+ }),
46
+ )
47
+ validateNoOverlappingMounts(configMounts)
48
+ return configMounts
49
+ })
50
+ validateNoOverlappingUrlRoots(compiledMounts)
72
51
 
73
52
  return {
74
53
  resolveUrlPathname(pathname) {
75
- let normalizedPathname = normalizePathname(pathname)
76
-
77
- for (let route of compiledRoutes) {
78
- let match = route.urlMatcher.match(`http://remix.run${normalizedPathname}`)
79
- if (!match) continue
80
- let relativeFilePath = decodeURIComponent(
81
- createHref(route.filePattern, match.params),
82
- ).replace(/^\/+/, '')
83
- return resolveFilePath(route.rootDir, relativeFilePath)
84
- }
85
-
86
- return null
54
+ return matchUrlPathname(pathname)?.filePath ?? null
87
55
  },
56
+ matchUrlPathname,
88
57
  toUrlPathname(filePath) {
89
- let normalizedFilePath = normalizeFilePath(filePath)
58
+ return matchFilePath(filePath)?.urlPathname ?? null
59
+ },
60
+ matchFilePath,
61
+ }
90
62
 
91
- for (let route of compiledRoutes) {
92
- let relativeFilePath = getRelativeFilePath(route.rootDir, normalizedFilePath)
93
- let match = route.fileMatcher.match(`http://remix.run/${relativeFilePath}`)
94
- if (!match) continue
95
- return normalizePathname(createHref(route.urlPattern, match.params))
63
+ function matchUrlPathname(pathname: string): AssetRouteMatch | null {
64
+ let normalizedPathname = normalizePathname(pathname)
65
+
66
+ for (let mount of compiledMounts) {
67
+ let relativePathname = getPathWithinRoot(mount.urlRoot, normalizedPathname)
68
+ if (relativePathname === null) continue
69
+ let filePath = resolveFilePath(mount.fileRoot, decodeURIComponent(relativePathname))
70
+ if (getPathWithinRoot(mount.fileRoot, filePath) === null) return null
71
+
72
+ return {
73
+ filePath,
74
+ fileRoot: mount.fileRootValue,
75
+ urlPathname: normalizedPathname,
76
+ urlRoot: mount.urlRoot,
96
77
  }
78
+ }
97
79
 
98
- return null
99
- },
80
+ return null
81
+ }
82
+
83
+ function matchFilePath(filePath: string): AssetRouteMatch | null {
84
+ let normalizedFilePath = normalizeFilePath(filePath)
85
+
86
+ for (let mount of compiledMounts) {
87
+ let relativeFilePath = getPathWithinRoot(mount.fileRoot, normalizedFilePath)
88
+ if (relativeFilePath === null) continue
89
+ let encodedFilePath = relativeFilePath.split('/').map(encodeURIComponent).join('/')
90
+
91
+ return {
92
+ filePath: normalizedFilePath,
93
+ fileRoot: mount.fileRootValue,
94
+ urlPathname: joinUrlPath(mount.urlRoot, encodedFilePath),
95
+ urlRoot: mount.urlRoot,
96
+ }
97
+ }
98
+
99
+ return null
100
100
  }
101
101
  }
102
102
 
103
- function compileRoute(
104
- route: AssetRouteDefinition,
103
+ function compileMount(
104
+ urlRoot: string,
105
+ fileRoot: string,
105
106
  options: {
106
107
  basePath: string
107
108
  rootDir: string
108
109
  },
109
- ): CompiledRoute {
110
- let basePath = normalizePathname(options.basePath).replace(/\/+$/, '') || '/'
111
- let relativeUrlPattern = normalizePathname(route.urlPattern)
112
- let urlPatternSource = normalizePathname(
113
- `${basePath.replace(/\/+$/, '')}/${relativeUrlPattern.replace(/^\/+/, '')}`,
114
- )
115
- let filePatternSource = normalizeFilePattern(route.filePattern)
116
-
117
- let urlPattern = RoutePattern.parse(urlPatternSource)
118
- let filePattern = RoutePattern.parse(filePatternSource)
119
-
120
- validateNoUnnamedWildcards(urlPattern, 'URL')
121
- validateNoUnnamedWildcards(filePattern, 'File')
122
- validateRoutePatterns(urlPattern, filePattern)
110
+ ): CompiledMount {
111
+ if (isAbsoluteFilePath(fileRoot)) {
112
+ throw new TypeError(`mounts values must be relative to rootDir. Received "${fileRoot}".`)
113
+ }
123
114
 
124
115
  return {
125
- rootDir: normalizeFilePath(options.rootDir).replace(/\/+$/, ''),
126
- urlPattern,
127
- urlMatcher: createMatcher(urlPattern),
128
- filePattern,
129
- fileMatcher: createMatcher(stripDotSegments(filePatternSource)),
116
+ fileRoot: resolveMountFileRoot(options.rootDir, fileRoot),
117
+ fileRootValue: fileRoot,
118
+ urlRoot: joinUrlPath(normalizeMountUrlRoot(options.basePath), normalizeMountUrlRoot(urlRoot)),
119
+ urlRootKey: urlRoot,
130
120
  }
131
121
  }
132
122
 
133
- function stripDotSegments(pattern: string): string {
134
- let segments: string[] = []
123
+ function normalizeMountUrlRoot(urlRoot: string): string {
124
+ let normalizedRoot = normalizePathname(urlRoot).replace(/\/+$/, '') || '/'
125
+ let url = new URL(normalizedRoot, 'http://remix.run')
135
126
 
136
- for (let segment of pattern.split('/')) {
137
- if (segment === '' || segment === '.') continue
138
- if (segment === '..') {
139
- segments.pop()
140
- continue
141
- }
142
- segments.push(segment)
127
+ if (
128
+ url.search !== '' ||
129
+ url.hash !== '' ||
130
+ getUrlPathSegmentCount(url.pathname) !== getUrlPathSegmentCount(normalizedRoot)
131
+ ) {
132
+ throw new TypeError(
133
+ `mounts keys must be URL pathnames without query strings, fragments, or encoded dot segments. Received "${urlRoot}".`,
134
+ )
143
135
  }
144
136
 
145
- return segments.join('/')
137
+ return url.pathname.replace(/\/+$/, '') || '/'
146
138
  }
147
139
 
148
- function validateRoutePatterns(urlPattern: RoutePattern, filePattern: RoutePattern): void {
149
- let urlCaptures = getPathnameCaptures(urlPattern)
150
- let fileCaptures = getPathnameCaptures(filePattern)
151
- if (urlCaptures.length !== fileCaptures.length) {
152
- throw new Error(
153
- `Route patterns must have matching capture structure.\nURL: ${urlPattern}\nFile: ${filePattern}`,
154
- )
140
+ export function resolveMountFileRoot(rootDir: string, fileRoot: string): string {
141
+ let resolvedRoot = resolveFilePath(rootDir, fileRoot)
142
+
143
+ try {
144
+ resolvedRoot = normalizeFilePath(fs.realpathSync(resolvedRoot))
145
+ } catch (error) {
146
+ if (!isUnresolvedPathError(error, resolvedRoot)) throw error
155
147
  }
156
148
 
157
- for (let i = 0; i < urlCaptures.length; i++) {
158
- let urlCapture = urlCaptures[i]
159
- let fileCapture = fileCaptures[i]
160
- if (urlCapture.type !== fileCapture.type || urlCapture.name !== fileCapture.name) {
161
- throw new Error(
162
- `Route patterns must have matching capture structure.\nURL: ${urlPattern}\nFile: ${filePattern}`,
163
- )
149
+ return resolvedRoot.replace(/\/+$/, '') || '/'
150
+ }
151
+
152
+ function getUrlPathSegmentCount(pathname: string): number {
153
+ return pathname.split('/').filter((segment) => segment !== '').length
154
+ }
155
+
156
+ function joinUrlPath(root: string, path: string): string {
157
+ if (path === '' || path === '/') return normalizeMountUrlRoot(root)
158
+ return normalizePathname(`${root.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`)
159
+ }
160
+
161
+ function getPathWithinRoot(root: string, value: string): string | null {
162
+ if (value === root) return ''
163
+ if (root === '/') return value.slice(1)
164
+ if (!value.startsWith(`${root}/`)) return null
165
+ return value.slice(root.length + 1)
166
+ }
167
+
168
+ function validateNoOverlappingMounts(mounts: readonly CompiledMount[]): void {
169
+ for (let index = 0; index < mounts.length; index++) {
170
+ let mount = mounts[index]
171
+
172
+ for (let otherIndex = index + 1; otherIndex < mounts.length; otherIndex++) {
173
+ let otherMount = mounts[otherIndex]
174
+
175
+ if (rootsOverlap(mount.fileRoot, otherMount.fileRoot)) {
176
+ throw new TypeError(
177
+ `mounts values must not overlap. Received "${mount.fileRootValue}" and "${otherMount.fileRootValue}", resolving to "${mount.fileRoot}" and "${otherMount.fileRoot}".`,
178
+ )
179
+ }
164
180
  }
165
181
  }
166
182
  }
167
183
 
168
- function validateNoUnnamedWildcards(pattern: RoutePattern, label: string): void {
169
- if (
170
- getRoutePatternCaptures(pattern).some(
171
- (capture) => capture.part === 'pathname' && capture.type === '*' && capture.name === '*',
172
- )
173
- ) {
174
- throw new Error(
175
- `${label} route patterns must use named wildcards for reversible mapping.\nPattern: ${pattern}`,
176
- )
184
+ function validateNoOverlappingUrlRoots(mounts: readonly CompiledMount[]): void {
185
+ for (let index = 0; index < mounts.length; index++) {
186
+ let mount = mounts[index]
187
+
188
+ for (let otherIndex = index + 1; otherIndex < mounts.length; otherIndex++) {
189
+ let otherMount = mounts[otherIndex]
190
+ if (!rootsOverlap(mount.urlRoot, otherMount.urlRoot)) continue
191
+
192
+ throw new TypeError(
193
+ `mounts keys must not overlap. Received "${mount.urlRootKey}" and "${otherMount.urlRootKey}".`,
194
+ )
195
+ }
177
196
  }
178
197
  }
179
198
 
180
- type PathnameCapture = RoutePatternCapture & { readonly part: 'pathname' }
199
+ function rootsOverlap(root: string, otherRoot: string): boolean {
200
+ return (
201
+ root === otherRoot ||
202
+ root === '/' ||
203
+ otherRoot === '/' ||
204
+ root.startsWith(`${otherRoot}/`) ||
205
+ otherRoot.startsWith(`${root}/`)
206
+ )
207
+ }
181
208
 
182
- function getPathnameCaptures(pattern: RoutePattern): Array<PathnameCapture> {
183
- return getRoutePatternCaptures(pattern).filter(
184
- (capture): capture is PathnameCapture => capture.part === 'pathname',
209
+ function isUnresolvedPathError(error: unknown, filePath: string): boolean {
210
+ // Windows reports UNKNOWN rather than ENOENT when a UNC share cannot be reached.
211
+ return (
212
+ error instanceof Error &&
213
+ 'code' in error &&
214
+ (error.code === 'ENOENT' ||
215
+ error.code === 'ENOTDIR' ||
216
+ (error.code === 'UNKNOWN' && filePath.startsWith('//')))
185
217
  )
186
218
  }