@remix-run/assets 0.2.0 → 0.4.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 (71) hide show
  1. package/README.md +241 -28
  2. package/dist/assets.d.ts +1 -0
  3. package/dist/assets.d.ts.map +1 -1
  4. package/dist/assets.js +1 -0
  5. package/dist/lib/access.d.ts +1 -0
  6. package/dist/lib/access.d.ts.map +1 -1
  7. package/dist/lib/access.js +10 -1
  8. package/dist/lib/asset-server.d.ts +33 -7
  9. package/dist/lib/asset-server.d.ts.map +1 -1
  10. package/dist/lib/asset-server.js +195 -23
  11. package/dist/lib/compilation-error.d.ts +1 -1
  12. package/dist/lib/compilation-error.d.ts.map +1 -1
  13. package/dist/lib/files/compiler.d.ts +71 -0
  14. package/dist/lib/files/compiler.d.ts.map +1 -0
  15. package/dist/lib/files/compiler.js +552 -0
  16. package/dist/lib/files/config.d.ts +101 -0
  17. package/dist/lib/files/config.d.ts.map +1 -0
  18. package/dist/lib/files/config.js +219 -0
  19. package/dist/lib/files/store.d.ts +31 -0
  20. package/dist/lib/files/store.d.ts.map +1 -0
  21. package/dist/lib/files/store.js +63 -0
  22. package/dist/lib/fingerprint.d.ts +3 -3
  23. package/dist/lib/fingerprint.d.ts.map +1 -1
  24. package/dist/lib/fingerprint.js +5 -5
  25. package/dist/lib/injected-packages.d.ts +11 -0
  26. package/dist/lib/injected-packages.d.ts.map +1 -0
  27. package/dist/lib/injected-packages.js +86 -0
  28. package/dist/lib/paths.d.ts +1 -0
  29. package/dist/lib/paths.d.ts.map +1 -1
  30. package/dist/lib/paths.js +12 -0
  31. package/dist/lib/routes.d.ts +5 -7
  32. package/dist/lib/routes.d.ts.map +1 -1
  33. package/dist/lib/routes.js +41 -36
  34. package/dist/lib/scripts/compiler.d.ts +1 -0
  35. package/dist/lib/scripts/compiler.d.ts.map +1 -1
  36. package/dist/lib/scripts/compiler.js +30 -5
  37. package/dist/lib/scripts/resolve.d.ts.map +1 -1
  38. package/dist/lib/scripts/resolve.js +57 -10
  39. package/dist/lib/scripts/transform.d.ts.map +1 -1
  40. package/dist/lib/scripts/transform.js +79 -3
  41. package/dist/lib/styles/compiler.d.ts +4 -0
  42. package/dist/lib/styles/compiler.d.ts.map +1 -1
  43. package/dist/lib/styles/compiler.js +2 -0
  44. package/dist/lib/styles/emit.d.ts +3 -0
  45. package/dist/lib/styles/emit.d.ts.map +1 -1
  46. package/dist/lib/styles/emit.js +36 -1
  47. package/dist/lib/styles/resolve.d.ts +8 -1
  48. package/dist/lib/styles/resolve.d.ts.map +1 -1
  49. package/dist/lib/styles/resolve.js +85 -32
  50. package/dist/lib/watch.d.ts +2 -0
  51. package/dist/lib/watch.d.ts.map +1 -1
  52. package/dist/lib/watch.js +25 -8
  53. package/package.json +11 -5
  54. package/src/assets.ts +1 -0
  55. package/src/lib/access.ts +10 -1
  56. package/src/lib/asset-server.ts +297 -34
  57. package/src/lib/compilation-error.ts +9 -0
  58. package/src/lib/files/compiler.ts +885 -0
  59. package/src/lib/files/config.ts +479 -0
  60. package/src/lib/files/store.ts +109 -0
  61. package/src/lib/fingerprint.ts +8 -7
  62. package/src/lib/injected-packages.ts +117 -0
  63. package/src/lib/paths.ts +28 -0
  64. package/src/lib/routes.ts +67 -55
  65. package/src/lib/scripts/compiler.ts +43 -5
  66. package/src/lib/scripts/resolve.ts +89 -11
  67. package/src/lib/scripts/transform.ts +105 -3
  68. package/src/lib/styles/compiler.ts +9 -0
  69. package/src/lib/styles/emit.ts +75 -1
  70. package/src/lib/styles/resolve.ts +132 -35
  71. package/src/lib/watch.ts +34 -12
