@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.
- package/README.md +241 -28
- package/dist/assets.d.ts +1 -0
- package/dist/assets.d.ts.map +1 -1
- package/dist/assets.js +1 -0
- package/dist/lib/access.d.ts +1 -0
- package/dist/lib/access.d.ts.map +1 -1
- package/dist/lib/access.js +10 -1
- package/dist/lib/asset-server.d.ts +33 -7
- package/dist/lib/asset-server.d.ts.map +1 -1
- package/dist/lib/asset-server.js +195 -23
- package/dist/lib/compilation-error.d.ts +1 -1
- package/dist/lib/compilation-error.d.ts.map +1 -1
- package/dist/lib/files/compiler.d.ts +71 -0
- package/dist/lib/files/compiler.d.ts.map +1 -0
- package/dist/lib/files/compiler.js +552 -0
- package/dist/lib/files/config.d.ts +101 -0
- package/dist/lib/files/config.d.ts.map +1 -0
- package/dist/lib/files/config.js +219 -0
- package/dist/lib/files/store.d.ts +31 -0
- package/dist/lib/files/store.d.ts.map +1 -0
- package/dist/lib/files/store.js +63 -0
- package/dist/lib/fingerprint.d.ts +3 -3
- package/dist/lib/fingerprint.d.ts.map +1 -1
- package/dist/lib/fingerprint.js +5 -5
- package/dist/lib/injected-packages.d.ts +11 -0
- package/dist/lib/injected-packages.d.ts.map +1 -0
- package/dist/lib/injected-packages.js +86 -0
- package/dist/lib/paths.d.ts +1 -0
- package/dist/lib/paths.d.ts.map +1 -1
- package/dist/lib/paths.js +12 -0
- package/dist/lib/routes.d.ts +5 -7
- package/dist/lib/routes.d.ts.map +1 -1
- package/dist/lib/routes.js +41 -36
- package/dist/lib/scripts/compiler.d.ts +1 -0
- package/dist/lib/scripts/compiler.d.ts.map +1 -1
- package/dist/lib/scripts/compiler.js +30 -5
- package/dist/lib/scripts/resolve.d.ts.map +1 -1
- package/dist/lib/scripts/resolve.js +57 -10
- package/dist/lib/scripts/transform.d.ts.map +1 -1
- package/dist/lib/scripts/transform.js +79 -3
- package/dist/lib/styles/compiler.d.ts +4 -0
- package/dist/lib/styles/compiler.d.ts.map +1 -1
- package/dist/lib/styles/compiler.js +2 -0
- package/dist/lib/styles/emit.d.ts +3 -0
- package/dist/lib/styles/emit.d.ts.map +1 -1
- package/dist/lib/styles/emit.js +36 -1
- package/dist/lib/styles/resolve.d.ts +8 -1
- package/dist/lib/styles/resolve.d.ts.map +1 -1
- package/dist/lib/styles/resolve.js +85 -32
- package/dist/lib/watch.d.ts +2 -0
- package/dist/lib/watch.d.ts.map +1 -1
- package/dist/lib/watch.js +25 -8
- package/package.json +11 -5
- package/src/assets.ts +1 -0
- package/src/lib/access.ts +10 -1
- package/src/lib/asset-server.ts +297 -34
- package/src/lib/compilation-error.ts +9 -0
- package/src/lib/files/compiler.ts +885 -0
- package/src/lib/files/config.ts +479 -0
- package/src/lib/files/store.ts +109 -0
- package/src/lib/fingerprint.ts +8 -7
- package/src/lib/injected-packages.ts +117 -0
- package/src/lib/paths.ts +28 -0
- package/src/lib/routes.ts +67 -55
- package/src/lib/scripts/compiler.ts +43 -5
- package/src/lib/scripts/resolve.ts +89 -11
- package/src/lib/scripts/transform.ts +105 -3
- package/src/lib/styles/compiler.ts +9 -0
- package/src/lib/styles/emit.ts +75 -1
- package/src/lib/styles/resolve.ts +132 -35
- 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
|
+
}
|