@remix-run/assets 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 (40) hide show
  1. package/README.md +85 -92
  2. package/dist/assets.d.ts +2 -0
  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 +21 -7
  8. package/dist/lib/asset-server.d.ts.map +1 -1
  9. package/dist/lib/asset-server.js +30 -15
  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.js +2 -2
  13. package/dist/lib/injected-packages.d.ts +3 -2
  14. package/dist/lib/injected-packages.d.ts.map +1 -1
  15. package/dist/lib/injected-packages.js +11 -8
  16. package/dist/lib/inspection.d.ts +39 -0
  17. package/dist/lib/inspection.d.ts.map +1 -0
  18. package/dist/lib/inspection.js +160 -0
  19. package/dist/lib/routes.d.ts +11 -3
  20. package/dist/lib/routes.d.ts.map +1 -1
  21. package/dist/lib/routes.js +124 -80
  22. package/dist/lib/scripts/compiler.js +2 -2
  23. package/dist/lib/scripts/resolve.js +9 -9
  24. package/dist/lib/scripts/transform.js +2 -2
  25. package/dist/lib/styles/resolve.js +8 -8
  26. package/dist/lib/styles/transform.js +2 -2
  27. package/package.json +5 -6
  28. package/src/assets.ts +2 -0
  29. package/src/lib/access.ts +83 -30
  30. package/src/lib/asset-server.ts +50 -22
  31. package/src/lib/compilation-error.ts +3 -3
  32. package/src/lib/files/compiler.ts +2 -2
  33. package/src/lib/injected-packages.ts +16 -10
  34. package/src/lib/inspection.ts +229 -0
  35. package/src/lib/routes.ts +158 -126
  36. package/src/lib/scripts/compiler.ts +2 -2
  37. package/src/lib/scripts/resolve.ts +9 -9
  38. package/src/lib/scripts/transform.ts +2 -2
  39. package/src/lib/styles/resolve.ts +8 -8
  40. package/src/lib/styles/transform.ts +2 -2