@@ -0,0 +1,885 @@
1
+ import * as fs from 'node:fs'
2
+ import * as fsp from 'node:fs/promises'
3
+ import * as path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import type { FileStorage } from '@remix-run/file-storage'
6
+ import { IfNoneMatch } from '@remix-run/headers'
7
+ import { detectContentType } from '@remix-run/mime'
8
+ import {
9
+ createAssetServerCompilationError,
10
+ isAssetServerCompilationError,
11
+ } from '../compilation-error.ts'
12
+ import type { AssetServerCompilationError } from '../compilation-error.ts'
13
+ import { formatFingerprintedPathname, generateFingerprint, hashContent } from '../fingerprint.ts'
14
+ import { normalizeFilePath, resolveFilePath } from '../paths.ts'
15
+ import type { CompiledRoutes } from '../routes.ts'
16
+ import type { AssetFileTransformResult, ResolvedAssetRequestTransformMap } from './config.ts'
17
+ import { parseAssetTransformInvocations } from './config.ts'
18
+ import { createSourceFileStore } from './store.ts'
19
+ import type {
20
+ FileSnapshot,
21
+ SourceFileMetadata,
22
+ SourceFileRecord,
23
+ SourceFileStore,
24
+ } from './store.ts'
25
+
26
+ type EmittedFile = {
27
+ body: Uint8Array
28
+ contentType: string
29
+ etag: string
30
+ extension: string
31
+ fingerprint: string | null
32
+ }
33
+
34
+ type EmittedFileMetadata = Omit<EmittedFile, 'body'>
35
+
36
+ type FileCompileResult = EmittedFile
37
+
38
+ type FileGetResult =
39
+ | {
40
+ etag: string
41
+ type: 'not-modified'
42
+ }
43
+ | {
44
+ file: FileCompileResult
45
+ type: 'file'
46
+ }
47
+
48
+ type FileGetOptions = {
49
+ ifNoneMatch: string | null
50
+ requestedFingerprint: string | null
51
+ transform: readonly string[] | null
52
+ }
53
+
54
+ type FileGetHrefOptions = {
55
+ transform: readonly string[] | null
56
+ }
57
+
58
+ type FileCompilerOptions = {
59
+ buildId?: string
60
+ cache?: FileStorage
61
+ extensions: readonly string[]
62
+ fingerprintAssets: boolean
63
+ globalTransforms: readonly {
64
+ extensions?: readonly string[]
65
+ name?: string
66
+ transform(
67
+ bytes: Uint8Array,
68
+ context: {
69
+ extension: string
70
+ filePath: string
71
+ },
72
+ ):
73
+ | string
74
+ | Uint8Array
75
+ | AssetFileTransformResult
76
+ | null
77
+ | Promise<string | Uint8Array | AssetFileTransformResult | null>
78
+ }[]
79
+ isAllowed(absolutePath: string): boolean
80
+ maxRequestTransforms: number
81
+ transforms: ResolvedAssetRequestTransformMap
82
+ rootDir: string
83
+ routes: CompiledRoutes
84
+ }
85
+
86
+ export type FileCompiler = {
87
+ getFile(filePath: string, options: FileGetOptions): Promise<FileGetResult>
88
+ getHref(filePath: string, options: FileGetHrefOptions): Promise<string>
89
+ handleFileEvent(filePath: string, event: 'add' | 'change' | 'unlink'): Promise<void>
90
+ isServedFilePath(filePath: string): boolean
91
+ validateTransformQuery(transformQuery: readonly string[]): void
92
+ }
93
+
94
+ type ResolveArgs = {
95
+ extensions: ReadonlySet<string>
96
+ isAllowed(absolutePath: string): boolean
97
+ routes: CompiledRoutes
98
+ }
99
+
100
+ type ResolvedFile = {
101
+ identityPath: string
102
+ stableUrlPathname: string
103
+ }
104
+
105
+ type ParsedRequestTransform = {
106
+ name: string
107
+ param: string | undefined
108
+ }
109
+
110
+ type FileTransformSubject =
111
+ | {
112
+ kind: 'request'
113
+ name: string
114
+ }
115
+ | {
116
+ index: number
117
+ kind: 'global'
118
+ name: string | undefined
119
+ }
120
+
121
+ export function createFileCompiler(options: FileCompilerOptions): FileCompiler {
122
+ let resolvedOptions = {
123
+ ...options,
124
+ extensionSet: new Set(options.extensions),
125
+ }
126
+ let sourceFileStore: SourceFileStore = createSourceFileStore()
127
+ let sourceFileInFlightByCacheKey = new Map<string, Promise<EmittedFile>>()
128
+ let transformedAssetMetadataByCacheKey = new Map<string, EmittedFileMetadata>()
129
+ let transformedCacheKeysByIdentityPath = new Map<string, Set<string>>()
130
+ let transformedEmitInFlightByCacheKey = new Map<string, Promise<EmittedFile>>()
131
+ let cacheEpoch = resolvedOptions.buildId ?? crypto.randomUUID()
132
+ let resolveArgs: ResolveArgs = {
133
+ extensions: resolvedOptions.extensionSet,
134
+ isAllowed: resolvedOptions.isAllowed,
135
+ routes: resolvedOptions.routes,
136
+ }
137
+
138
+ return {
139
+ async getFile(filePath, getOptions) {
140
+ let resolvedFile = resolveServedFileOrThrow(resolveInputFilePath(filePath), resolveArgs)
141
+ let record = getFreshSourceFileRecord(resolvedFile.identityPath)
142
+ if (shouldUseTransformPipeline(getOptions.transform)) {
143
+ let cacheKey = getTransformedRecordCacheKey(cacheEpoch, record, getOptions.transform)
144
+ let notModified = getNotModifiedFile(
145
+ transformedAssetMetadataByCacheKey.get(cacheKey),
146
+ getOptions,
147
+ )
148
+ if (notModified) return notModified
149
+
150
+ let transformedFile = await getOrCreateTransformedFile(record, getOptions.transform)
151
+ notModified = getNotModifiedFile(toEmittedFileMetadata(transformedFile), getOptions)
152
+ if (notModified) return notModified
153
+
154
+ return {
155
+ file: transformedFile,
156
+ type: 'file',
157
+ }
158
+ }
159
+
160
+ let notModified = getNotModifiedFile(record.metadata, getOptions)
161
+ if (notModified) return notModified
162
+
163
+ if (
164
+ record.staleMetadata &&
165
+ record.staleMetadataSnapshot &&
166
+ isFileSnapshotFresh(record.staleMetadataSnapshot)
167
+ ) {
168
+ let staleNotModified = getNotModifiedFile(record.staleMetadata, getOptions)
169
+ if (staleNotModified) return staleNotModified
170
+ }
171
+
172
+ let file = await getOrCreateSourceFile(record)
173
+ return {
174
+ file,
175
+ type: 'file',
176
+ }
177
+ },
178
+
179
+ async getHref(filePath, hrefOptions) {
180
+ let resolvedFile = resolveServedFileOrThrow(resolveInputFilePath(filePath), resolveArgs)
181
+ let record = getFreshSourceFileRecord(resolvedFile.identityPath)
182
+ let href = resolvedOptions.fingerprintAssets
183
+ ? formatFingerprintedPathname(
184
+ resolvedFile.stableUrlPathname,
185
+ (await getOrCreateSourceFileMetadata(record)).fingerprint,
186
+ )
187
+ : resolvedFile.stableUrlPathname
188
+
189
+ if (shouldUseTransformPipeline(hrefOptions.transform)) {
190
+ return appendTransformQuery(href, hrefOptions.transform)
191
+ }
192
+
193
+ return href
194
+ },
195
+
196
+ async handleFileEvent(filePath, event) {
197
+ if (!isServedFilePath(filePath, resolvedOptions.extensionSet)) return
198
+ sourceFileStore.invalidateForFileEvent(filePath, event)
199
+ clearTransformedCacheIndex(filePath)
200
+ },
201
+
202
+ isServedFilePath(filePath) {
203
+ return isServedFilePath(filePath, resolvedOptions.extensionSet)
204
+ },
205
+ validateTransformQuery(transformQuery) {
206
+ parseRequestTransforms(
207
+ transformQuery,
208
+ resolvedOptions.transforms,
209
+ resolvedOptions.maxRequestTransforms,
210
+ )
211
+ },
212
+ }
213
+
214
+ function resolveInputFilePath(filePath: string): string {
215
+ if (filePath.startsWith('file://')) {
216
+ return normalizeFilePath(fileURLToPath(new URL(filePath)))
217
+ }
218
+
219
+ if (filePath.includes('://')) {
220
+ throw new TypeError(`Expected a file path or file:// URL, received "${filePath}"`)
221
+ }
222
+
223
+ return resolveFilePath(resolvedOptions.rootDir, filePath)
224
+ }
225
+
226
+ function shouldUseTransformPipeline(transformQuery: readonly string[] | null): boolean {
227
+ return (
228
+ (transformQuery !== null && transformQuery.length > 0) ||
229
+ resolvedOptions.globalTransforms.length > 0
230
+ )
231
+ }
232
+
233
+ function getFreshSourceFileRecord(identityPath: string): SourceFileRecord {
234
+ let record = sourceFileStore.get(identityPath)
235
+ if (record.metadataSnapshot && !isFileSnapshotFresh(record.metadataSnapshot)) {
236
+ sourceFileStore.invalidate(identityPath)
237
+ clearTransformedCacheIndex(identityPath)
238
+ record = sourceFileStore.get(identityPath)
239
+ }
240
+
241
+ return record
242
+ }
243
+
244
+ async function getOrCreateSourceFile(record: SourceFileRecord): Promise<EmittedFile> {
245
+ let cacheKey = getRecordCacheKey(record)
246
+ let existing = sourceFileInFlightByCacheKey.get(cacheKey)
247
+ if (existing) return existing
248
+
249
+ let promise = (async () => {
250
+ let startedVersion = record.invalidationVersion
251
+ let body = await readFileContents(record.identityPath)
252
+ let sourceFile = record.metadata
253
+ ? createEmittedFileFromMetadata(body, record.metadata)
254
+ : await createSourceFile(body, record.identityPath)
255
+
256
+ if (record.invalidationVersion === startedVersion && !record.metadata) {
257
+ sourceFileStore.set(
258
+ record.identityPath,
259
+ toEmittedFileMetadata(sourceFile),
260
+ getFileSnapshot(record.identityPath),
261
+ )
262
+ }
263
+
264
+ return sourceFile
265
+ })()
266
+
267
+ sourceFileInFlightByCacheKey.set(cacheKey, promise)
268
+
269
+ try {
270
+ return await promise
271
+ } finally {
272
+ if (sourceFileInFlightByCacheKey.get(cacheKey) === promise) {
273
+ sourceFileInFlightByCacheKey.delete(cacheKey)
274
+ }
275
+ }
276
+ }
277
+
278
+ async function getOrCreateSourceFileMetadata(
279
+ record: SourceFileRecord,
280
+ ): Promise<SourceFileMetadata> {
281
+ if (record.metadata) return record.metadata
282
+ return toEmittedFileMetadata(await getOrCreateSourceFile(record))
283
+ }
284
+
285
+ async function getOrCreateTransformedFile(
286
+ record: SourceFileRecord,
287
+ transformQuery: readonly string[] | null,
288
+ ): Promise<EmittedFile> {
289
+ let parsedTransforms = parseRequestTransforms(
290
+ transformQuery,
291
+ resolvedOptions.transforms,
292
+ resolvedOptions.maxRequestTransforms,
293
+ )
294
+ let cacheKey = getTransformedRecordCacheKey(cacheEpoch, record, transformQuery)
295
+ let existing = transformedEmitInFlightByCacheKey.get(cacheKey)
296
+ if (existing) return existing
297
+
298
+ let promise = (async () => {
299
+ let sourceFile = await getOrCreateSourceFile(record)
300
+ let cachedFile = await getCachedTransformedFile(
301
+ cacheKey,
302
+ record.identityPath,
303
+ sourceFile.fingerprint,
304
+ )
305
+ if (cachedFile) return cachedFile
306
+
307
+ let transformedFile = await applyTransforms(record.identityPath, sourceFile, parsedTransforms)
308
+ if (transformedFile === null) {
309
+ return sourceFile
310
+ }
311
+
312
+ let emittedFile = await createEmittedFile(transformedFile.body, {
313
+ extension: transformedFile.extension,
314
+ filePath: record.identityPath,
315
+ fingerprint: sourceFile.fingerprint,
316
+ })
317
+ rememberTransformedAssetMetadata(
318
+ cacheKey,
319
+ record.identityPath,
320
+ toEmittedFileMetadata(emittedFile),
321
+ )
322
+ await setCachedTransformedFile(cacheKey, record.identityPath, emittedFile)
323
+ return emittedFile
324
+ })()
325
+
326
+ transformedEmitInFlightByCacheKey.set(cacheKey, promise)
327
+
328
+ try {
329
+ return await promise
330
+ } finally {
331
+ if (transformedEmitInFlightByCacheKey.get(cacheKey) === promise) {
332
+ transformedEmitInFlightByCacheKey.delete(cacheKey)
333
+ }
334
+ }
335
+ }
336
+
337
+ async function createSourceFile(body: Uint8Array, identityPath: string): Promise<EmittedFile> {
338
+ return createEmittedFile(body, {
339
+ extension: path.extname(identityPath).toLowerCase(),
340
+ filePath: identityPath,
341
+ fingerprint:
342
+ resolvedOptions.fingerprintAssets && resolvedOptions.buildId
343
+ ? await generateFingerprint({
344
+ buildId: resolvedOptions.buildId,
345
+ content: body,
346
+ })
347
+ : null,
348
+ })
349
+ }
350
+
351
+ function createEmittedFileFromMetadata(
352
+ body: Uint8Array,
353
+ metadata: EmittedFileMetadata,
354
+ ): EmittedFile {
355
+ return {
356
+ body,
357
+ ...metadata,
358
+ }
359
+ }
360
+
361
+ async function applyTransforms(
362
+ filePath: string,
363
+ source: EmittedFile,
364
+ transforms: readonly ParsedRequestTransform[],
365
+ ): Promise<{ body: Uint8Array; extension: string } | null> {
366
+ let currentBody = source.body
367
+ let currentExtension = source.extension
368
+ let appliedTransform = false
369
+
370
+ for (let requestTransform of transforms) {
371
+ let transform = resolvedOptions.transforms.get(requestTransform.name)
372
+ if (transform === undefined) {
373
+ throw createAssetServerCompilationError(
374
+ `Unknown file transform "${requestTransform.name}" requested for ${filePath}`,
375
+ { code: 'FILE_TRANSFORM_QUERY_INVALID' },
376
+ )
377
+ }
378
+
379
+ if (!supportsTransformExtension(transform.extensions, currentExtension)) {
380
+ throw createAssetServerCompilationError(
381
+ `File transform "${requestTransform.name}" does not support ${currentExtension} inputs for ${filePath}`,
382
+ { code: 'FILE_TRANSFORM_NOT_SUPPORTED' },
383
+ )
384
+ }
385
+
386
+ let result: unknown
387
+ let transformSubject: FileTransformSubject = {
388
+ kind: 'request',
389
+ name: requestTransform.name,
390
+ }
391
+ try {
392
+ result = await transform.transform(currentBody, {
393
+ extension: currentExtension,
394
+ filePath,
395
+ param: requestTransform.param,
396
+ })
397
+ } catch (error) {
398
+ throw toFileTransformFailedError(error, filePath, transformSubject)
399
+ }
400
+ let normalizedResult = normalizeTransformResult(result, {
401
+ currentExtension,
402
+ filePath,
403
+ transformSubject,
404
+ })
405
+ currentBody = normalizedResult.content
406
+ currentExtension = normalizedResult.extension
407
+ appliedTransform = true
408
+ }
409
+
410
+ for (let [index, globalTransform] of resolvedOptions.globalTransforms.entries()) {
411
+ if (!supportsTransformExtension(globalTransform.extensions, currentExtension)) continue
412
+
413
+ let result: unknown
414
+ let transformSubject = getGlobalFileTransformSubject(globalTransform, index)
415
+ try {
416
+ result = await globalTransform.transform(currentBody, {
417
+ extension: currentExtension,
418
+ filePath,
419
+ })
420
+ } catch (error) {
421
+ throw toFileTransformFailedError(error, filePath, transformSubject)
422
+ }
423
+ if (result === null) continue
424
+
425
+ let normalizedResult = normalizeTransformResult(result, {
426
+ currentExtension,
427
+ filePath,
428
+ transformSubject,
429
+ })
430
+ currentBody = normalizedResult.content
431
+ currentExtension = normalizedResult.extension
432
+ appliedTransform = true
433
+ }
434
+
435
+ if (!appliedTransform) return null
436
+ return { body: currentBody, extension: currentExtension }
437
+ }
438
+
439
+ async function getCachedTransformedFile(
440
+ cacheKey: string,
441
+ identityPath: string,
442
+ fingerprint: string | null,
443
+ ): Promise<EmittedFile | null> {
444
+ if (!resolvedOptions.cache) return null
445
+
446
+ let file = await resolvedOptions.cache.get(cacheKey)
447
+ if (!file) return null
448
+
449
+ let body = new Uint8Array(await file.arrayBuffer())
450
+ let metadata =
451
+ transformedAssetMetadataByCacheKey.get(cacheKey) ??
452
+ (await createEmittedFileMetadata(body, {
453
+ extension: path.extname(file.name).toLowerCase(),
454
+ filePath: identityPath,
455
+ fingerprint,
456
+ }))
457
+
458
+ rememberTransformedAssetMetadata(cacheKey, identityPath, metadata)
459
+
460
+ return {
461
+ body,
462
+ ...metadata,
463
+ }
464
+ }
465
+
466
+ async function setCachedTransformedFile(
467
+ cacheKey: string,
468
+ filePath: string,
469
+ emittedFile: EmittedFile,
470
+ ): Promise<void> {
471
+ if (!resolvedOptions.cache) return
472
+
473
+ let basename = path.basename(filePath, path.extname(filePath))
474
+ await resolvedOptions.cache.set(
475
+ cacheKey,
476
+ new File([Buffer.from(emittedFile.body)], `${basename}${emittedFile.extension}`, {
477
+ type: emittedFile.contentType,
478
+ }),
479
+ )
480
+ }
481
+
482
+ async function createEmittedFile(
483
+ body: Uint8Array,
484
+ options: {
485
+ extension: string
486
+ filePath: string
487
+ fingerprint: string | null
488
+ },
489
+ ): Promise<EmittedFile> {
490
+ let metadata = await createEmittedFileMetadata(body, options)
491
+ return {
492
+ body,
493
+ ...metadata,
494
+ }
495
+ }
496
+
497
+ function clearTransformedCacheIndex(identityPath: string): void {
498
+ let cacheKeys = transformedCacheKeysByIdentityPath.get(identityPath)
499
+ if (!cacheKeys) return
500
+
501
+ for (let cacheKey of cacheKeys) {
502
+ transformedAssetMetadataByCacheKey.delete(cacheKey)
503
+ }
504
+
505
+ transformedCacheKeysByIdentityPath.delete(identityPath)
506
+ }
507
+
508
+ function rememberTransformedAssetMetadata(
509
+ cacheKey: string,
510
+ identityPath: string,
511
+ metadata: EmittedFileMetadata,
512
+ ): void {
513
+ transformedAssetMetadataByCacheKey.set(cacheKey, metadata)
514
+
515
+ let cacheKeys = transformedCacheKeysByIdentityPath.get(identityPath) ?? new Set<string>()
516
+ cacheKeys.add(cacheKey)
517
+ transformedCacheKeysByIdentityPath.set(identityPath, cacheKeys)
518
+ }
519
+
520
+ async function createEmittedFileMetadata(
521
+ body: Uint8Array,
522
+ options: {
523
+ extension: string
524
+ filePath: string
525
+ fingerprint: string | null
526
+ },
527
+ ): Promise<EmittedFileMetadata> {
528
+ let contentType =
529
+ detectContentType(`file${options.extension}`) ??
530
+ detectContentType(options.filePath) ??
531
+ 'application/octet-stream'
532
+
533
+ return {
534
+ contentType,
535
+ etag: `W/"${await hashContent(body)}"`,
536
+ extension: options.extension,
537
+ fingerprint: options.fingerprint,
538
+ }
539
+ }
540
+ }
541
+
542
+ export function resolveServedFileOrThrow(filePath: string, args: ResolveArgs): ResolvedFile {
543
+ let identityPath = resolveExistingFilePath(filePath)
544
+ if (!identityPath) {
545
+ throw createAssetServerCompilationError(`File not found: ${filePath}`, {
546
+ code: 'FILE_NOT_FOUND',
547
+ })
548
+ }
549
+
550
+ if (!isServedFilePath(identityPath, args.extensions)) {
551
+ throw createAssetServerCompilationError(`File type is not supported: ${identityPath}`, {
552
+ code: 'FILE_NOT_SUPPORTED',
553
+ })
554
+ }
555
+
556
+ if (!args.isAllowed(identityPath)) {
557
+ throw createAssetServerCompilationError(`File is not allowed: ${identityPath}`, {
558
+ code: 'FILE_NOT_ALLOWED',
559
+ })
560
+ }
561
+
562
+ let stableUrlPathname = args.routes.toUrlPathname(identityPath)
563
+ if (!stableUrlPathname) {
564
+ throw createAssetServerCompilationError(
565
+ `File ${identityPath} is outside all configured fileMap entries.`,
566
+ {
567
+ code: 'FILE_OUTSIDE_FILE_MAP',
568
+ },
569
+ )
570
+ }
571
+
572
+ return {
573
+ identityPath,
574
+ stableUrlPathname,
575
+ }
576
+ }
577
+
578
+ export function createResponseForFile(
579
+ result: FileCompileResult,
580
+ options: {
581
+ cacheControl: string
582
+ ifNoneMatch: string | null
583
+ method: string
584
+ },
585
+ ): Response {
586
+ if (IfNoneMatch.from(options.ifNoneMatch).matches(result.etag)) {
587
+ return new Response(null, { status: 304, headers: { ETag: result.etag } })
588
+ }
589
+
590
+ return new Response(options.method === 'HEAD' ? null : Buffer.from(result.body), {
591
+ headers: {
592
+ 'Cache-Control': options.cacheControl,
593
+ 'Content-Type': result.contentType,
594
+ ETag: result.etag,
595
+ },
596
+ })
597
+ }
598
+
599
+ export function isServedFilePath(filePath: string, extensions: ReadonlySet<string>): boolean {
600
+ return extensions.has(path.extname(filePath).toLowerCase())
601
+ }
602
+
603
+ function getRecordCacheKey(record: SourceFileRecord): string {
604
+ return `${record.identityPath}\0${record.invalidationVersion}`
605
+ }
606
+
607
+ function getTransformedRecordCacheKey(
608
+ cacheEpoch: string,
609
+ record: SourceFileRecord,
610
+ transformQuery: readonly string[] | null,
611
+ ): string {
612
+ return [
613
+ encodeCacheKeyPart(cacheEpoch),
614
+ encodeCacheKeyPart(record.identityPath),
615
+ String(record.invalidationVersion),
616
+ encodeCacheKeyPart(JSON.stringify(transformQuery ?? [])),
617
+ ].join('/')
618
+ }
619
+
620
+ function getFileSnapshot(filePath: string): FileSnapshot | null {
621
+ try {
622
+ let stats = fs.statSync(filePath, { bigint: true })
623
+ if (!stats.isFile()) return null
624
+ return {
625
+ filePath,
626
+ mtimeNs: stats.mtimeNs,
627
+ size: stats.size,
628
+ }
629
+ } catch (error) {
630
+ if (isNoEntityError(error)) return null
631
+ throw error
632
+ }
633
+ }
634
+
635
+ function getNotModifiedFile(
636
+ emittedFile: EmittedFileMetadata | undefined,
637
+ options: FileGetOptions,
638
+ ): FileGetResult | null {
639
+ if (!emittedFile || options.ifNoneMatch === null) return null
640
+
641
+ if (
642
+ options.requestedFingerprint !== null &&
643
+ emittedFile.fingerprint !== options.requestedFingerprint
644
+ ) {
645
+ return null
646
+ }
647
+
648
+ if (!IfNoneMatch.from(options.ifNoneMatch).matches(emittedFile.etag)) return null
649
+ return { etag: emittedFile.etag, type: 'not-modified' }
650
+ }
651
+
652
+ function toEmittedFileMetadata(emittedFile: EmittedFile): EmittedFileMetadata {
653
+ return {
654
+ contentType: emittedFile.contentType,
655
+ etag: emittedFile.etag,
656
+ extension: emittedFile.extension,
657
+ fingerprint: emittedFile.fingerprint,
658
+ }
659
+ }
660
+
661
+ function isFileSnapshotFresh(snapshot: FileSnapshot): boolean {
662
+ let current = getFileSnapshot(snapshot.filePath)
663
+ return current != null && current.mtimeNs === snapshot.mtimeNs && current.size === snapshot.size
664
+ }
665
+
666
+ function appendTransformQuery(href: string, transformQuery: readonly string[] | null): string {
667
+ if (transformQuery === null) return href
668
+ let searchParams = new URLSearchParams()
669
+ for (let transform of transformQuery) {
670
+ searchParams.append('transform', transform)
671
+ }
672
+
673
+ let search = searchParams.toString()
674
+ return search.length > 0 ? `${href}?${search}` : href
675
+ }
676
+
677
+ function encodeCacheKeyPart(value: string): string {
678
+ return Buffer.from(value).toString('base64url')
679
+ }
680
+
681
+ function normalizeTransformResult(
682
+ result: unknown,
683
+ options: {
684
+ currentExtension: string
685
+ filePath: string
686
+ transformSubject: FileTransformSubject
687
+ },
688
+ ): AssetFileTransformResult & { content: Uint8Array; extension: string } {
689
+ if (typeof result === 'string') {
690
+ return {
691
+ content: new TextEncoder().encode(result),
692
+ extension: options.currentExtension,
693
+ }
694
+ }
695
+
696
+ if (result instanceof Uint8Array) {
697
+ return {
698
+ content: result,
699
+ extension: options.currentExtension,
700
+ }
701
+ }
702
+
703
+ if (result === null || typeof result !== 'object') {
704
+ throw createAssetServerCompilationError(
705
+ `${formatFileTransformSubject(options.transformSubject)} must return a string, Uint8Array, or object for ${options.filePath}`,
706
+ { code: 'FILE_TRANSFORM_RESULT_INVALID' },
707
+ )
708
+ }
709
+
710
+ if (
711
+ !('content' in result) ||
712
+ (typeof result.content !== 'string' && !(result.content instanceof Uint8Array))
713
+ ) {
714
+ throw createAssetServerCompilationError(
715
+ `${formatFileTransformSubject(options.transformSubject)} must return a string or Uint8Array content value for ${options.filePath}`,
716
+ { code: 'FILE_TRANSFORM_RESULT_INVALID' },
717
+ )
718
+ }
719
+
720
+ let extension = options.currentExtension
721
+ if ('extension' in result && result.extension !== undefined) {
722
+ if (typeof result.extension !== 'string') {
723
+ throw createAssetServerCompilationError(
724
+ `${formatFileTransformSubject(options.transformSubject)} must return a string extension for ${options.filePath}`,
725
+ { code: 'FILE_TRANSFORM_RESULT_INVALID' },
726
+ )
727
+ }
728
+
729
+ extension = normalizeTransformExtension(
730
+ result.extension,
731
+ options.filePath,
732
+ options.transformSubject,
733
+ )
734
+ }
735
+
736
+ return {
737
+ content:
738
+ typeof result.content === 'string'
739
+ ? new TextEncoder().encode(result.content)
740
+ : result.content,
741
+ extension,
742
+ }
743
+ }
744
+
745
+ function normalizeTransformExtension(
746
+ extension: string,
747
+ filePath: string,
748
+ transformSubject: FileTransformSubject,
749
+ ): string {
750
+ let normalizedExtension = extension.trim().toLowerCase()
751
+ if (!/^\.[A-Za-z0-9_-]+$/.test(normalizedExtension)) {
752
+ throw createAssetServerCompilationError(
753
+ `${formatFileTransformSubject(transformSubject)} returned an invalid extension "${extension}" for ${filePath}`,
754
+ { code: 'FILE_TRANSFORM_RESULT_INVALID' },
755
+ )
756
+ }
757
+
758
+ return normalizedExtension
759
+ }
760
+
761
+ function toFileTransformFailedError(
762
+ error: unknown,
763
+ filePath: string,
764
+ transformSubject: FileTransformSubject,
765
+ ): AssetServerCompilationError {
766
+ if (isAssetServerCompilationError(error)) return error
767
+
768
+ return createAssetServerCompilationError(
769
+ `${formatFileTransformSubject(transformSubject)} failed for ${filePath}. ${formatUnknownError(error)}`,
770
+ {
771
+ cause: error,
772
+ code: 'FILE_TRANSFORM_FAILED',
773
+ },
774
+ )
775
+ }
776
+
777
+ function getGlobalFileTransformSubject(
778
+ globalTransform: FileCompilerOptions['globalTransforms'][number],
779
+ index: number,
780
+ ): FileTransformSubject {
781
+ let functionName = globalTransform.transform.name
782
+ return {
783
+ index,
784
+ kind: 'global',
785
+ name: globalTransform.name ?? (functionName.length > 0 ? functionName : undefined),
786
+ }
787
+ }
788
+
789
+ function formatFileTransformSubject(transformSubject: FileTransformSubject): string {
790
+ if (transformSubject.kind === 'request') {
791
+ return `File transform "${transformSubject.name}"`
792
+ }
793
+
794
+ return transformSubject.name === undefined
795
+ ? `Global file transform at index ${transformSubject.index}`
796
+ : `Global file transform "${transformSubject.name}"`
797
+ }
798
+
799
+ function formatUnknownError(error: unknown): string {
800
+ return error instanceof Error ? error.message : String(error)
801
+ }
802
+
803
+ function supportsTransformExtension(
804
+ extensions: readonly string[] | undefined,
805
+ currentExtension: string,
806
+ ): boolean {
807
+ return extensions === undefined || extensions.includes(currentExtension)
808
+ }
809
+
810
+ function parseRequestTransforms(
811
+ transformQuery: readonly string[] | null,
812
+ transforms: ResolvedAssetRequestTransformMap,
813
+ maxRequestTransforms: number,
814
+ ): readonly ParsedRequestTransform[] {
815
+ if (transformQuery === null) return []
816
+
817
+ try {
818
+ return parseAssetTransformInvocations(transformQuery, transforms, maxRequestTransforms).map(
819
+ (transformInvocation): ParsedRequestTransform =>
820
+ typeof transformInvocation === 'string'
821
+ ? {
822
+ name: transformInvocation,
823
+ param: undefined,
824
+ }
825
+ : {
826
+ name: transformInvocation[0],
827
+ param: transformInvocation[1],
828
+ },
829
+ )
830
+ } catch (error) {
831
+ throw createAssetServerCompilationError(
832
+ error instanceof Error ? error.message : 'Invalid file transforms',
833
+ {
834
+ code: 'FILE_TRANSFORM_QUERY_INVALID',
835
+ },
836
+ )
837
+ }
838
+ }
839
+
840
+ function resolveExistingFilePath(filePath: string): string | null {
841
+ try {
842
+ return normalizeFilePath(fs.realpathSync(filePath))
843
+ } catch (error) {
844
+ if (isNoEntityError(error)) return null
845
+ throw error
846
+ }
847
+ }
848
+
849
+ function isNoEntityError(
850
+ error: unknown,
851
+ ): error is NodeJS.ErrnoException & { code: 'ENOENT' | 'ENOTDIR' } {
852
+ return (
853
+ error instanceof Error &&
854
+ 'code' in error &&
855
+ ((error as NodeJS.ErrnoException).code === 'ENOENT' ||
856
+ (error as NodeJS.ErrnoException).code === 'ENOTDIR')
857
+ )
858
+ }
859
+
860
+ async function readFileContents(identityPath: string): Promise<Uint8Array> {
861
+ try {
862
+ return new Uint8Array(await fsp.readFile(identityPath))
863
+ } catch (error) {
864
+ if (isNoEntityError(error)) {
865
+ throw createAssetServerCompilationError(`File not found: ${identityPath}`, {
866
+ cause: error,
867
+ code: 'FILE_NOT_FOUND',
868
+ })
869
+ }
870
+
871
+ throw toEmitError(error, identityPath)
872
+ }
873
+ }
874
+
875
+ function toEmitError(error: unknown, identityPath: string) {
876
+ if (isAssetServerCompilationError(error)) return error
877
+
878
+ return createAssetServerCompilationError(
879
+ `Failed to read file ${identityPath}. ${error instanceof Error ? error.message : String(error)}`,
880
+ {
881
+ cause: error,
882
+ code: 'EMIT_FAILED',
883
+ },
884
+ )
885
+ }