@c9up/ream 0.2.13 → 0.2.14

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 (46) hide show
  1. package/dist/GracefulShutdown.js +6 -5
  2. package/dist/GracefulShutdown.js.map +1 -1
  3. package/dist/bodyparser/MultipartFile.d.ts +33 -7
  4. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  5. package/dist/bodyparser/MultipartFile.js +54 -9
  6. package/dist/bodyparser/MultipartFile.js.map +1 -1
  7. package/dist/container/Container.d.ts +19 -3
  8. package/dist/container/Container.d.ts.map +1 -1
  9. package/dist/container/Container.js +83 -30
  10. package/dist/container/Container.js.map +1 -1
  11. package/dist/container/ContainerResolver.js.map +1 -1
  12. package/dist/http/Response.d.ts.map +1 -1
  13. package/dist/http/Response.js +4 -9
  14. package/dist/http/Response.js.map +1 -1
  15. package/dist/http/SseStream.d.ts.map +1 -1
  16. package/dist/http/SseStream.js +10 -1
  17. package/dist/http/SseStream.js.map +1 -1
  18. package/dist/http/etag.d.ts +44 -0
  19. package/dist/http/etag.d.ts.map +1 -0
  20. package/dist/http/etag.js +65 -0
  21. package/dist/http/etag.js.map +1 -0
  22. package/dist/http/mime.d.ts +33 -0
  23. package/dist/http/mime.d.ts.map +1 -0
  24. package/dist/http/mime.js +1346 -0
  25. package/dist/http/mime.js.map +1 -0
  26. package/dist/index.d.ts +2 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  30. package/dist/storage/StaticMiddleware.js +78 -59
  31. package/dist/storage/StaticMiddleware.js.map +1 -1
  32. package/events.win32-x64-msvc.node +0 -0
  33. package/index.win32-x64-msvc.node +0 -0
  34. package/package.json +3 -5
  35. package/scheduler.win32-x64-msvc.node +0 -0
  36. package/scripts/generate-mime.mjs +119 -0
  37. package/src/GracefulShutdown.ts +5 -5
  38. package/src/bodyparser/MultipartFile.ts +87 -13
  39. package/src/container/Container.ts +106 -52
  40. package/src/container/ContainerResolver.ts +1 -1
  41. package/src/http/Response.ts +4 -8
  42. package/src/http/SseStream.ts +16 -7
  43. package/src/http/etag.ts +68 -0
  44. package/src/http/mime.ts +1346 -0
  45. package/src/index.ts +2 -2
  46. package/src/storage/StaticMiddleware.ts +81 -66
package/src/index.ts CHANGED
@@ -101,7 +101,7 @@ export { default as SessionMiddleware } from './session/SessionMiddleware.js'
101
101
 
102
102
  export type { BodyParserConfig } from './bodyparser/BodyParserMiddleware.js'
103
103
  export { default as BodyParserMiddleware } from './bodyparser/BodyParserMiddleware.js'
104
- export type { FileValidationOptions } from './bodyparser/MultipartFile.js'
104
+ export type { FileValidationOptions, MimeType } from './bodyparser/MultipartFile.js'
105
105
  export { MultipartFile } from './bodyparser/MultipartFile.js'
106
106
 
107
107
  // ─── JSON-RPC ───────────────────────────────────────────────
@@ -378,4 +378,4 @@ export {
378
378
  StatsTracker,
379
379
  } from './scheduler/index.js'
380
380
 
381
- export const VERSION = '0.2.13'
381
+ export const VERSION = '0.2.14'
@@ -9,10 +9,13 @@
9
9
  * @implements MISS-25
10
10
  */
11
11
 
12
- import { createHash } from 'node:crypto'
13
12
  import * as fs from 'node:fs'
13
+ import type { FileHandle } from 'node:fs/promises'
14
+ import * as fsp from 'node:fs/promises'
14
15
  import * as path from 'node:path'
16
+ import { matchesIfNoneMatch, statTag } from '../http/etag.js'
15
17
  import type { HttpContext } from '../http/HttpContext.js'
18
+ import { contentType } from '../http/mime.js'
16
19
 
