@c9up/ream 0.2.18 → 0.2.19

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 (45) hide show
  1. package/README.md +4 -0
  2. package/dist/Application.d.ts +15 -0
  3. package/dist/Application.d.ts.map +1 -1
  4. package/dist/Application.js +15 -0
  5. package/dist/Application.js.map +1 -1
  6. package/dist/Provider.d.ts +15 -0
  7. package/dist/Provider.d.ts.map +1 -1
  8. package/dist/Provider.js.map +1 -1
  9. package/dist/http/Request.d.ts +13 -3
  10. package/dist/http/Request.d.ts.map +1 -1
  11. package/dist/http/Request.js.map +1 -1
  12. package/dist/index.d.ts +3 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -2
  15. package/dist/index.js.map +1 -1
  16. package/dist/router/Router.d.ts +1 -1
  17. package/dist/router/Router.js +1 -1
  18. package/dist/security/CookieSigner.d.ts +3 -2
  19. package/dist/security/CookieSigner.d.ts.map +1 -1
  20. package/dist/security/CookieSigner.js +3 -2
  21. package/dist/security/CookieSigner.js.map +1 -1
  22. package/dist/storage/StaticMiddleware.d.ts +75 -7
  23. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  24. package/dist/storage/StaticMiddleware.js +257 -85
  25. package/dist/storage/StaticMiddleware.js.map +1 -1
  26. package/dist/storage/StaticProvider.d.ts.map +1 -1
  27. package/dist/storage/StaticProvider.js +12 -3
  28. package/dist/storage/StaticProvider.js.map +1 -1
  29. package/events.win32-x64-msvc.node +0 -0
  30. package/index.darwin-arm64.node +0 -0
  31. package/index.darwin-x64.node +0 -0
  32. package/index.linux-arm64-gnu.node +0 -0
  33. package/index.linux-x64-gnu.node +0 -0
  34. package/index.win32-x64-msvc.node +0 -0
  35. package/package.json +8 -8
  36. package/scheduler.win32-x64-msvc.node +0 -0
  37. package/scripts/copy-napi.mjs +11 -3
  38. package/src/Application.ts +15 -0
  39. package/src/Provider.ts +15 -0
  40. package/src/http/Request.ts +13 -3
  41. package/src/index.ts +3 -3
  42. package/src/router/Router.ts +1 -1
  43. package/src/security/CookieSigner.ts +3 -2
  44. package/src/storage/StaticMiddleware.ts +338 -97
  45. package/src/storage/StaticProvider.ts +14 -3
@@ -1,10 +1,22 @@
1
1
  /**
2
2
  * Static file serving middleware.
3
3
  *
4
- * Serves files from a directory with cache headers and ETag support.
4
+ * The shape is `@adonisjs/static`'s, whose own work is done by `serve-static`:
5
+ * the same option names, the same defaults, the same response headers. Ream
6
+ * cannot delegate to `serve-static` itself —
7
+ *
8
+ * NAMED DEVIATION (NAPI): `serve-static` writes to a Node `ServerResponse`, and
9
+ * Ream's response crosses the NAPI boundary as a complete object; there is no
10
+ * such object to hand it. So the BEHAVIOUR is reproduced here rather than the
11
+ * code, and every deviation from it below is deliberate and marked.
12
+ *
13
+ * One deviation is a deliberate improvement, kept per the rule that we do not
14
+ * give up safety for parity: the file is opened with `O_NOFOLLOW` and its
15
+ * metadata read from the descriptor, so the bytes served are the ones that
16
+ * passed the containment checks. `serve-static` re-reads by path.
5
17
  *
6
18
  * Usage:
7
- * server.use(new StaticMiddleware({ root: 'public' }))
19
+ * server.use(new StaticMiddleware({ root: app.publicPath() }))
8
20
  *
9
21
  * @implements MISS-25
10
22
  */
@@ -17,144 +29,364 @@ import { matchesIfNoneMatch, statTag } from '../http/etag.js'
17
29
  import type { HttpContext } from '../http/HttpContext.js'
