@remix-run/assets 0.0.0 → 0.1.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/LICENSE +21 -0
- package/README.md +319 -2
- package/dist/assets.d.ts +3 -0
- package/dist/assets.d.ts.map +1 -0
- package/dist/assets.js +1 -0
- package/dist/lib/access.d.ts +10 -0
- package/dist/lib/access.d.ts.map +1 -0
- package/dist/lib/access.js +14 -0
- package/dist/lib/asset-server.d.ts +137 -0
- package/dist/lib/asset-server.d.ts.map +1 -0
- package/dist/lib/asset-server.js +242 -0
- package/dist/lib/compilation-error.d.ts +33 -0
- package/dist/lib/compilation-error.d.ts.map +1 -0
- package/dist/lib/compilation-error.js +32 -0
- package/dist/lib/file-matcher.d.ts +6 -0
- package/dist/lib/file-matcher.d.ts.map +1 -0
- package/dist/lib/file-matcher.js +43 -0
- package/dist/lib/fingerprint.d.ts +12 -0
- package/dist/lib/fingerprint.d.ts.map +1 -0
- package/dist/lib/fingerprint.js +49 -0
- package/dist/lib/paths.d.ts +8 -0
- package/dist/lib/paths.d.ts.map +1 -0
- package/dist/lib/paths.js +50 -0
- package/dist/lib/routes.d.ts +13 -0
- package/dist/lib/routes.d.ts.map +1 -0
- package/dist/lib/routes.js +94 -0
- package/dist/lib/scripts/cjs-check.d.ts +3 -0
- package/dist/lib/scripts/cjs-check.d.ts.map +1 -0
- package/dist/lib/scripts/cjs-check.js +398 -0
- package/dist/lib/scripts/compiler.d.ts +62 -0
- package/dist/lib/scripts/compiler.d.ts.map +1 -0
- package/dist/lib/scripts/compiler.js +435 -0
- package/dist/lib/scripts/emit.d.ts +25 -0
- package/dist/lib/scripts/emit.d.ts.map +1 -0
- package/dist/lib/scripts/emit.js +63 -0
- package/dist/lib/scripts/resolve.d.ts +60 -0
- package/dist/lib/scripts/resolve.d.ts.map +1 -0
- package/dist/lib/scripts/resolve.js +230 -0
- package/dist/lib/scripts/store.d.ts +40 -0
- package/dist/lib/scripts/store.d.ts.map +1 -0
- package/dist/lib/scripts/store.js +228 -0
- package/dist/lib/scripts/transform.d.ts +62 -0
- package/dist/lib/scripts/transform.d.ts.map +1 -0
- package/dist/lib/scripts/transform.js +362 -0
- package/dist/lib/source-maps.d.ts +4 -0
- package/dist/lib/source-maps.d.ts.map +1 -0
- package/dist/lib/source-maps.js +56 -0
- package/dist/lib/watch.d.ts +22 -0
- package/dist/lib/watch.d.ts.map +1 -0
- package/dist/lib/watch.js +96 -0
- package/package.json +50 -12
- package/src/assets.ts +2 -0
- package/src/lib/access.ts +24 -0
- package/src/lib/asset-server.ts +415 -0
- package/src/lib/compilation-error.ts +61 -0
- package/src/lib/file-matcher.ts +62 -0
- package/src/lib/fingerprint.ts +65 -0
- package/src/lib/paths.ts +66 -0
- package/src/lib/routes.ts +164 -0
- package/src/lib/scripts/cjs-check.ts +476 -0
- package/src/lib/scripts/compiler.ts +622 -0
- package/src/lib/scripts/emit.ts +122 -0
- package/src/lib/scripts/resolve.ts +422 -0
- package/src/lib/scripts/store.ts +327 -0
- package/src/lib/scripts/transform.ts +594 -0
- package/src/lib/source-maps.ts +68 -0
- package/src/lib/watch.ts +136 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import * as path from 'node:path'
|
|
2
|
+
import * as fs from 'node:fs'
|
|
3
|
+
import { isAssetServerCompilationError } from './compilation-error.ts'
|
|
4
|
+
import { createAccessPolicy } from './access.ts'
|
|
5
|
+
import { createModuleCompiler, createResponseForModule } from './scripts/compiler.ts'
|
|
6
|
+
import { normalizeFilePath } from './paths.ts'
|
|
7
|
+
import { compileRoutes } from './routes.ts'
|
|
8
|
+
import type { CompiledRoutes } from './routes.ts'
|
|
9
|
+
import { createAssetServerWatcher } from './watch.ts'
|
|
10
|
+
import type { ChokidarWatcher } from './watch.ts'
|
|
11
|
+
import type { AssetServerWatcher } from './watch.ts'
|
|
12
|
+
|
|
13
|
+
interface AssetServerWatchOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Ignore matching glob patterns or file paths. Relative values are resolved
|
|
16
|
+
* from `rootDir`.
|
|
17
|
+
*/
|
|
18
|
+
ignore?: readonly string[]
|
|
19
|
+
/**
|
|
20
|
+
* Use polling instead of native filesystem events. Defaults to `false`.
|
|
21
|
+
*/
|
|
22
|
+
poll?: boolean
|
|
23
|
+
/**
|
|
24
|
+
* Polling interval in milliseconds when `poll` is enabled. Defaults to `100`.
|
|
25
|
+
*/
|
|
26
|
+
pollInterval?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface FingerprintOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Per-build invalidation token that must change whenever fingerprinted module URLs
|
|
32
|
+
* should be invalidated together.
|
|
33
|
+
*/
|
|
34
|
+
buildId: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const scriptTargets = [
|
|
38
|
+
'es2015',
|
|
39
|
+
'es2016',
|
|
40
|
+
'es2017',
|
|
41
|
+
'es2018',
|
|
42
|
+
'es2019',
|
|
43
|
+
'es2020',
|
|
44
|
+
'es2021',
|
|
45
|
+
'es2022',
|
|
46
|
+
'es2023',
|
|
47
|
+
'es2024',
|
|
48
|
+
'es2025',
|
|
49
|
+
'es2026',
|
|
50
|
+
'esnext',
|
|
51
|
+
] as const
|
|
52
|
+
const scriptTargetSet = new Set<string>(scriptTargets)
|
|
53
|
+
|
|
54
|
+
export type ScriptsTarget = (typeof scriptTargets)[number]
|
|
55
|
+
|
|
56
|
+
export interface AssetServerOptions {
|
|
57
|
+
/** File patterns keyed by public URL patterns. */
|
|
58
|
+
fileMap: Readonly<Record<string, string>>
|
|
59
|
+
/**
|
|
60
|
+
* Root directory used to resolve relative file paths. Defaults to `process.cwd()`.
|
|
61
|
+
*/
|
|
62
|
+
rootDir?: string
|
|
63
|
+
/**
|
|
64
|
+
* Glob patterns or file paths that are allowed to be served. Relative values are resolved from `rootDir`.
|
|
65
|
+
*/
|
|
66
|
+
allow: readonly string[]
|
|
67
|
+
/**
|
|
68
|
+
* Glob patterns or file paths that are denied from being served. Relative values are resolved from `rootDir`.
|
|
69
|
+
*/
|
|
70
|
+
deny?: readonly string[]
|
|
71
|
+
/**
|
|
72
|
+
* Controls optional source-based URL fingerprinting for rewritten import URLs.
|
|
73
|
+
*
|
|
74
|
+
* When omitted, all served modules use stable non-fingerprinted URLs with `Cache-Control: no-cache`.
|
|
75
|
+
* Cannot be used together with active watch mode. Set `watch: false` when fingerprinting.
|
|
76
|
+
*/
|
|
77
|
+
fingerprint?: FingerprintOptions
|
|
78
|
+
/**
|
|
79
|
+
* Script pipeline configuration. Omit to use defaults.
|
|
80
|
+
*/
|
|
81
|
+
scripts?: {
|
|
82
|
+
/**
|
|
83
|
+
* Source map mode (disabled when omitted).
|
|
84
|
+
* - `'external'`: serve source maps as separate `.map` files; adds `//# sourceMappingURL=` comment
|
|
85
|
+
* - `'inline'`: embed source maps as a base64 data URL directly in the JS; no separate `.map` file
|
|
86
|
+
*/
|
|
87
|
+
sourceMaps?: 'inline' | 'external'
|
|
88
|
+
/**
|
|
89
|
+
* Controls the source paths written into source map `sources`.
|
|
90
|
+
* - `'url'` (default): use the stable server path (e.g. `'/assets/app/entry.ts'`)
|
|
91
|
+
* - `'absolute'`: use the original filesystem path on disk
|
|
92
|
+
*/
|
|
93
|
+
sourceMapSourcePaths?: 'url' | 'absolute'
|
|
94
|
+
/**
|
|
95
|
+
* Minify emitted modules.
|
|
96
|
+
*/
|
|
97
|
+
minify?: boolean
|
|
98
|
+
/**
|
|
99
|
+
* Replace global expressions with constant values during transform, e.g.
|
|
100
|
+
* `{ 'process.env.NODE_ENV': '"production"' }`
|
|
101
|
+
*/
|
|
102
|
+
define?: Record<string, string>
|
|
103
|
+
/**
|
|
104
|
+
* Lower emitted syntax to a specific ECMAScript target. Omit this option to preserve
|
|
105
|
+
* modern syntax unless project configuration already requests a lower target.
|
|
106
|
+
*/
|
|
107
|
+
target?: ScriptsTarget
|
|
108
|
+
/** Import specifiers to leave unrewritten (CDN URLs, import map entries, etc.) */
|
|
109
|
+
external?: string[]
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Enable filesystem-backed cache invalidation for long-lived server instances.
|
|
113
|
+
* Enabled by default. Pass `true` to use the default watcher options, an options
|
|
114
|
+
* object to customize watcher behavior, or `false` to disable watching.
|
|
115
|
+
*/
|
|
116
|
+
watch?: boolean | AssetServerWatchOptions
|
|
117
|
+
/**
|
|
118
|
+
* Handles unexpected request-time compilation errors. Return a `Response` to override the
|
|
119
|
+
* default `500 Internal Server Error` response, or return nothing to use the default.
|
|
120
|
+
*/
|
|
121
|
+
onError?: (error: unknown) => void | Response | Promise<void | Response>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface AssetServer {
|
|
125
|
+
/**
|
|
126
|
+
* Serves a script request. Returns `Response | null` — null means the request was not
|
|
127
|
+
* handled by this server, letting the router fall through to a 404.
|
|
128
|
+
*/
|
|
129
|
+
fetch(request: Request): Promise<Response | null>
|
|
130
|
+
/**
|
|
131
|
+
* Returns the request href for a served module file.
|
|
132
|
+
*/
|
|
133
|
+
getHref(filePath: string): Promise<string>
|
|
134
|
+
/**
|
|
135
|
+
* Returns preload URLs for one or more served module files, ordered shallowest-first.
|
|
136
|
+
*/
|
|
137
|
+
getPreloads(filePath: string | readonly string[]): Promise<string[]>
|
|
138
|
+
/**
|
|
139
|
+
* Closes any watcher resources owned by this server instance.
|
|
140
|
+
*/
|
|
141
|
+
close(): Promise<void>
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
type ResolvedAssetServerOptions = {
|
|
145
|
+
allow: readonly string[]
|
|
146
|
+
buildId?: string
|
|
147
|
+
define?: Record<string, string>
|
|
148
|
+
deny?: readonly string[]
|
|
149
|
+
external: string[]
|
|
150
|
+
fingerprintModules: boolean
|
|
151
|
+
minify: boolean
|
|
152
|
+
onError: NonNullable<AssetServerOptions['onError']>
|
|
153
|
+
rootDir: string
|
|
154
|
+
routes: CompiledRoutes
|
|
155
|
+
sourceMapSourcePaths: 'url' | 'absolute'
|
|
156
|
+
sourceMaps?: 'inline' | 'external'
|
|
157
|
+
scriptsTarget?: ScriptsTarget
|
|
158
|
+
watchOptions: AssetServerWatchOptions | null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const chokidarWatcherByAssetServer = new WeakMap<AssetServer, ChokidarWatcher>()
|
|
162
|
+
const watcherByAssetServer = new WeakMap<AssetServer, AssetServerWatcher>()
|
|
163
|
+
|
|
164
|
+
export function getInternalChokidarWatcher(assetServer: AssetServer): ChokidarWatcher | undefined {
|
|
165
|
+
return chokidarWatcherByAssetServer.get(assetServer)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function getInternalWatchTargets(assetServer: AssetServer): readonly string[] {
|
|
169
|
+
return watcherByAssetServer.get(assetServer)?.getWatchedTargets() ?? []
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create an asset server instance
|
|
174
|
+
*
|
|
175
|
+
* Compiles TypeScript/JavaScript modules on demand with optional source-based URL
|
|
176
|
+
* fingerprinting, caching, and configurable file mapping.
|
|
177
|
+
*
|
|
178
|
+
* @param options Server configuration
|
|
179
|
+
* @returns A {@link AssetServer} with `fetch()`, `getHref()`, and `getPreloads()` methods
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* ```ts
|
|
183
|
+
* let assetServer = createAssetServer({
|
|
184
|
+
* fileMap: {
|
|
185
|
+
* '/assets/app/*path': 'app/*path',
|
|
186
|
+
* },
|
|
187
|
+
* allow: ['app/**'],
|
|
188
|
+
* })
|
|
189
|
+
*
|
|
190
|
+
* route('/assets/*path', ({ request }) => assetServer.fetch(request))
|
|
191
|
+
* ```
|
|
192
|
+
*/
|
|
193
|
+
export function createAssetServer(options: AssetServerOptions): AssetServer {
|
|
194
|
+
let resolvedOptions = resolveAssetServerOptions(options)
|
|
195
|
+
let accessPolicy = createAccessPolicy({
|
|
196
|
+
allow: resolvedOptions.allow,
|
|
197
|
+
deny: resolvedOptions.deny,
|
|
198
|
+
rootDir: resolvedOptions.rootDir,
|
|
199
|
+
})
|
|
200
|
+
let watcher: AssetServerWatcher | null = null
|
|
201
|
+
let chokidarWatcher: ChokidarWatcher | null = null
|
|
202
|
+
let moduleCompiler = createModuleCompiler({
|
|
203
|
+
buildId: resolvedOptions.buildId,
|
|
204
|
+
define: resolvedOptions.define,
|
|
205
|
+
external: resolvedOptions.external,
|
|
206
|
+
fingerprintModules: resolvedOptions.fingerprintModules,
|
|
207
|
+
isAllowed: accessPolicy.isAllowed,
|
|
208
|
+
minify: resolvedOptions.minify,
|
|
209
|
+
onWatchDirectoriesChange: (delta) => {
|
|
210
|
+
if (!watcher) return
|
|
211
|
+
watcher.updateWatchedDirectories(delta)
|
|
212
|
+
},
|
|
213
|
+
rootDir: resolvedOptions.rootDir,
|
|
214
|
+
routes: resolvedOptions.routes,
|
|
215
|
+
sourceMapSourcePaths: resolvedOptions.sourceMapSourcePaths,
|
|
216
|
+
sourceMaps: resolvedOptions.sourceMaps,
|
|
217
|
+
target: resolvedOptions.scriptsTarget,
|
|
218
|
+
watchIgnore: resolvedOptions.watchOptions?.ignore,
|
|
219
|
+
watchMode: resolvedOptions.watchOptions !== null,
|
|
220
|
+
})
|
|
221
|
+
if (resolvedOptions.watchOptions) {
|
|
222
|
+
watcher = createAssetServerWatcher({
|
|
223
|
+
...resolvedOptions.watchOptions,
|
|
224
|
+
onChokidarWatcherCreated(createdWatcher) {
|
|
225
|
+
chokidarWatcher = createdWatcher
|
|
226
|
+
},
|
|
227
|
+
onFileEvent: handleWatchEvent,
|
|
228
|
+
rootDir: resolvedOptions.rootDir,
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function responseForError(error: unknown): Promise<Response> {
|
|
233
|
+
try {
|
|
234
|
+
return (await resolvedOptions.onError(error)) ?? internalServerError()
|
|
235
|
+
} catch (error) {
|
|
236
|
+
console.error(`There was an error in the asset server error handler: ${error}`)
|
|
237
|
+
return internalServerError()
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function handleWatchEvent(filePath: string, event: 'add' | 'change' | 'unlink') {
|
|
242
|
+
try {
|
|
243
|
+
let normalizedFilePath = normalizeFilePath(filePath)
|
|
244
|
+
await moduleCompiler.handleFileEvent(normalizedFilePath, event)
|
|
245
|
+
} catch (error) {
|
|
246
|
+
console.error(`There was an error invalidating the asset server cache: ${error}`)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let assetServer: AssetServer = {
|
|
251
|
+
async fetch(request) {
|
|
252
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return null
|
|
253
|
+
|
|
254
|
+
let parsedRequestPathname = moduleCompiler.parseRequestPathname(new URL(request.url).pathname)
|
|
255
|
+
if (!parsedRequestPathname) return null
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
let ifNoneMatch = request.headers.get('If-None-Match')
|
|
259
|
+
let moduleResult = await moduleCompiler.getModule(parsedRequestPathname.filePath, {
|
|
260
|
+
ifNoneMatch,
|
|
261
|
+
isSourceMapRequest: parsedRequestPathname.isSourceMapRequest,
|
|
262
|
+
requestedFingerprint: parsedRequestPathname.requestedFingerprint,
|
|
263
|
+
})
|
|
264
|
+
if (moduleResult.type === 'not-modified') {
|
|
265
|
+
return new Response(null, {
|
|
266
|
+
status: 304,
|
|
267
|
+
headers: { ETag: moduleResult.etag },
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let compiledModule = moduleResult.module
|
|
272
|
+
|
|
273
|
+
if (parsedRequestPathname.requestedFingerprint !== null) {
|
|
274
|
+
if (compiledModule.fingerprint !== parsedRequestPathname.requestedFingerprint) return null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return createResponseForModule(compiledModule, {
|
|
278
|
+
cacheControl: parsedRequestPathname.cacheControl,
|
|
279
|
+
ifNoneMatch,
|
|
280
|
+
isSourceMapRequest: parsedRequestPathname.isSourceMapRequest,
|
|
281
|
+
method: request.method,
|
|
282
|
+
})
|
|
283
|
+
} catch (error) {
|
|
284
|
+
// A direct request can race with the filesystem or fail a deeper allow check while
|
|
285
|
+
// compiling imports. In this fetch context, both cases should fall through as "not
|
|
286
|
+
// handled here" so the outer router can continue to its own 404 behavior.
|
|
287
|
+
if (
|
|
288
|
+
isAssetServerCompilationError(error) &&
|
|
289
|
+
(error.code === 'MODULE_NOT_FOUND' || error.code === 'MODULE_NOT_ALLOWED')
|
|
290
|
+
) {
|
|
291
|
+
return null
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return responseForError(error)
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
async getHref(filePath) {
|
|
299
|
+
return moduleCompiler.getHref(filePath)
|
|
300
|
+
},
|
|
301
|
+
async getPreloads(filePath) {
|
|
302
|
+
return moduleCompiler.getPreloadUrls(filePath)
|
|
303
|
+
},
|
|
304
|
+
async close() {
|
|
305
|
+
await watcher?.close()
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (chokidarWatcher) {
|
|
310
|
+
chokidarWatcherByAssetServer.set(assetServer, chokidarWatcher)
|
|
311
|
+
}
|
|
312
|
+
if (watcher) {
|
|
313
|
+
watcherByAssetServer.set(assetServer, watcher)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return assetServer
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function internalServerError(): Response {
|
|
320
|
+
return new Response('Internal Server Error', {
|
|
321
|
+
status: 500,
|
|
322
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function defaultErrorHandler(error: unknown): void {
|
|
327
|
+
console.error(error)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function resolveAssetServerOptions(options: AssetServerOptions): ResolvedAssetServerOptions {
|
|
331
|
+
let rootDir = normalizeFilePath(fs.realpathSync(path.resolve(options.rootDir ?? process.cwd())))
|
|
332
|
+
let scriptOptions = options.scripts ?? {}
|
|
333
|
+
let fingerprintOptions = normalizeFingerprintOptions({
|
|
334
|
+
fingerprint: options.fingerprint,
|
|
335
|
+
watch: options.watch,
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
allow: options.allow,
|
|
340
|
+
buildId: fingerprintOptions.buildId,
|
|
341
|
+
define: scriptOptions.define,
|
|
342
|
+
deny: options.deny,
|
|
343
|
+
external: scriptOptions.external ?? [],
|
|
344
|
+
fingerprintModules: fingerprintOptions.enabled,
|
|
345
|
+
minify: scriptOptions.minify ?? false,
|
|
346
|
+
onError: options.onError ?? defaultErrorHandler,
|
|
347
|
+
rootDir,
|
|
348
|
+
routes: compileRoutes({
|
|
349
|
+
fileMap: options.fileMap,
|
|
350
|
+
rootDir,
|
|
351
|
+
}),
|
|
352
|
+
sourceMapSourcePaths: scriptOptions.sourceMapSourcePaths ?? 'url',
|
|
353
|
+
sourceMaps: scriptOptions.sourceMaps,
|
|
354
|
+
scriptsTarget: normalizeTarget(scriptOptions.target),
|
|
355
|
+
watchOptions: normalizeWatchOptions(options.watch),
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function normalizeTarget(
|
|
360
|
+
target: NonNullable<AssetServerOptions['scripts']>['target'],
|
|
361
|
+
): ScriptsTarget | undefined {
|
|
362
|
+
if (target == null) return undefined
|
|
363
|
+
|
|
364
|
+
if (typeof target !== 'string' || !scriptTargetSet.has(target)) {
|
|
365
|
+
throw new TypeError(
|
|
366
|
+
`Expected target to be one of ${scriptTargets.map((value) => `"${value}"`).join(', ')}. Received "${target}".`,
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return target as ScriptsTarget
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function normalizeFingerprintOptions(options: {
|
|
374
|
+
fingerprint: AssetServerOptions['fingerprint']
|
|
375
|
+
watch: AssetServerOptions['watch']
|
|
376
|
+
}):
|
|
377
|
+
| {
|
|
378
|
+
enabled: false
|
|
379
|
+
buildId?: string
|
|
380
|
+
}
|
|
381
|
+
| {
|
|
382
|
+
enabled: true
|
|
383
|
+
buildId: string
|
|
384
|
+
} {
|
|
385
|
+
if (!options.fingerprint) {
|
|
386
|
+
return {
|
|
387
|
+
enabled: false,
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (typeof options.fingerprint.buildId !== 'string') {
|
|
392
|
+
throw new TypeError('fingerprint.buildId must be a string')
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (options.fingerprint.buildId.length === 0) {
|
|
396
|
+
throw new TypeError('fingerprint.buildId must be a non-empty string')
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (options.watch !== false) {
|
|
400
|
+
throw new TypeError('fingerprint cannot be used with watch mode')
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
enabled: true,
|
|
405
|
+
buildId: options.fingerprint.buildId,
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function normalizeWatchOptions(
|
|
410
|
+
options: AssetServerOptions['watch'],
|
|
411
|
+
): AssetServerWatchOptions | null {
|
|
412
|
+
if (options === false) return null
|
|
413
|
+
if (options == null || options === true) return {}
|
|
414
|
+
return options
|
|
415
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
type AssetServerCompilationErrorCode =
|
|
2
|
+
| 'MODULE_NOT_FOUND'
|
|
3
|
+
| 'MODULE_NOT_ALLOWED'
|
|
4
|
+
| 'MODULE_OUTSIDE_FILE_MAP'
|
|
5
|
+
| 'MODULE_COMMONJS_NOT_SUPPORTED'
|
|
6
|
+
| 'MODULE_TRANSFORM_FAILED'
|
|
7
|
+
| 'MODULE_EMIT_FAILED'
|
|
8
|
+
| 'IMPORT_RESOLUTION_FAILED'
|
|
9
|
+
| 'IMPORT_NOT_SUPPORTED'
|
|
10
|
+
| 'IMPORT_NOT_ALLOWED'
|
|
11
|
+
| 'IMPORT_OUTSIDE_FILE_MAP'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Internal error used by the request-time module compilation pipeline.
|
|
15
|
+
*/
|
|
16
|
+
export class AssetServerCompilationError extends Error {
|
|
17
|
+
code: AssetServerCompilationErrorCode
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
message: string,
|
|
21
|
+
options: {
|
|
22
|
+
cause?: unknown
|
|
23
|
+
code: AssetServerCompilationErrorCode
|
|
24
|
+
},
|
|
25
|
+
) {
|
|
26
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause })
|
|
27
|
+
this.name = 'AssetServerCompilationError'
|
|
28
|
+
this.code = options.code
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Returns true when a value is an `AssetServerCompilationError`.
|
|
34
|
+
*
|
|
35
|
+
* @param error Value thrown by the compilation pipeline.
|
|
36
|
+
* @returns Whether the value is an `AssetServerCompilationError`.
|
|
37
|
+
*/
|
|
38
|
+
export function isAssetServerCompilationError(
|
|
39
|
+
error: unknown,
|
|
40
|
+
): error is AssetServerCompilationError {
|
|
41
|
+
return error instanceof AssetServerCompilationError
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Creates an `AssetServerCompilationError` with a stable internal code.
|
|
46
|
+
*
|
|
47
|
+
* @param message Human-readable error message.
|
|
48
|
+
* @param options Structured internal error details.
|
|
49
|
+
* @param options.cause Original error cause, when available.
|
|
50
|
+
* @param options.code Stable internal compilation error code.
|
|
51
|
+
* @returns A `AssetServerCompilationError`.
|
|
52
|
+
*/
|
|
53
|
+
export function createAssetServerCompilationError(
|
|
54
|
+
message: string,
|
|
55
|
+
options: {
|
|
56
|
+
cause?: unknown
|
|
57
|
+
code: AssetServerCompilationErrorCode
|
|
58
|
+
},
|
|
59
|
+
): AssetServerCompilationError {
|
|
60
|
+
return new AssetServerCompilationError(message, options)
|
|
61
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { normalizeFilePath, resolveFilePath } from './paths.ts'
|
|
5
|
+
|
|
6
|
+
export type FileMatcher = (filePath: string) => boolean
|
|
7
|
+
|
|
8
|
+
export function createFileMatcher(
|
|
9
|
+
pattern: string,
|
|
10
|
+
rootDir: string,
|
|
11
|
+
options: {
|
|
12
|
+
allowDirectories?: boolean
|
|
13
|
+
allowMissing?: boolean
|
|
14
|
+
} = {},
|
|
15
|
+
): FileMatcher {
|
|
16
|
+
let resolvedPatternPath = resolveFilePath(rootDir, pattern)
|
|
17
|
+
let allowDirectories = options.allowDirectories ?? true
|
|
18
|
+
let allowMissing = options.allowMissing ?? true
|
|
19
|
+
|
|
20
|
+
if (!containsGlobSyntax(pattern)) {
|
|
21
|
+
try {
|
|
22
|
+
resolvedPatternPath = normalizeFilePath(fs.realpathSync(resolvedPatternPath))
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (!allowMissing || !isPathNotFoundError(error)) throw error
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (allowDirectories) {
|
|
28
|
+
try {
|
|
29
|
+
if (fs.statSync(resolveFilePath(rootDir, pattern)).isDirectory()) {
|
|
30
|
+
return (filePath) => isSameOrDescendantPath(filePath, resolvedPatternPath)
|
|
31
|
+
}
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (!isPathNotFoundError(error)) throw error
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (filePath) => filePath === resolvedPatternPath
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (filePath) => path.posix.matchesGlob(filePath, resolvedPatternPath)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isSameOrDescendantPath(filePath: string, directoryPath: string): boolean {
|
|
44
|
+
let normalizedDirectoryPath = directoryPath.replace(/\/+$/, '')
|
|
45
|
+
|
|
46
|
+
return filePath === normalizedDirectoryPath || filePath.startsWith(`${normalizedDirectoryPath}/`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function containsGlobSyntax(pattern: string): boolean {
|
|
50
|
+
return /[*?[\]{}()!+@]/.test(pattern)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isPathNotFoundError(
|
|
54
|
+
error: unknown,
|
|
55
|
+
): error is NodeJS.ErrnoException & { code: 'ENOENT' | 'ENOTDIR' } {
|
|
56
|
+
return (
|
|
57
|
+
error instanceof Error &&
|
|
58
|
+
'code' in error &&
|
|
59
|
+
((error as NodeJS.ErrnoException).code === 'ENOENT' ||
|
|
60
|
+
(error as NodeJS.ErrnoException).code === 'ENOTDIR')
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const fingerprintedExtensionRE = /^(.+)\.@([A-Za-z0-9_-]+)(\.[^./]+)$/
|
|
2
|
+
const fingerprintedBasenameRE = /^(.+)\.@([A-Za-z0-9_-]+)$/
|
|
3
|
+
|
|
4
|
+
export async function hashContent(content: string): Promise<string> {
|
|
5
|
+
let encoder = new TextEncoder()
|
|
6
|
+
let data = encoder.encode(content)
|
|
7
|
+
let hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
|
8
|
+
return Buffer.from(hashBuffer).toString('base64url').slice(0, 6)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function generateFingerprint(options: {
|
|
12
|
+
buildId: string
|
|
13
|
+
content: string
|
|
14
|
+
}): Promise<string> {
|
|
15
|
+
return hashContent(JSON.stringify([options.content, options.buildId]))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseFingerprintSuffix(pathname: string): {
|
|
19
|
+
pathname: string
|
|
20
|
+
requestedFingerprint: string | null
|
|
21
|
+
} {
|
|
22
|
+
let lastSlashIndex = pathname.lastIndexOf('/')
|
|
23
|
+
let directory = lastSlashIndex >= 0 ? pathname.slice(0, lastSlashIndex + 1) : ''
|
|
24
|
+
let basename = lastSlashIndex >= 0 ? pathname.slice(lastSlashIndex + 1) : pathname
|
|
25
|
+
let extensionMatch = basename.match(fingerprintedExtensionRE)
|
|
26
|
+
|
|
27
|
+
if (extensionMatch) {
|
|
28
|
+
return {
|
|
29
|
+
pathname: `${directory}${extensionMatch[1]}${extensionMatch[3]}`,
|
|
30
|
+
requestedFingerprint: extensionMatch[2],
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let basenameMatch = basename.match(fingerprintedBasenameRE)
|
|
35
|
+
if (basenameMatch) {
|
|
36
|
+
return {
|
|
37
|
+
pathname: `${directory}${basenameMatch[1]}`,
|
|
38
|
+
requestedFingerprint: basenameMatch[2],
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
pathname,
|
|
44
|
+
requestedFingerprint: null,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatFingerprintedPathname(pathname: string, fingerprint: string | null): string {
|
|
49
|
+
if (fingerprint === null) return pathname
|
|
50
|
+
|
|
51
|
+
let lastSlashIndex = pathname.lastIndexOf('/')
|
|
52
|
+
let directory = lastSlashIndex >= 0 ? pathname.slice(0, lastSlashIndex + 1) : ''
|
|
53
|
+
let basename = lastSlashIndex >= 0 ? pathname.slice(lastSlashIndex + 1) : pathname
|
|
54
|
+
let lastDotIndex = basename.lastIndexOf('.')
|
|
55
|
+
|
|
56
|
+
if (lastDotIndex <= 0) {
|
|
57
|
+
return `${pathname}.@${fingerprint}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return `${directory}${basename.slice(0, lastDotIndex)}.@${fingerprint}${basename.slice(lastDotIndex)}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getFingerprintRequestCacheControl(requestedFingerprint: string | null): string {
|
|
64
|
+
return requestedFingerprint === null ? 'no-cache' : 'public, max-age=31536000, immutable'
|
|
65
|
+
}
|
package/src/lib/paths.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as path from 'node:path'
|
|
2
|
+
|
|
3
|
+
const windowsDriveLetterRE = /^[A-Za-z]:\//
|
|
4
|
+
const uncPrefixRE = /^\/\/[^/]+\/[^/]+/
|
|
5
|
+
|
|
6
|
+
export function normalizeWindowsPath(filePath: string): string {
|
|
7
|
+
return filePath
|
|
8
|
+
.replace(/\\/g, '/')
|
|
9
|
+
.replace(windowsDriveLetterRE, (prefix) => `${prefix[0]!.toUpperCase()}${prefix.slice(1)}`)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function normalizePathname(pathname: string): string {
|
|
13
|
+
let normalized = path.posix.normalize(normalizeWindowsPath(pathname))
|
|
14
|
+
|
|
15
|
+
if (!normalized.startsWith('/')) {
|
|
16
|
+
normalized = `/${normalized}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return normalized
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isAbsoluteFilePath(filePath: string): boolean {
|
|
23
|
+
let normalized = normalizeWindowsPath(filePath)
|
|
24
|
+
return normalized.startsWith('/') || windowsDriveLetterRE.test(normalized)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function normalizeFilePath(filePath: string): string {
|
|
28
|
+
let normalized = normalizeWindowsPath(filePath)
|
|
29
|
+
let uncRoot = getUncRoot(normalized)
|
|
30
|
+
|
|
31
|
+
if (uncRoot) {
|
|
32
|
+
let remainder = normalized.slice(uncRoot.length)
|
|
33
|
+
let normalizedRemainder = path.posix.normalize(remainder || '/')
|
|
34
|
+
return `${uncRoot}${normalizedRemainder === '/' ? '' : normalizedRemainder}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (windowsDriveLetterRE.test(normalized)) {
|
|
38
|
+
return path.posix.normalize(normalized)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (normalized.startsWith('/')) {
|
|
42
|
+
return path.posix.normalize(normalized)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return path.posix.normalize(normalizeWindowsPath(path.resolve(normalized)))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveFilePath(rootDir: string, filePath: string): string {
|
|
49
|
+
if (isAbsoluteFilePath(filePath)) {
|
|
50
|
+
return normalizeFilePath(filePath)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return normalizeFilePath(`${rootDir.replace(/\/+$/, '')}/${normalizeWindowsPath(filePath)}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getFilePathDirectory(filePath: string): string {
|
|
57
|
+
return path.posix.dirname(normalizeWindowsPath(filePath))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getFilePathBaseName(filePath: string): string {
|
|
61
|
+
return path.posix.basename(normalizeWindowsPath(filePath))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getUncRoot(filePath: string): string | null {
|
|
65
|
+
return filePath.startsWith('//') ? (filePath.match(uncPrefixRE)?.[0] ?? null) : null
|
|
66
|
+
}
|