17
20
  export interface StaticConfig {
18
21
  /** Root directory to serve files from. */
@@ -51,32 +54,6 @@ const DEFAULT_EXTENSIONS = [
51
54
  '.zip',
52
55
  ]
53
56
 
54
- const MIME_TYPES: Record<string, string> = {
55
- '.html': 'text/html',
56
- '.css': 'text/css',
57
- '.js': 'application/javascript',
58
- '.json': 'application/json',
59
- '.xml': 'application/xml',
60
- '.txt': 'text/plain',
61
- '.png': 'image/png',
62
- '.jpg': 'image/jpeg',
63
- '.jpeg': 'image/jpeg',
64
- '.gif': 'image/gif',
65
- '.svg': 'image/svg+xml',
66
- '.ico': 'image/x-icon',
67
- '.webp': 'image/webp',
68
- '.woff': 'font/woff',
69
- '.woff2': 'font/woff2',
70
- '.ttf': 'font/ttf',
71
- '.eot': 'application/vnd.ms-fontobject',
72
- '.mp4': 'video/mp4',
73
- '.webm': 'video/webm',
74
- '.mp3': 'audio/mpeg',
75
- '.ogg': 'audio/ogg',
76
- '.pdf': 'application/pdf',
77
- '.zip': 'application/zip',
78
- }
79
-
80
57
  export class StaticMiddleware {
81
58
  #root: string
82
59
  #prefix: string
@@ -95,46 +72,65 @@ export class StaticMiddleware {
95
72
  return next()
96
73
  }
97
74
 
98
- const resolved = this.#resolveServableFile(ctx.request.path())
75
+ const resolved = await this.#openServableFile(ctx.request.path())
99
76
  if (!resolved) return next()
100
- const { realFilePath, ext, stat } = resolved
101
-
102
- const etag = createHash('md5').update(`${stat.size}-${stat.mtimeMs}`).digest('hex')
103
-
104
- // ETag check — 304 Not Modified
105
- const ifNoneMatch = ctx.request.header('if-none-match')
106
- if (ifNoneMatch === etag) {
107
- ctx.response.status(304)
108
- return
109
- }
77
+ const { handle, ext, stat } = resolved
110
78
 
111
- const mime = MIME_TYPES[ext] ?? 'application/octet-stream'
112
- ctx.response.header('Content-Type', mime)
113
- ctx.response.header('Content-Length', String(stat.size))
114
- ctx.response.header('Cache-Control', `public, max-age=${this.#maxAge}`)
115
- ctx.response.header('ETag', etag)
116
-
117
- if (ctx.request.method() === 'HEAD') {
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.
81
+ let owned = true
82
+ try {
83
+ const etag = statTag(stat)
84
+
85
+ if (matchesIfNoneMatch(ctx.request.header('if-none-match'), etag)) {
86
+ ctx.response.status(304)
87
+ return
88
+ }
89
+
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)
101
+ return
102
+ }
103
+
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.
118
113
  ctx.response.status(200)
119
- return
114
+ owned = false
115
+ await ctx.response.stream(handle.createReadStream())
116
+ } finally {
117
+ if (owned) await handle.close().catch(() => {})
120
118
  }
121
-
122
- // Send the raw Buffer — `content.toString()` would interpret PNG /
123
- // PDF / ZIP bytes as UTF-8 and replace any byte the encoding can't
124
- // round-trip (anything in the surrogate range, plus invalid
125
- // sequences) with U+FFFD.
126
- const content = fs.readFileSync(realFilePath)
127
- ctx.response.status(200).sendBuffer(content)
128
119
  }
129
120
 
130
121
  /**
131
- * Resolve a request path to a real, in-root, existing file, or `null` when it
132
- * is not servable (wrong prefix/extension, outside the root, missing, or a
133
- * symlink escaping the root). Returning `null` tells `handle` to call `next()`.
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()`.
126
+ *
127
+ * 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.
134
130
  */
135
- #resolveServableFile(
131
+ async #openServableFile(
136
132
  reqPath: string,
137
- ): { realFilePath: string; ext: string; stat: fs.Stats } | null {
133
+ ): Promise<{ handle: FileHandle; ext: string; stat: fs.Stats } | null> {
138
134
  // Match only when the prefix is followed by `/` or end-of-path —
139
135
  // otherwise `/staticx/foo.js` would be intercepted when the configured
140
136
  // prefix is `/static`, hijacking unrelated routes.
@@ -152,7 +148,7 @@ export class StaticMiddleware {
152
148
  // Two containment checks. The first uses `path.sep` to reject sibling
153
149
  // directories with a shared prefix (`/var/www-static-secret` slipping
154
150
  // past a guard for `/var/www-static`). The second resolves symlinks
155
- // via realpathSync so a symlink planted inside the root cannot point
151
+ // via realpath so a symlink planted inside the root cannot point
156
152
  // outside it. We do realpath AFTER the lexical check so a non-existent
157
153
  // path returns `next()` rather than throwing.
158
154
  const rootWithSep = this.#root.endsWith(path.sep) ? this.#root : this.#root + path.sep
@@ -160,15 +156,11 @@ export class StaticMiddleware {
160
156
  return null
161
157
  }
162
158
 
163
- if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
164
- return null
165
- }
166
-
167
159
  let realFilePath: string
168
160
  let realRoot: string
169
161
  try {
170
- realFilePath = fs.realpathSync(filePath)
171
- realRoot = fs.realpathSync(this.#root)
162
+ realFilePath = await fsp.realpath(filePath)
163
+ realRoot = await fsp.realpath(this.#root)
172
164
  } catch {
173
165
  return null
174
166
  }
@@ -177,6 +169,29 @@ export class StaticMiddleware {
177
169
  return null
178
170
  }
179
171
 
180
- return { realFilePath, ext, stat: fs.statSync(realFilePath) }
172
+ // O_NOFOLLOW closes the window the checks above leave open: between
173
+ // `realpath` and the read, the last path component can be replaced by a
174
+ // symlink pointing anywhere, and a read by name follows it. Refusing to
175
+ // follow a link at open time makes that swap fail the open instead of
176
+ // serving a file outside the root. A parent directory swapped in the same
177
+ // window is not covered — that needs openat(), which Node does not expose.
178
+ let handle: FileHandle
179
+ try {
180
+ handle = await fsp.open(realFilePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
181
+ } catch {
182
+ return null
183
+ }
184
+
185
+ try {
186
+ const stat = await handle.stat()
187
+ if (!stat.isFile()) {
188
+ await handle.close()
189
+ return null
190
+ }
191
+ return { handle, ext, stat }
192
+ } catch {
193
+ await handle.close().catch(() => {})
194
+ return null
195
+ }
181
196
  }
182
197
  }