@@ -17,7 +17,7 @@ import type {
17
17
  import { getFingerprintRequestCacheControl, parseFingerprintSuffix } from './fingerprint.ts'
18
18
  import { createHmrClientSource } from './hmr.ts'
19
19
  import type { HmrPayload } from './hmr.ts'
20
- import { getInjectedPackageRouteConfigs } from './injected-packages.ts'
20
+ import { getInjectedPackageMountConfigs } from './injected-packages.ts'
21
21
  import type { ModuleLoader } from './loaders.ts'
22
22
  import { normalizeFilePath, normalizePathname } from './paths.ts'
23
23
  import { compileRoutes } from './routes.ts'
@@ -29,6 +29,7 @@ import { createResponseForStyle, createStyleCompiler, isStyleFilePath } from './
29
29
  import { resolveScriptTarget, resolveStyleTarget } from './target.ts'
30
30
  import type { AssetTarget, ResolvedScriptTarget, ResolvedStyleTarget } from './target.ts'
31
31
  import { createAssetServerWatcher } from './watch.ts'
32
+ import { createAssetInspector, type AssetDetails } from './inspection.ts'
32
33
  import type { AssetServerWatcher, ChokidarWatcher } from './watch.ts'
33
34
 
34
35
  interface AssetServerWatchOptions {
@@ -172,6 +173,10 @@ interface AssetServerScriptOptions {
172
173
  }
173
174
 
174
175
  const scriptExtensionSet = new Set<string>(supportedScriptExtensions)
176
+ const defaultMounts = {
177
+ app: 'app',
178
+ npm: 'node_modules',
179
+ } as const
175
180
 
176
181
  /**
177
182
  * Options used to construct an {@link AssetServer} via {@link createAssetServer}.
@@ -179,8 +184,14 @@ const scriptExtensionSet = new Set<string>(supportedScriptExtensions)
179
184
  export interface AssetServerOptions<transforms extends AssetRequestTransformMap = {}> {
180
185
  /** Public mount path for this asset server, e.g. `'/assets'`. */
181
186
  basePath: string
182
- /** File patterns keyed by public URL patterns. */
183
- fileMap: Readonly<Record<string, string>>
187
+ /**
188
+ * Directories to mount at public URL paths.
189
+ *
190
+ * Each key is a public URL path and its value is a directory relative to `rootDir`. Defaults to
191
+ * `{ app: 'app', npm: 'node_modules' }`. Public paths must not contain query strings, fragments,
192
+ * or encoded dot segments.
193
+ */
194
+ mounts?: Readonly<Record<string, string>>
184
195
  /**
185
196
  * Root directory used to resolve relative file paths. Defaults to `process.cwd()`.
186
197
  */
@@ -191,7 +202,7 @@ export interface AssetServerOptions<transforms extends AssetRequestTransformMap
191
202
  allowFiles: readonly string[]
192
203
  /**
193
204
  * Exact package names whose files are allowed to be served. Dependencies and installed optional
194
- * dependencies are allowed automatically. Package files must still match `fileMap`.
205
+ * dependencies are allowed automatically. Package files must still be within a configured mount.
195
206
  */
196
207
  allowPackages?: readonly string[]
197
208
  /**
@@ -289,6 +300,16 @@ export interface AssetServer<transforms extends AssetRequestTransformMap = {}> {
289
300
  * Returns preload URLs for one or more served asset files, ordered shallowest-first.
290
301
  */
291
302
  getPreloads(filePath: string | readonly string[]): Promise<string[]>
303
+ /**
304
+ * Returns diagnostic details about one public asset URL or file path, including the matched mount
305
+ * roots, access rules, file type, and browser-reachability status.
306
+ */
307
+ getAssetDetails(input: string): Promise<AssetDetails>
308
+ /**
309
+ * Returns every file currently reachable through this asset server, sorted by public URL and
310
+ * then absolute file path.
311
+ */
312
+ getAssets(): Promise<AssetDetails[]>
292
313
  /**
293
314
  * Closes this server's filesystem watcher and browser HMR channel.
294
315
  *
@@ -312,6 +333,7 @@ type ResolvedAssetServerOptions<transforms extends AssetRequestTransformMap> = {
312
333
  loaders: readonly ModuleLoader[]
313
334
  onError: NonNullable<AssetServerOptions['onError']>
314
335
  rootDir: string
336
+ mounts: Readonly<Record<string, string>>
315
337
  routes: CompiledRoutes
316
338
  sourceMapSourcePaths: 'url' | 'absolute'
317
339
  sourceMaps?: 'inline' | 'external'
@@ -339,7 +361,7 @@ export function getInternalWatchTargets<transforms extends AssetRequestTransform
339
361
  * Create an asset server instance
340
362
  *
341
363
  * Compiles TypeScript/JavaScript scripts and CSS styles on demand with optional
342
- * source-based URL fingerprinting, caching, and configurable file mapping.
364
+ * source-based URL fingerprinting, caching, and configurable directory mounts.
343
365
  *
344
366
  * @param options Server configuration
345
367
  * @returns A {@link AssetServer} with `fetch()`, `getHref()`, and `getPreloads()` methods
@@ -348,9 +370,6 @@ export function getInternalWatchTargets<transforms extends AssetRequestTransform
348
370
  * ```ts
349
371
  * let assetServer = createAssetServer({
350
372
  * basePath: '/assets',
351
- * fileMap: {
352
- * '/app/*path': 'app/*path',
353
- * },
354
373
  * allowFiles: ['app/routes.ts', 'app/**\/public/**'],
355
374
  * allowPackages: ['remix'],
356
375
  * denyFiles: ['app/**\/*.test.*'],
@@ -368,10 +387,17 @@ export function createAssetServer<const transforms extends AssetRequestTransform
368
387
  allowPackages: resolvedOptions.allowPackages,
369
388
  denyFiles: resolvedOptions.denyFiles,
370
389
  packageSearchRoots: hasPackages(resolvedOptions.allowPackages)
371
- ? getPackageSearchRoots(options.fileMap, resolvedOptions.rootDir)
390
+ ? getPackageSearchRoots(resolvedOptions.mounts, resolvedOptions.rootDir)
372
391
  : undefined,
373
392
  rootDir: resolvedOptions.rootDir,
374
393
  })
394
+ let assetInspector = createAssetInspector({
395
+ accessPolicy,
396
+ allowFiles: resolvedOptions.allowFiles,
397
+ fileExtensions: resolvedOptions.files.extensions,
398
+ rootDir: resolvedOptions.rootDir,
399
+ routes: resolvedOptions.routes,
400
+ })
375
401
  let watcher: AssetServerWatcher | null = null
376
402
  let chokidarWatcher: ChokidarWatcher | null = null
377
403
  let fileCompiler: FileCompiler | null = null
@@ -575,6 +601,12 @@ export function createAssetServer<const transforms extends AssetRequestTransform
575
601
  }
576
602
 
577
603
  let assetServer: AssetServer<transforms> = {
604
+ getAssetDetails(input) {
605
+ return assetInspector.getAssetDetails(input)
606
+ },
607
+ getAssets() {
608
+ return assetInspector.getAssets()
609
+ },
578
610
  async fetch(request) {
579
611
  if (request.method !== 'GET' && request.method !== 'HEAD') return null
580
612
  let requestPathname = new URL(request.url).pathname
@@ -1006,10 +1038,14 @@ function resolveAssetServerOptions<transforms extends AssetRequestTransformMap>(
1006
1038
  })
1007
1039
  let watchOptions = normalizeWatchOptions(options.watch)
1008
1040
  let hmrFactory = normalizeHmrFactory(options.hmr)
1041
+ let mounts = options.mounts ?? defaultMounts
1009
1042
 
1010
1043
  if (hmrFactory && watchOptions === null) {
1011
1044
  throw new TypeError('hmr requires watch mode')
1012
1045
  }
1046
+ if (Object.keys(mounts).length === 0) {
1047
+ throw new TypeError('mounts must include at least one entry')
1048
+ }
1013
1049
  return {
1014
1050
  allowFiles: options.allowFiles,
1015
1051
  allowPackages: options.allowPackages,
@@ -1022,15 +1058,16 @@ function resolveAssetServerOptions<transforms extends AssetRequestTransformMap>(
1022
1058
  fingerprintAssets: fingerprintOptions.enabled,
1023
1059
  hmr: hmrFactory,
1024
1060
  minify: options.minify ?? false,
1061
+ mounts,
1025
1062
  loaders: scriptOptions.loaders ?? [],
1026
1063
  onError: options.onError ?? defaultErrorHandler,
1027
1064
  rootDir,
1028
1065
  routes: compileRoutes(basePath, [
1029
1066
  {
1030
- fileMap: options.fileMap,
1067
+ mounts,
1031
1068
  rootDir,
1032
1069
  },
1033
- ...getInjectedPackageRouteConfigs(),
1070
+ ...getInjectedPackageMountConfigs(),
1034
1071
  ]),
1035
1072
  sourceMapSourcePaths: options.sourceMapSourcePaths ?? 'url',
1036
1073
  sourceMaps: options.sourceMaps,
@@ -1154,19 +1191,10 @@ function hasPackages(packages: readonly string[] | undefined): boolean {
1154
1191
  }
1155
1192
 
1156
1193
  function getPackageSearchRoots(
1157
- fileMap: AssetServerOptions['fileMap'],
1194
+ mounts: Readonly<Record<string, string>>,
1158
1195
  rootDir: string,
1159
1196
  ): readonly string[] {
1160
- return Object.values(fileMap).map((filePattern) =>
1161
- path.resolve(rootDir, getStaticFilePatternPrefix(filePattern)),
1162
- )
1163
- }
1164
-
1165
- function getStaticFilePatternPrefix(filePattern: string): string {
1166
- let firstDynamicIndex = filePattern.search(/[*:]/)
1167
- let staticPrefix =
1168
- firstDynamicIndex === -1 ? filePattern : filePattern.slice(0, firstDynamicIndex)
1169
- return staticPrefix.replace(/[/\\]*$/, '')
1197
+ return Object.values(mounts).map((fileRoot) => path.resolve(rootDir, fileRoot))
1170
1198
  }
1171
1199
 
1172
1200
  function parseAssetRequestPathname(
@@ -2,7 +2,7 @@ type AssetServerCompilationErrorCode =
2
2
  | 'FILE_NOT_FOUND'
3
3
  | 'FILE_NOT_ALLOWED'
4
4
  | 'FILE_NOT_SUPPORTED'
5
- | 'FILE_OUTSIDE_FILE_MAP'
5
+ | 'FILE_OUTSIDE_MOUNTS'
6
6
  | 'FILE_TRANSFORM_QUERY_INVALID'
7
7
  | 'FILE_TRANSFORM_NOT_SUPPORTED'
8
8
  | 'FILE_TRANSFORM_RESULT_INVALID'
@@ -14,11 +14,11 @@ type AssetServerCompilationErrorCode =
14
14
  | 'IMPORT_RESOLUTION_FAILED'
15
15
  | 'IMPORT_NOT_SUPPORTED'
16
16
  | 'IMPORT_NOT_ALLOWED'
17
- | 'IMPORT_OUTSIDE_FILE_MAP'
17
+ | 'IMPORT_OUTSIDE_MOUNTS'
18
18
  | 'URL_RESOLUTION_FAILED'
19
19
  | 'URL_NOT_SUPPORTED'
20
20
  | 'URL_NOT_ALLOWED'
21
- | 'URL_OUTSIDE_FILE_MAP'
21
+ | 'URL_OUTSIDE_MOUNTS'
22
22
 
23
23
  /**
24
24
  * Internal error used by the request-time asset compilation pipeline.
@@ -566,9 +566,9 @@ export function resolveServedFileOrThrow(filePath: string, args: ResolveArgs): R
566
566
  let stableUrlPathname = args.routes.toUrlPathname(identityPath)
567
567
  if (!stableUrlPathname) {
568
568
  throw createAssetServerCompilationError(
569
- `File ${identityPath} is outside all configured fileMap entries.`,
569
+ `File ${identityPath} is outside all configured mounts.`,
570
570
  {
571
- code: 'FILE_OUTSIDE_FILE_MAP',
571
+ code: 'FILE_OUTSIDE_MOUNTS',
572
572
  },
573
573
  )
574
574
  }
@@ -28,23 +28,29 @@ export function isInjectedPackageFilePath(filePath: string): boolean {
28
28
  return false
29
29
  }
30
30
 
31
- export function getInjectedPackageRouteConfigs(): {
32
- fileMap: Record<string, string>
31
+ export function getInjectedPackageMountConfigs(): {
32
+ mounts: Record<string, string>
33
33
  rootDir: string
34
34
  }[] {
35
35
  return injectedPackageNames.map((packageName) => {
36
36
  let { packageRoot } = getResolvedInjectedPackage(packageName)
37
- let { filePattern, routeRoot } = getInjectedPackageRoute(packageRoot, packageName)
37
+ let { fileRoot, routeRoot } = getInjectedPackageRoute(packageRoot, packageName)
38
38
 
39
39
  return {
40
- fileMap: {
41
- [getInjectedPackageRoutePattern(packageName)]: filePattern,
40
+ mounts: {
41
+ [getInjectedPackageMountPath(packageName)]: fileRoot,
42
42
  },
43
43
  rootDir: routeRoot,
44
44
  }
45
45
  })
46
46
  }
47
47
 
48
+ export function getInjectedPackageRoots(): readonly string[] {
49
+ return injectedPackageNames.map(
50
+ (packageName) => getResolvedInjectedPackage(packageName).packageRoot,
51
+ )
52
+ }
53
+
48
54
  export function getInjectedPackageNameForSpecifier(specifier: string): string | null {
49
55
  for (let injectedSpecifier of generatedInjectedPackageSpecifiers) {
50
56
  if (specifier === injectedSpecifier) {
@@ -119,17 +125,17 @@ function getResolvedInjectedPackage(packageName: string): ResolvedInjectedPackag
119
125
  return resolvedInjectedPackage
120
126
  }
121
127
 
122
- function getInjectedPackageRoutePattern(packageName: string): string {
123
- return `${injectedPackagesBasePath}/${packageName}/*path`
128
+ function getInjectedPackageMountPath(packageName: string): string {
129
+ return `${injectedPackagesBasePath}/${packageName}`
124
130
  }
125
131
 
126
132
  function getInjectedPackageRoute(
127
133
  packageRoot: string,
128
134
  packageName: string,
129
- ): { filePattern: string; routeRoot: string } {
135
+ ): { fileRoot: string; routeRoot: string } {
130
136
  if (!packageRoot.endsWith(`/${packageName}`)) {
131
137
  return {
132
- filePattern: `${packageRoot.slice(getFilePathDirectory(packageRoot).length + 1)}/*path`,
138
+ fileRoot: packageRoot.slice(getFilePathDirectory(packageRoot).length + 1),
133
139
  routeRoot: getFilePathDirectory(packageRoot),
134
140
  }
135
141
  }
@@ -141,7 +147,7 @@ function getInjectedPackageRoute(
141
147
  }
142
148
 
143
149
  return {
144
- filePattern: `${packageName}/*path`,
150
+ fileRoot: packageName,
145
151
  routeRoot,
146
152
  }
147
153
  }
@@ -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
+ }