@c9up/ream 0.2.12 → 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.
- package/dist/Application.d.ts +9 -0
- package/dist/Application.d.ts.map +1 -1
- package/dist/Application.js +11 -0
- package/dist/Application.js.map +1 -1
- package/dist/GracefulShutdown.js +6 -5
- package/dist/GracefulShutdown.js.map +1 -1
- package/dist/bodyparser/MultipartFile.d.ts +33 -7
- package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
- package/dist/bodyparser/MultipartFile.js +54 -9
- package/dist/bodyparser/MultipartFile.js.map +1 -1
- package/dist/container/Container.d.ts +19 -3
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +83 -30
- package/dist/container/Container.js.map +1 -1
- package/dist/container/ContainerResolver.js.map +1 -1
- package/dist/errors/PipelineStageError.d.ts +10 -4
- package/dist/errors/PipelineStageError.d.ts.map +1 -1
- package/dist/errors/PipelineStageError.js +12 -6
- package/dist/errors/PipelineStageError.js.map +1 -1
- package/dist/http/Response.d.ts.map +1 -1
- package/dist/http/Response.js +4 -9
- package/dist/http/Response.js.map +1 -1
- package/dist/http/SseStream.d.ts.map +1 -1
- package/dist/http/SseStream.js +10 -1
- package/dist/http/SseStream.js.map +1 -1
- package/dist/http/etag.d.ts +44 -0
- package/dist/http/etag.d.ts.map +1 -0
- package/dist/http/etag.js +65 -0
- package/dist/http/etag.js.map +1 -0
- package/dist/http/mime.d.ts +33 -0
- package/dist/http/mime.d.ts.map +1 -0
- package/dist/http/mime.js +1346 -0
- package/dist/http/mime.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/storage/StaticMiddleware.d.ts.map +1 -1
- package/dist/storage/StaticMiddleware.js +78 -59
- package/dist/storage/StaticMiddleware.js.map +1 -1
- package/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +3 -5
- package/scheduler.win32-x64-msvc.node +0 -0
- package/scripts/generate-mime.mjs +119 -0
- package/src/Application.ts +12 -0
- package/src/GracefulShutdown.ts +5 -5
- package/src/bodyparser/MultipartFile.ts +87 -13
- package/src/container/Container.ts +106 -52
- package/src/container/ContainerResolver.ts +1 -1
- package/src/errors/PipelineStageError.ts +13 -7
- package/src/http/Response.ts +4 -8
- package/src/http/SseStream.ts +16 -7
- package/src/http/etag.ts +68 -0
- package/src/http/mime.ts +1346 -0
- package/src/index.ts +3 -3
- 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 ───────────────────────────────────────────────
|
|
@@ -286,7 +286,7 @@ export {
|
|
|
286
286
|
export type { PipelineStageName } from './errors/PipelineStageError.js'
|
|
287
287
|
export {
|
|
288
288
|
createPipelineError,
|
|
289
|
-
|
|
289
|
+
PIPELINE_STAGES,
|
|
290
290
|
validatePipelineConfig,
|
|
291
291
|
} from './errors/PipelineStageError.js'
|
|
292
292
|
export { isReamError, ReamError } from './errors/ReamError.js'
|
|
@@ -378,4 +378,4 @@ export {
|
|
|
378
378
|
StatsTracker,
|
|
379
379
|
} from './scheduler/index.js'
|
|
380
380
|
|
|
381
|
-
export const VERSION = '0.2.
|
|
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.#
|
|
75
|
+
const resolved = await this.#openServableFile(ctx.request.path())
|
|
99
76
|
if (!resolved) return next()
|
|
100
|
-
const {
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
132
|
-
* is not servable (wrong prefix/extension, outside the root,
|
|
133
|
-
* symlink escaping the root).
|
|
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
|
-
#
|
|
131
|
+
async #openServableFile(
|
|
136
132
|
reqPath: string,
|
|
137
|
-
): {
|
|
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
|
|
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 =
|
|
171
|
-
realRoot =
|
|
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
|
-
|
|
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
|
}
|