18
30
  import { contentType } from '../http/mime.js'
19
31
 
32
+ /** How a request for a dotfile is answered (`send`'s `dotfiles`). */
33
+ export type DotFilesPolicy = 'allow' | 'deny' | 'ignore'
34
+
20
35
  export interface StaticConfig {
21
36
  /** Root directory to serve files from. */
22
37
  root: string
23
- /** URL prefix (default: /static). */
24
- prefix?: string
25
- /** Max-age for Cache-Control header in seconds (default: 86400 = 1 day). */
38
+
39
+ /** Serve at all. `false` makes every request fall through. Default `true`. */
40
+ enabled?: boolean
41
+
42
+ /**
43
+ * Answer `Range` requests with `206`, and advertise `Accept-Ranges: bytes`.
44
+ * Default `true` — without it a browser cannot seek in audio or video, and
45
+ * an interrupted download restarts from zero.
46
+ */
47
+ acceptRanges?: boolean
48
+
49
+ /**
50
+ * Emit `Cache-Control`. Default `true`. When `false`, `maxAge` and
51
+ * `immutable` are ignored, as upstream documents.
52
+ */
53
+ cacheControl?: boolean
54
+
55
+ /**
56
+ * Dotfiles: `'ignore'` (fall through, the default), `'deny'` (403), or
57
+ * `'allow'` (serve). The default is why `.env` in the served root is not
58
+ * an accident waiting to happen.
59
+ */
60
+ dotFiles?: DotFilesPolicy
61
+
62
+ /** Emit `ETag` and honour `If-None-Match`. Default `true`. */
63
+ etag?: boolean
64
+
65
+ /**
66
+ * FALLBACK extensions, NOT an allowlist: when the requested path has no
67
+ * file, try appending each of these. `['html']` makes `/about` serve
68
+ * `about.html`. Default: none.
69
+ *
70
+ * The name and meaning are upstream's. An earlier version of this file used
71
+ * the same option as an allowlist — the opposite meaning — which silently
72
+ * restricted the server for anyone porting an AdonisJS config across, and
73
+ * refused formats the framework itself produces (`.avif`, from prism).
74
+ */
75
+ extensions?: string[] | false
76
+
77
+ /** Add `immutable` to `Cache-Control`. Needs `maxAge`. Default `false`. */
78
+ immutable?: boolean
79
+
80
+ /** Index file for a directory request. Default `'index.html'`; `false` disables. */
81
+ index?: string | string[] | false
82
+
83
+ /** Emit `Last-Modified` and honour `If-Modified-Since`. Default `true`. */
84
+ lastModified?: boolean
85
+
86
+ /** `Cache-Control: max-age`, in MILLISECONDS as upstream. Default `0`. */
26
87
  maxAge?: number
27
- /** File extensions to serve (default: common web assets). */
28
- extensions?: string[]
88
+
89
+ /** Extra headers, per file. Runs last, so it can override the above. */
90
+ headers?: (filePath: string, stat: fs.Stats) => Record<string, string>
91
+
92
+ /**
93
+ * Serve only under this URL prefix.
94
+ *
95
+ * NAMED DEVIATION: AdonisJS has no such option — its middleware is mounted at
96
+ * the URL root and falls through. Ream keeps it because the router is the
97
+ * same object the application writes to, and narrowing is occasionally what
98
+ * an application wants. The default is upstream's behaviour: no prefix.
99
+ */
100
+ prefix?: string
29
101
  }
30
102
 
31
- const DEFAULT_EXTENSIONS = [
32
- '.html',
33
- '.css',
34
- '.js',
35
- '.json',
36
- '.xml',
37
- '.txt',
38
- '.png',
39
- '.jpg',
40
- '.jpeg',
41
- '.gif',
42
- '.svg',
43
- '.ico',
44
- '.webp',
45
- '.woff',
46
- '.woff2',
47
- '.ttf',
48
- '.eot',
49
- '.mp4',
50
- '.webm',
51
- '.mp3',
52
- '.ogg',
53
- '.pdf',
54
- '.zip',
55
- ]
103
+ /** A single satisfiable byte range. */
104
+ interface ByteRange {
105
+ start: number
106
+ end: number
107
+ }
56
108
 
57
109
  export class StaticMiddleware {
58
- #root: string
59
- #prefix: string
60
- #maxAge: number
61
- #extensions: Set<string>
110
+ readonly #root: string
111
+ readonly #enabled: boolean
112
+ readonly #acceptRanges: boolean
113
+ readonly #cacheControl: boolean
114
+ readonly #dotFiles: DotFilesPolicy
115
+ readonly #etag: boolean
116
+ readonly #extensions: string[]
117
+ readonly #immutable: boolean
118
+ readonly #index: string[]
119
+ readonly #lastModified: boolean
120
+ readonly #maxAge: number
121
+ readonly #headers?: (filePath: string, stat: fs.Stats) => Record<string, string>
122
+ readonly #prefix: string
62
123
 
63
124
  constructor(config: StaticConfig) {
64
125
  this.#root = path.resolve(config.root)
65
- this.#prefix = config.prefix ?? '/static'
66
- this.#maxAge = config.maxAge ?? 86400
67
- this.#extensions = new Set(config.extensions ?? DEFAULT_EXTENSIONS)
126
+ this.#enabled = config.enabled ?? true
127
+ this.#acceptRanges = config.acceptRanges ?? true
128
+ this.#cacheControl = config.cacheControl ?? true
129
+ this.#dotFiles = config.dotFiles ?? 'ignore'
130
+ this.#etag = config.etag ?? true
131
+ this.#extensions = config.extensions === false ? [] : (config.extensions ?? [])
132
+ this.#immutable = config.immutable ?? false
133
+ this.#index =
134
+ config.index === false
135
+ ? []
136
+ : typeof config.index === 'string'
137
+ ? [config.index]
138
+ : (config.index ?? ['index.html'])
139
+ this.#lastModified = config.lastModified ?? true
140
+ this.#maxAge = config.maxAge ?? 0
141
+ this.#headers = config.headers
142
+ this.#prefix = config.prefix ?? ''
68
143
  }
69
144
 
70
145
  async handle(ctx: HttpContext, next: () => Promise<void>): Promise<void> {
71
- if (ctx.request.method() !== 'GET' && ctx.request.method() !== 'HEAD') {
72
- return next()
146
+ if (!this.#enabled) return next()
147
+ const method = ctx.request.method()
148
+ if (method !== 'GET' && method !== 'HEAD') return next()
149
+
150
+ const reqPath = ctx.request.path()
151
+
152
+ // A dotfile under `deny` is answered, not passed on: falling through would
153
+ // leak its existence through whatever the router answers next.
154
+ if (this.#dotFiles === 'deny' && this.#hasDotSegment(reqPath)) {
155
+ ctx.response.status(403).send('Forbidden')
156
+ return
73
157
  }
74
158
 
75
- const resolved = await this.#openServableFile(ctx.request.path())
159
+ const resolved = await this.#openServableFile(reqPath)
76
160
  if (!resolved) return next()
77
- const { handle, ext, stat } = resolved
161
+ const { handle, filePath, stat } = resolved
78
162
 
79
- // The handle is ours until the read stream takes it; every path that does
80
- // not hand it over closes it, or the process leaks a descriptor per 304.
163
+ // The handle is ours until a read stream takes it; every path that does not
164
+ // hand it over closes it, or the process leaks a descriptor per 304.
81
165
  let owned = true
82
166
  try {
83
- const etag = statTag(stat)
167
+ const etag = this.#etag ? statTag(stat) : undefined
168
+ const lastModified = this.#lastModified ? stat.mtime.toUTCString() : undefined
84
169
 
85
- if (matchesIfNoneMatch(ctx.request.header('if-none-match'), etag)) {
170
+ // One table for the whole package (`src/http/mime.ts`), generated from
171
+ // mime-db. A second hand-written one here is how `.js` came to be served
172
+ // as `application/javascript` from one path and `text/javascript` from
173
+ // the other.
174
+ ctx.response.header(
175
+ 'Content-Type',
176
+ contentType(path.extname(filePath).toLowerCase()) || 'application/octet-stream',
177
+ )
178
+ if (etag) ctx.response.header('ETag', etag)
179
+ if (lastModified) ctx.response.header('Last-Modified', lastModified)
180
+ if (this.#cacheControl) ctx.response.header('Cache-Control', this.#cacheControlValue())
181
+ if (this.#acceptRanges) ctx.response.header('Accept-Ranges', 'bytes')
182
+ if (this.#headers) {
183
+ for (const [name, value] of Object.entries(this.#headers(filePath, stat))) {
184
+ ctx.response.header(name, value)
185
+ }
186
+ }
187
+
188
+ if (this.#isFresh(ctx, etag, stat)) {
189
+ // A 304 carries no body and no Content-Length: the client reuses the
190
+ // copy it already has.
86
191
  ctx.response.status(304)
87
192
  return
88
193
  }
89
194
 
90
- // One table for the whole package (`src/http/mime.ts`), generated from
91
- // mime-db. A second hand-written one here is how `.js` came to be
92
- // served as `application/javascript` from one code path and
93
- // `text/javascript` from the other.
94
- ctx.response.header('Content-Type', contentType(ext) || 'application/octet-stream')
95
- ctx.response.header('Content-Length', String(stat.size))
96
- ctx.response.header('Cache-Control', `public, max-age=${this.#maxAge}`)
97
- ctx.response.header('ETag', etag)
98
-
99
- if (ctx.request.method() === 'HEAD') {
100
- ctx.response.status(200)
195
+ const range = this.#acceptRanges ? this.#resolveRange(ctx, stat, etag) : undefined
196
+ if (range === 'unsatisfiable') {
197
+ ctx.response.header('Content-Range', `bytes */${stat.size}`)
198
+ ctx.response.status(416).send('')
199
+ return
200
+ }
201
+
202
+ const length = range ? range.end - range.start + 1 : stat.size
203
+ ctx.response.header('Content-Length', String(length))
204
+ if (range) {
205
+ ctx.response.header('Content-Range', `bytes ${range.start}-${range.end}/${stat.size}`)
206
+ }
207
+
208
+ if (method === 'HEAD') {
209
+ // HEAD carries the headers a GET would, and no body.
210
+ ctx.response.status(range ? 206 : 200)
101
211
  return
102
212
  }
103
213
 
104
- // Streamed, not read whole.
105
- //
106
- // `readFileSync` stalled the event loop for EVERY concurrent request
107
- // while one client asked for a file, and the allowed extensions include
108
- // `.mp4`, `.zip` and `.pdf` — so one large asset became every other
109
- // request's latency, and each request allocated the file again. The
110
- // stream applies backpressure and holds one chunk at a time.
111
- //
112
- // The read stream owns the handle from here and closes it at end.
113
- ctx.response.status(200)
214
+ // Streamed, not read whole. `readFileSync` stalled the event loop for
215
+ // every concurrent request while one client asked for a file, and one
216
+ // large asset became every other request's latency. The read stream owns
217
+ // the handle from here and closes it at end.
218
+ ctx.response.status(range ? 206 : 200)
114
219
  owned = false
115
- await ctx.response.stream(handle.createReadStream())
220
+ await ctx.response.stream(
221
+ range
222
+ ? handle.createReadStream({ start: range.start, end: range.end })
223
+ : handle.createReadStream(),
224
+ )
116
225
  } finally {
117
226
  if (owned) await handle.close().catch(() => {})
118
227
  }
119
228
  }
120
229
 
230
+ /** `Cache-Control`, built the way `send` builds it. */
231
+ #cacheControlValue(): string {
232
+ const seconds = Math.floor(this.#maxAge / 1000)
233
+ let value = `public, max-age=${seconds}`
234
+ // Upstream only honours `immutable` alongside a max-age: `immutable` on a
235
+ // response that expires immediately tells a cache two opposite things.
236
+ if (this.#immutable && seconds > 0) value += ', immutable'
237
+ return value
238
+ }
239
+
240
+ /**
241
+ * Is the client's copy still good? `If-None-Match` wins outright when
242
+ * present, and `If-Modified-Since` is only consulted in its absence — the
243
+ * order RFC 9110 §13.1.3 requires, and the reason a weak ETag plus a
244
+ * second-resolution mtime do not contradict each other.
245
+ */
246
+ #isFresh(ctx: HttpContext, etag: string | undefined, stat: fs.Stats): boolean {
247
+ const ifNoneMatch = ctx.request.header('if-none-match')
248
+ if (ifNoneMatch !== undefined && ifNoneMatch !== null) {
249
+ return etag !== undefined && matchesIfNoneMatch(ifNoneMatch, etag)
250
+ }
251
+ if (!this.#lastModified) return false
252
+ const since = ctx.request.header('if-modified-since')
253
+ if (since === undefined || since === null) return false
254
+ const sinceMs = Date.parse(Array.isArray(since) ? since[0] : String(since))
255
+ if (Number.isNaN(sinceMs)) return false
256
+ // HTTP dates have one-second resolution; the file's mtime does not. Compare
257
+ // at the coarser one, or a file written mid-second looks modified forever.
258
+ return Math.floor(stat.mtimeMs / 1000) * 1000 <= sinceMs
259
+ }
260
+
261
+ /**
262
+ * The range to serve, `undefined` for the whole file, or `'unsatisfiable'`
263
+ * for a 416.
264
+ *
265
+ * Only a single range is honoured. Multiple ranges require a
266
+ * `multipart/byteranges` body, which upstream supports and this does not —
267
+ * a request for several is served whole, which is a response every client
268
+ * accepts.
269
+ */
270
+ #resolveRange(
271
+ ctx: HttpContext,
272
+ stat: fs.Stats,
273
+ etag: string | undefined,
274
+ ): ByteRange | 'unsatisfiable' | undefined {
275
+ const raw = ctx.request.header('range')
276
+ if (raw === undefined || raw === null) return undefined
277
+ const header = Array.isArray(raw) ? raw[0] : String(raw)
278
+
279
+ // `If-Range` guards against a resumed download stitching bytes from two
280
+ // different versions of the file: if the validator does not match, the
281
+ // whole current file is the honest answer.
282
+ const ifRange = ctx.request.header('if-range')
283
+ if (ifRange !== undefined && ifRange !== null) {
284
+ const value = Array.isArray(ifRange) ? ifRange[0] : String(ifRange)
285
+ const matchesEtag = etag !== undefined && value.trim() === etag
286
+ const asDate = Date.parse(value)
287
+ const matchesDate = !Number.isNaN(asDate) && Math.floor(stat.mtimeMs / 1000) * 1000 <= asDate
288
+ if (!matchesEtag && !matchesDate) return undefined
289
+ }
290
+
291
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
292
+ if (!match) return undefined // not a form we serve: send the whole file
293
+ const [, rawStart, rawEnd] = match
294
+ if (rawStart === '' && rawEnd === '') return undefined
295
+
296
+ let start: number
297
+ let end: number
298
+ if (rawStart === '') {
299
+ // `bytes=-500`: the LAST 500 bytes, not the first.
300
+ const suffix = Number(rawEnd)
301
+ if (suffix === 0) return 'unsatisfiable'
302
+ start = Math.max(stat.size - suffix, 0)
303
+ end = stat.size - 1
304
+ } else {
305
+ start = Number(rawStart)
306
+ end = rawEnd === '' ? stat.size - 1 : Math.min(Number(rawEnd), stat.size - 1)
307
+ }
308
+
309
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= stat.size) {
310
+ return 'unsatisfiable'
311
+ }
312
+ return { start, end }
313
+ }
314
+
315
+ #hasDotSegment(reqPath: string): boolean {
316
+ return reqPath.split('/').some((segment) => segment.length > 1 && segment.startsWith('.'))
317
+ }
318
+
121
319
  /**
122
- * Open a request path as a real, in-root, regular file, or answer `null`
123
- * when it is not servable (wrong prefix/extension, outside the root,
124
- * missing, or a symlink escaping the root). `null` tells `handle` to call
125
- * `next()`.
320
+ * Open a request path as a real, in-root, regular file, or answer `null` when
321
+ * it is not servable. `null` tells `handle` to call `next()`.
126
322
  *
127
323
  * The file is OPENED here rather than merely checked, and its metadata read
128
- * from the descriptor: what gets served is then the same file that passed
129
- * the checks, which a path re-read after the fact cannot promise.
324
+ * from the descriptor: what gets served is then the same file that passed the
325
+ * checks, which a path re-read after the fact cannot promise.
130
326
  */
131
327
  async #openServableFile(
132
328
  reqPath: string,
133
- ): Promise<{ handle: FileHandle; ext: string; stat: fs.Stats } | null> {
134
- // Match only when the prefix is followed by `/` or end-of-path —
135
- // otherwise `/staticx/foo.js` would be intercepted when the configured
136
- // prefix is `/static`, hijacking unrelated routes.
137
- if (!(reqPath === this.#prefix || reqPath.startsWith(`${this.#prefix}/`))) {
329
+ ): Promise<{ handle: FileHandle; filePath: string; stat: fs.Stats } | null> {
330
+ if (this.#prefix !== '') {
331
+ // Match only when the prefix is followed by `/` or end-of-path —
332
+ // otherwise `/staticx/foo.js` would be intercepted when the configured
333
+ // prefix is `/static`, hijacking unrelated routes.
334
+ if (!(reqPath === this.#prefix || reqPath.startsWith(`${this.#prefix}/`))) return null
335
+ reqPath = reqPath.slice(this.#prefix.length) || '/'
336
+ }
337
+
338
+ if (this.#dotFiles === 'ignore' && this.#hasDotSegment(reqPath)) return null
339
+
340
+ // The candidates, in upstream's order: the path itself, then the path with
341
+ // each fallback extension, then the index files for a directory request.
342
+ //
343
+ // Percent-decoding happens BEFORE resolution on purpose: `%2e%2e%2f` is
344
+ // `../` and has to be normalised away by `path.resolve` like any other
345
+ // traversal, not carried through as an opaque segment. `decodeURIComponent`
346
+ // THROWS on a malformed sequence (a lone `%`), which would otherwise become
347
+ // a 500 on a request anyone can send.
348
+ let decoded: string
349
+ try {
350
+ decoded = decodeURIComponent(reqPath)
351
+ } catch {
138
352
  return null
139
353
  }
354
+ // A NUL truncates the path in the syscall layer of some runtimes; Node
355
+ // rejects it, but refusing here keeps the decision in one place.
356
+ if (decoded.includes('\0')) return null
357
+ const relative = decoded.replace(/^\/+/, '')
358
+ const candidates: string[] = []
359
+ if (relative !== '') {
360
+ candidates.push(relative)
361
+ for (const ext of this.#extensions) {
362
+ candidates.push(`${relative}.${ext.replace(/^\./, '')}`)
363
+ }
364
+ }
365
+ for (const index of this.#index) {
366
+ candidates.push(relative === '' ? index : `${relative}/${index}`)
367
+ }
140
368
 
141
- // Strip prefix and resolve file path
142
- const relativePath = reqPath.slice(this.#prefix.length) || '/index.html'
143
- const ext = path.extname(relativePath).toLowerCase()
144
- if (!this.#extensions.has(ext)) return null
369
+ for (const candidate of candidates) {
370
+ const opened = await this.#openInRoot(candidate)
371
+ if (opened) return opened
372
+ }
373
+ return null
374
+ }
145
375
 
146
- const filePath = path.resolve(this.#root, relativePath.replace(/^\//, ''))
376
+ /** One candidate, fully contained and opened, or `null`. */
377
+ async #openInRoot(
378
+ relative: string,
379
+ ): Promise<{ handle: FileHandle; filePath: string; stat: fs.Stats } | null> {
380
+ const filePath = path.resolve(this.#root, relative)
147
381
 
148
382
  // Two containment checks. The first uses `path.sep` to reject sibling
149
- // directories with a shared prefix (`/var/www-static-secret` slipping
150
- // past a guard for `/var/www-static`). The second resolves symlinks
151
- // via realpath so a symlink planted inside the root cannot point
152
- // outside it. We do realpath AFTER the lexical check so a non-existent
153
- // path returns `next()` rather than throwing.
383
+ // directories with a shared prefix (`/var/www-static-secret` slipping past
384
+ // a guard for `/var/www-static`). The second resolves symlinks via realpath
385
+ // so a symlink planted inside the root cannot point outside it. Realpath
386
+ // runs AFTER the lexical check so a non-existent path returns null rather
387
+ // than throwing.
154
388
  const rootWithSep = this.#root.endsWith(path.sep) ? this.#root : this.#root + path.sep
155
- if (filePath !== this.#root && !filePath.startsWith(rootWithSep)) {
156
- return null
157
- }
389
+ if (filePath !== this.#root && !filePath.startsWith(rootWithSep)) return null
158
390
 
159
391
  let realFilePath: string
160
392
  let realRoot: string
@@ -165,9 +397,7 @@ export class StaticMiddleware {
165
397
  return null
166
398
  }
167
399
  const realRootWithSep = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep
168
- if (realFilePath !== realRoot && !realFilePath.startsWith(realRootWithSep)) {
169
- return null
170
- }
400
+ if (realFilePath !== realRoot && !realFilePath.startsWith(realRootWithSep)) return null
171
401
 
172
402
  // O_NOFOLLOW closes the window the checks above leave open: between
173
403
  // `realpath` and the read, the last path component can be replaced by a
@@ -188,10 +418,21 @@ export class StaticMiddleware {
188
418
  await handle.close()
189
419
  return null
190
420
  }
191
- return { handle, ext, stat }
421
+ return { handle, filePath: realFilePath, stat }
192
422
  } catch {
193
423
  await handle.close().catch(() => {})
194
424
  return null
195
425
  }
196
426
  }
197
427
  }
428
+
429
+ /**
430
+ * Static config with the defaults filled in — `@adonisjs/static`'s
431
+ * `defineConfig`, value for value.
432
+ *
433
+ * // config/static.ts
434
+ * export default defineStaticConfig({ maxAge: 86_400_000 })
435
+ */
436
+ export function defineStaticConfig(config: Partial<StaticConfig> = {}): Partial<StaticConfig> {
437
+ return { enabled: true, dotFiles: 'ignore', etag: true, lastModified: true, ...config }
438
+ }
@@ -41,9 +41,20 @@ export class StaticProvider extends Provider {
41
41
  * the app that meant to override it.
42
42
  */
43
43
  override async start(): Promise<void> {
44
- const config = this.app.config.get<StaticConfig>('static')
45
- const middleware = this.#override ?? (config?.root ? new StaticMiddleware(config) : undefined)
46
- if (!middleware) return // not configured — opt-out
44
+ // `?? {}` rather than a default argument: a host may supply its own
45
+ // ConfigReader, and not every implementation honours the second parameter.
46
+ const config = this.app.config.get<StaticConfig>('static') ?? ({} as StaticConfig)
47
+
48
+ // Registering the provider is the whole opt-in, as it is upstream: there,
49
+ // `node ace configure @adonisjs/static` adds the provider AND the
50
+ // middleware, and the middleware serves `public/` with no `root` named
51
+ // anywhere. Requiring a `root` before doing anything meant a provider
52
+ // could be registered and silently serve nothing.
53
+ const root = config.root ?? this.app.publicPath?.() ?? 'public'
54
+ const middleware =
55
+ this.#override ??
56
+ (config.enabled === false ? undefined : new StaticMiddleware({ ...config, root }))
57
+ if (!middleware) return
47
58
  const server = await this.app.container.make<Server>('server')
48
59
  server.use([(ctx, next) => middleware.handle(ctx, next)])
49
60
  }