@znight/http 0.0.0-stage → 0.1.1

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2333 -2
  3. package/dist/adapter-CGSceVEN.d.mts +876 -0
  4. package/dist/compiler.d.mts +156 -0
  5. package/dist/compiler.mjs +2180 -0
  6. package/dist/compiler.mjs.map +1 -0
  7. package/dist/express.d.mts +39 -0
  8. package/dist/express.mjs +135 -0
  9. package/dist/express.mjs.map +1 -0
  10. package/dist/fastify.d.mts +33 -0
  11. package/dist/fastify.mjs +191 -0
  12. package/dist/fastify.mjs.map +1 -0
  13. package/dist/hono.d.mts +49 -0
  14. package/dist/hono.mjs +319 -0
  15. package/dist/hono.mjs.map +1 -0
  16. package/dist/index.d.mts +1254 -0
  17. package/dist/index.mjs +5618 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/node-BUnkdSsa.mjs +183 -0
  20. package/dist/node-BUnkdSsa.mjs.map +1 -0
  21. package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
  22. package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
  23. package/dist/node.d.mts +30 -0
  24. package/dist/node.mjs +1 -0
  25. package/dist/path-JI87C4k8.d.mts +109 -0
  26. package/dist/path.d.mts +2 -0
  27. package/dist/path.mjs +245 -0
  28. package/dist/path.mjs.map +1 -0
  29. package/dist/size-D8rsQcV0.mjs +184 -0
  30. package/dist/size-D8rsQcV0.mjs.map +1 -0
  31. package/dist/utils-DCbu3a4w.mjs +27 -0
  32. package/dist/utils-DCbu3a4w.mjs.map +1 -0
  33. package/package.json +142 -4
  34. package/src/adapter.ts +240 -0
  35. package/src/adapters/express.ts +201 -0
  36. package/src/adapters/fastify.ts +280 -0
  37. package/src/adapters/hono.ts +488 -0
  38. package/src/adapters/node-io.ts +368 -0
  39. package/src/adapters/node.ts +135 -0
  40. package/src/adapters/shared.ts +508 -0
  41. package/src/body.ts +135 -0
  42. package/src/coerce.ts +195 -0
  43. package/src/compiler/analyze.ts +1513 -0
  44. package/src/compiler/ast.ts +90 -0
  45. package/src/compiler/coercion.ts +267 -0
  46. package/src/compiler/generate.ts +374 -0
  47. package/src/compiler/index.ts +76 -0
  48. package/src/compiler/model.ts +260 -0
  49. package/src/compiler/route-decorators.ts +405 -0
  50. package/src/compiler/validate.ts +288 -0
  51. package/src/context.ts +533 -0
  52. package/src/cookie-settings.ts +68 -0
  53. package/src/cookie-signer.ts +106 -0
  54. package/src/cookies.ts +726 -0
  55. package/src/cors.ts +349 -0
  56. package/src/decisions.ts +331 -0
  57. package/src/decorators.ts +486 -0
  58. package/src/event-channel.ts +197 -0
  59. package/src/exceptions.ts +257 -0
  60. package/src/files.ts +429 -0
  61. package/src/headers.ts +39 -0
  62. package/src/index.ts +185 -0
  63. package/src/inject-sse.ts +309 -0
  64. package/src/interfaces.ts +101 -0
  65. package/src/metadata.ts +162 -0
  66. package/src/mime.ts +157 -0
  67. package/src/multipart.ts +597 -0
  68. package/src/option-error.ts +31 -0
  69. package/src/path.ts +288 -0
  70. package/src/pipeline.ts +918 -0
  71. package/src/plugin-routes.ts +188 -0
  72. package/src/plugin.ts +1432 -0
  73. package/src/proxy.ts +236 -0
  74. package/src/query.ts +30 -0
  75. package/src/response.ts +240 -0
  76. package/src/router.ts +119 -0
  77. package/src/security-headers.ts +161 -0
  78. package/src/serialize.ts +264 -0
  79. package/src/size.ts +66 -0
  80. package/src/sse-parse.ts +194 -0
  81. package/src/sse.ts +650 -0
  82. package/src/standard-schema.ts +81 -0
  83. package/src/status.ts +78 -0
  84. package/src/stream.ts +200 -0
  85. package/src/tokens.ts +21 -0
  86. package/src/uploads.ts +176 -0
  87. package/src/utils.ts +31 -0
  88. package/src/validate.ts +65 -0
@@ -0,0 +1,257 @@
1
+ import type { StandardSchemaV1Issue } from './standard-schema.ts'
2
+
3
+ import { isValidStatus, statusText } from './status.ts'
4
+
5
+ /** Response header values: a single value or several (`set-cookie`). */
6
+ export type HeaderValue = string | readonly string[]
7
+
8
+ /** Options of {@link HttpException}. */
9
+ export interface HttpExceptionOptions extends ErrorOptions {
10
+ /** Headers added to the error response (`www-authenticate`, `retry-after`, `allow`...). */
11
+ readonly headers?: Readonly<Record<string, HeaderValue>>
12
+ }
13
+
14
+ /** JSON body of an error response. */
15
+ export interface HttpErrorBody {
16
+ readonly statusCode: number
17
+ /** Reason phrase of the status (`Not Found`). */
18
+ readonly error: string
19
+ readonly message: string
20
+ /** Structured details (validation issues...), omitted when absent. */
21
+ readonly details?: unknown
22
+ }
23
+
24
+ /** One entry of the `details` of a validation error. */
25
+ export interface ValidationIssueDetail {
26
+ /** Dotted path of the invalid value inside the validated input (`user.email`, `items.0`), `''` for the root. */
27
+ readonly path: string
28
+ readonly message: string
29
+ }
30
+
31
+ const NO_HEADERS: Readonly<Record<string, HeaderValue>> = Object.freeze({})
32
+
33
+ /**
34
+ * An error carrying an HTTP status. Thrown from handlers, guards, interceptors or services, it is
35
+ * rendered as `{ statusCode, error, message, details? }` with its status and headers.
36
+ *
37
+ * ```ts
38
+ * throw new HttpException(409, 'Email already used', { field: 'email' })
39
+ * throw new NotFoundException(`User ${id} not found`)
40
+ * throw new UnauthorizedException('Token expired', undefined, { headers: { 'www-authenticate': 'Bearer' } })
41
+ * ```
42
+ *
43
+ * Invalid statuses (not an integer in 100–599) become `500`.
44
+ */
45
+ export class HttpException extends Error {
46
+ /** HTTP status of the response. */
47
+ readonly status: number
48
+ /** Structured details rendered in the body (omitted when `undefined`). */
49
+ readonly details: unknown
50
+ /** Headers added to the response. */
51
+ readonly headers: Readonly<Record<string, HeaderValue>>
52
+
53
+ constructor(status: number, message?: string, details?: unknown, options: HttpExceptionOptions = {}) {
54
+ const code = isValidStatus(status) ? status : 500
55
+ super(message ?? statusText(code), options.cause === undefined ? undefined : { cause: options.cause })
56
+ this.name = 'HttpException'
57
+ this.status = code
58
+ this.details = details
59
+ this.headers = options.headers ?? NO_HEADERS
60
+ }
61
+
62
+ /** The `statusCode` field of the body (alias of {@link status}). */
63
+ get statusCode(): number {
64
+ return this.status
65
+ }
66
+
67
+ /** Body of the error response. Override it in a subclass to change the shape. */
68
+ toBody(): HttpErrorBody {
69
+ const body: { statusCode: number; error: string; message: string; details?: unknown } = {
70
+ statusCode: this.status,
71
+ error: statusText(this.status),
72
+ message: this.message,
73
+ }
74
+ if (this.details !== undefined) body.details = this.details
75
+ return body
76
+ }
77
+ }
78
+
79
+ /** Whether `value` is an {@link HttpException} (also across duplicated package copies). */
80
+ export function isHttpException(value: unknown): value is HttpException {
81
+ return (
82
+ value instanceof HttpException ||
83
+ (value instanceof Error &&
84
+ typeof (value as { status?: unknown }).status === 'number' &&
85
+ typeof (value as { toBody?: unknown }).toBody === 'function')
86
+ )
87
+ }
88
+
89
+ /** 400 Bad Request. */
90
+ export class BadRequestException extends HttpException {
91
+ constructor(message?: string, details?: unknown, options?: HttpExceptionOptions) {
92
+ super(400, message, details, options)
93
+ this.name = 'BadRequestException'
94
+ }
95
+ }
96
+
97
+ /**
98
+ * 400 Bad Request raised when an input fails its schema (`@Body(schema)`, `@Query(schema)`...).
99
+ * `details` lists the issues as `{ path, message }`; the raw Standard Schema issues are kept in
100
+ * {@link issues} for exception mappers.
101
+ */
102
+ export class ValidationException extends BadRequestException {
103
+ /** What was validated: `body`, `query`, `query "page"`, `param "id"`... */
104
+ readonly location: string
105
+ readonly issues: readonly StandardSchemaV1Issue[]
106
+
107
+ constructor(location: string, issues: readonly StandardSchemaV1Issue[], options?: HttpExceptionOptions) {
108
+ super(`Invalid ${location}`, issues.map(toIssueDetail), options)
109
+ this.name = 'ValidationException'
110
+ this.location = location
111
+ this.issues = issues
112
+ }
113
+ }
114
+
115
+ /**
116
+ * The `details` of a 400 raised by the framework for one invalid input (a multipart field...): the
117
+ * shape of the issues of {@link ValidationException}, documented by `@znight/openapi`.
118
+ */
119
+ export function issue(path: string, message: string): readonly ValidationIssueDetail[] {
120
+ return [{ path, message }]
121
+ }
122
+
123
+ /** Converts a Standard Schema issue to `{ path, message }` (`path` dotted, `''` for the root). */
124
+ export function toIssueDetail(issue: StandardSchemaV1Issue): ValidationIssueDetail {
125
+ return { path: formatIssuePath(issue.path), message: issue.message }
126
+ }
127
+
128
+ function formatIssuePath(path: StandardSchemaV1Issue['path']): string {
129
+ if (path === undefined || path.length === 0) return ''
130
+ let result = ''
131
+ for (const segment of path) {
132
+ const key = typeof segment === 'object' && segment !== null ? segment.key : segment
133
+ const text = typeof key === 'symbol' ? (key.description ?? 'symbol') : String(key)
134
+ result = result === '' ? text : `${result}.${text}`
135
+ }
136
+ return result
137
+ }
138
+
139
+ /** 401 Unauthorized. */
140
+ export class UnauthorizedException extends HttpException {
141
+ constructor(message?: string, details?: unknown, options?: HttpExceptionOptions) {
142
+ super(401, message, details, options)
143
+ this.name = 'UnauthorizedException'
144
+ }
145
+ }
146
+
147
+ /** 403 Forbidden (also the response of a guard returning `false`). */
148
+ export class ForbiddenException extends HttpException {
149
+ constructor(message?: string, details?: unknown, options?: HttpExceptionOptions) {
150
+ super(403, message, details, options)
151
+ this.name = 'ForbiddenException'
152
+ }
153
+ }
154
+
155
+ /** 404 Not Found. */
156
+ export class NotFoundException extends HttpException {
157
+ constructor(message?: string, details?: unknown, options?: HttpExceptionOptions) {
158
+ super(404, message, details, options)
159
+ this.name = 'NotFoundException'
160
+ }
161
+ }
162
+
163
+ /** 405 Method Not Allowed (pass the `allow` header in `options.headers`). */
164
+ export class MethodNotAllowedException extends HttpException {
165
+ constructor(message?: string, details?: unknown, options?: HttpExceptionOptions) {
166
+ super(405, message, details, options)
167
+ this.name = 'MethodNotAllowedException'
168
+ }
169
+ }
170
+
171
+ /** 409 Conflict. */
172
+ export class ConflictException extends HttpException {
173
+ constructor(message?: string, details?: unknown, options?: HttpExceptionOptions) {
174
+ super(409, message, details, options)
175
+ this.name = 'ConflictException'
176
+ }
177
+ }
178
+
179
+ /** 410 Gone. */
180
+ export class GoneException extends HttpException {
181
+ constructor(message?: string, details?: unknown, options?: HttpExceptionOptions) {
182
+ super(410, message, details, options)
183
+ this.name = 'GoneException'
184
+ }
185
+ }
186
+
187
+ /** 413 Payload Too Large (request body above `bodyLimit`). */
188
+ export class PayloadTooLargeException extends HttpException {
189
+ constructor(message?: string, details?: unknown, options?: HttpExceptionOptions) {
190
+ super(413, message, details, options)
191
+ this.name = 'PayloadTooLargeException'
192
+ }
193
+ }
194
+
195
+ /** 415 Unsupported Media Type. */
196
+ export class UnsupportedMediaTypeException extends HttpException {
197
+ constructor(message?: string, details?: unknown, options?: HttpExceptionOptions) {
198
+ super(415, message, details, options)
199
+ this.name = 'UnsupportedMediaTypeException'
200
+ }
201
+ }
202
+
203
+ /** 422 Unprocessable Entity. */
204
+ export class UnprocessableEntityException extends HttpException {
205
+ constructor(message?: string, details?: unknown, options?: HttpExceptionOptions) {
206
+ super(422, message, details, options)
207
+ this.name = 'UnprocessableEntityException'
208
+ }
209
+ }
210
+
211
+ /** 429 Too Many Requests (pass `retry-after` in `options.headers`). */
212
+ export class TooManyRequestsException extends HttpException {
213
+ constructor(message?: string, details?: unknown, options?: HttpExceptionOptions) {
214
+ super(429, message, details, options)
215
+ this.name = 'TooManyRequestsException'
216
+ }
217
+ }
218
+
219
+ /** 500 Internal Server Error (its message is always sent, unlike unexpected errors). */
220
+ export class InternalServerErrorException extends HttpException {
221
+ constructor(message?: string, details?: unknown, options?: HttpExceptionOptions) {
222
+ super(500, message, details, options)
223
+ this.name = 'InternalServerErrorException'
224
+ }
225
+ }
226
+
227
+ /** 501 Not Implemented. */
228
+ export class NotImplementedException extends HttpException {
229
+ constructor(message?: string, details?: unknown, options?: HttpExceptionOptions) {
230
+ super(501, message, details, options)
231
+ this.name = 'NotImplementedException'
232
+ }
233
+ }
234
+
235
+ /** 502 Bad Gateway. */
236
+ export class BadGatewayException extends HttpException {
237
+ constructor(message?: string, details?: unknown, options?: HttpExceptionOptions) {
238
+ super(502, message, details, options)
239
+ this.name = 'BadGatewayException'
240
+ }
241
+ }
242
+
243
+ /** 503 Service Unavailable. */
244
+ export class ServiceUnavailableException extends HttpException {
245
+ constructor(message?: string, details?: unknown, options?: HttpExceptionOptions) {
246
+ super(503, message, details, options)
247
+ this.name = 'ServiceUnavailableException'
248
+ }
249
+ }
250
+
251
+ /** 504 Gateway Timeout. */
252
+ export class GatewayTimeoutException extends HttpException {
253
+ constructor(message?: string, details?: unknown, options?: HttpExceptionOptions) {
254
+ super(504, message, details, options)
255
+ this.name = 'GatewayTimeoutException'
256
+ }
257
+ }
package/src/files.ts ADDED
@@ -0,0 +1,429 @@
1
+ /**
2
+ * File downloads (`HttpResponse.file()`): files on disk (sent as lazily read, file-backed `Blob`s:
3
+ * `fs.openAsBlob()`) or `Blob` objects, with `Content-Length`, `Content-Type`, `Last-Modified` /
4
+ * `ETag`, conditional requests (304 / 412), single byte ranges (206 / 416) and RFC 6266
5
+ * `Content-Disposition` headers.
6
+ */
7
+ import type { HeaderValue } from './exceptions.ts'
8
+
9
+ import { openAsBlob } from 'node:fs'
10
+ import { realpath, stat } from 'node:fs/promises'
11
+ import { basename, isAbsolute, relative, resolve } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ import { HttpException, NotFoundException } from './exceptions.ts'
15
+ import { contentTypeFor, OCTET_STREAM } from './mime.ts'
16
+ import { emptyRecord } from './utils.ts'
17
+
18
+ /** How a file is presented to the client (`Content-Disposition`). */
19
+ export type ContentDispositionType = 'attachment' | 'inline'
20
+
21
+ /** Options of `HttpResponse.file()`. */
22
+ export interface FileResponseOptions {
23
+ /**
24
+ * Name proposed to the client in `Content-Disposition` (any Unicode name; path separators are
25
+ * removed). Setting it makes the response an `attachment` unless `disposition` says otherwise.
26
+ */
27
+ readonly filename?: string
28
+ /**
29
+ * Media type of the response. Default: the type of the `Blob`, else looked up from the extension
30
+ * of `filename` / of the path (small built-in table, text types with `charset=utf-8`), else
31
+ * `application/octet-stream`.
32
+ */
33
+ readonly contentType?: string
34
+ /**
35
+ * `attachment` (download) or `inline` (displayed by the browser). Default: `attachment` when
36
+ * `filename` is set, otherwise no `Content-Disposition` header. Without `filename`, the file name
37
+ * (path basename, `File.name`) is used.
38
+ */
39
+ readonly disposition?: ContentDispositionType
40
+ /**
41
+ * Directory the path is resolved in. The resolved file (symbolic links followed) must stay inside
42
+ * it: `..` segments, absolute paths and links escaping the directory answer 404. Always set it
43
+ * when the path comes from the request.
44
+ */
45
+ readonly root?: string
46
+ /**
47
+ * With `root`: whether files or directories whose name starts with a dot (`.env`, `.git/config`)
48
+ * may be sent. Default: `'deny'` (404).
49
+ */
50
+ readonly dotfiles?: 'allow' | 'deny'
51
+ }
52
+
53
+ /**
54
+ * The body of `HttpResponse.file()`: a file on disk or a `Blob`, resolved by the pipeline when the
55
+ * response is sent (stat, conditional request, range).
56
+ */
57
+ export class FileBody {
58
+ /** Path (`string` / `file:` URL) or `Blob` / `File`. */
59
+ readonly source: string | URL | Blob
60
+ readonly options: FileResponseOptions
61
+
62
+ constructor(source: string | URL | Blob, options: FileResponseOptions = {}) {
63
+ if (typeof source !== 'string' && !(source instanceof URL) && !(source instanceof Blob)) {
64
+ throw new TypeError('HttpResponse.file() expects a path (string or file: URL), a Blob or a File')
65
+ }
66
+ if (source instanceof URL && source.protocol !== 'file:') {
67
+ throw new TypeError(`HttpResponse.file() expects a file: URL (received ${source.protocol})`)
68
+ }
69
+ const disposition = options.disposition
70
+ if (disposition !== undefined && disposition !== 'attachment' && disposition !== 'inline') {
71
+ throw new TypeError(`Invalid disposition ${JSON.stringify(disposition)} (expected "attachment" or "inline")`)
72
+ }
73
+ if (options.dotfiles !== undefined && options.dotfiles !== 'allow' && options.dotfiles !== 'deny') {
74
+ throw new TypeError(`Invalid dotfiles ${JSON.stringify(options.dotfiles)} (expected "allow" or "deny")`)
75
+ }
76
+ this.source = source
77
+ this.options = options
78
+ }
79
+ }
80
+
81
+ /** Whether `value` is a {@link FileBody}. */
82
+ export function isFileBody(value: unknown): value is FileBody {
83
+ return value instanceof FileBody
84
+ }
85
+
86
+ /** The request facts a file response depends on. */
87
+ export interface FileRequest {
88
+ readonly method: string
89
+ readonly headers: Readonly<Record<string, string | undefined>>
90
+ }
91
+
92
+ /** A resolved file response (before route headers and status precedence). */
93
+ export interface ResolvedFile {
94
+ /** 200, 206 (range) or 304 (not modified). */
95
+ readonly status: 200 | 206 | 304
96
+ readonly headers: Record<string, HeaderValue>
97
+ /**
98
+ * The bytes to send, as a `Blob` (file-backed for files on disk: read lazily, sent with its size;
99
+ * `sendfile` on Bun); `null` for 304, HEAD requests and empty files.
100
+ */
101
+ readonly body: Blob | null
102
+ }
103
+
104
+ /** Attempts to open a file whose size changes between `stat` and `openAsBlob` (concurrent writes). */
105
+ const OPEN_ATTEMPTS = 3
106
+
107
+ /**
108
+ * Resolves a file response for a request: opens the file, evaluates the preconditions
109
+ * (`If-Match`, `If-Unmodified-Since` → 412; `If-None-Match`, `If-Modified-Since` → 304) and the
110
+ * `Range` header (single range, honouring `If-Range`; 416 when unsatisfiable).
111
+ *
112
+ * @throws NotFoundException (404) when the file does not exist, is not a regular file or escapes `root`;
113
+ * HttpException 412 / 416.
114
+ */
115
+ export function resolveFile(file: FileBody, request: FileRequest): Promise<ResolvedFile> {
116
+ if (!(file.source instanceof Blob)) return resolvePath(file, request)
117
+ try {
118
+ return Promise.resolve(resolveBlob(file, file.source, request))
119
+ } catch (error) {
120
+ return Promise.reject(error)
121
+ }
122
+ }
123
+
124
+ async function resolvePath(file: FileBody, request: FileRequest): Promise<ResolvedFile> {
125
+ const target = await locate(file.source as string | URL, file.options.root, file.options.dotfiles !== 'allow')
126
+ for (let attempt = 1; ; attempt++) {
127
+ let stats: Awaited<ReturnType<typeof stat>>
128
+ try {
129
+ stats = await stat(target)
130
+ } catch (error) {
131
+ throw notFound(error)
132
+ }
133
+ if (!stats.isFile()) throw new NotFoundException('File not found')
134
+ const name = basename(target)
135
+ const plan = planResponse(
136
+ {
137
+ size: stats.size,
138
+ lastModified: stats.mtimeMs,
139
+ contentType: contentTypeOf(file.options, undefined, name),
140
+ disposition: dispositionOf(file.options, name),
141
+ },
142
+ request,
143
+ )
144
+ if (plan.body === undefined) return { ...plan, body: null }
145
+ let blob: Blob
146
+ try {
147
+ blob = await openAsBlob(target)
148
+ } catch (error) {
149
+ throw notFound(error)
150
+ }
151
+ // the file was replaced or written to since `stat`: describe the new content
152
+ if (blob.size !== stats.size) {
153
+ if (attempt < OPEN_ATTEMPTS) continue
154
+ throw new Error(`The file ${name} keeps changing while being sent`)
155
+ }
156
+ return { ...plan, body: slice(blob, plan.body) }
157
+ }
158
+ }
159
+
160
+ function resolveBlob(file: FileBody, blob: Blob, request: FileRequest): ResolvedFile {
161
+ const name = blob instanceof File && blob.name !== '' ? blob.name : undefined
162
+ const info: Representation = {
163
+ size: blob.size,
164
+ lastModified: blob instanceof File ? blob.lastModified : undefined,
165
+ contentType: contentTypeOf(file.options, blob.type, name),
166
+ disposition: dispositionOf(file.options, name),
167
+ }
168
+ const plan = planResponse(info, request)
169
+ return { ...plan, body: plan.body === undefined ? null : slice(blob, plan.body) }
170
+ }
171
+
172
+ /** The bytes `start..end` (inclusive) of a blob (the blob itself when complete). */
173
+ function slice(blob: Blob, { start, end }: ByteRange): Blob {
174
+ return start === 0 && end === blob.size - 1 ? blob : blob.slice(start, end + 1)
175
+ }
176
+
177
+ /** Resolves the path to open, enforcing `root` (and hiding dotfiles inside it). */
178
+ async function locate(source: string | URL, root: string | undefined, hideDotfiles: boolean): Promise<string> {
179
+ const path = source instanceof URL ? fileURLToPath(source) : source
180
+ if (path === '' || path.includes('\0')) throw new NotFoundException('File not found')
181
+ if (root === undefined) return resolve(path)
182
+ const base = resolve(root)
183
+ const target = resolve(base, path)
184
+ if (!isInside(base, target, hideDotfiles)) throw new NotFoundException('File not found')
185
+ // symbolic links: the real file must be inside the real root
186
+ let realBase: string
187
+ let realTarget: string
188
+ try {
189
+ ;[realBase, realTarget] = await Promise.all([realpath(base), realpath(target)])
190
+ } catch (error) {
191
+ throw notFound(error)
192
+ }
193
+ if (!isInside(realBase, realTarget, hideDotfiles)) throw new NotFoundException('File not found')
194
+ return realTarget
195
+ }
196
+
197
+ function isInside(base: string, target: string, hideDotfiles: boolean): boolean {
198
+ const path = relative(base, target)
199
+ if (path === '' || isAbsolute(path)) return false
200
+ const segments = path.split(/[\\/]/)
201
+ if (segments[0] === '..') return false
202
+ return !hideDotfiles || !segments.some(segment => segment.startsWith('.'))
203
+ }
204
+
205
+ const MISSING = new Set(['ENOENT', 'ENOTDIR', 'EISDIR', 'ENAMETOOLONG', 'ELOOP', 'ERR_INVALID_ARG_VALUE'])
206
+
207
+ function notFound(error: unknown): unknown {
208
+ const code = (error as { code?: unknown } | null)?.code
209
+ return typeof code === 'string' && MISSING.has(code)
210
+ ? new NotFoundException('File not found', undefined, { cause: error })
211
+ : error
212
+ }
213
+
214
+ // -------------------------------------------------------------------------------------------------
215
+ // Representation metadata, preconditions and ranges
216
+ // -------------------------------------------------------------------------------------------------
217
+
218
+ interface Representation {
219
+ readonly size: number
220
+ /** Milliseconds since the epoch, when known. */
221
+ readonly lastModified: number | undefined
222
+ readonly contentType: string
223
+ readonly disposition: string | undefined
224
+ }
225
+
226
+ interface Plan {
227
+ readonly status: 200 | 206 | 304
228
+ readonly headers: Record<string, HeaderValue>
229
+ /** Inclusive byte range to send; `undefined`: no body (304, HEAD, empty file). */
230
+ readonly body: { readonly start: number; readonly end: number } | undefined
231
+ }
232
+
233
+ function planResponse(info: Representation, request: FileRequest): Plan {
234
+ const validators = emptyRecord<HeaderValue>()
235
+ const etag = info.lastModified === undefined ? undefined : entityTag(info.size, info.lastModified)
236
+ const seconds = info.lastModified === undefined ? undefined : Math.floor(info.lastModified / 1000)
237
+ if (etag !== undefined) validators['etag'] = etag
238
+ if (seconds !== undefined) validators['last-modified'] = new Date(seconds * 1000).toUTCString()
239
+
240
+ const precondition = evaluatePreconditions(request, etag, seconds)
241
+ if (precondition === 412) throw new HttpException(412, 'Precondition Failed', undefined, { headers: validators })
242
+ if (precondition === 304) return { status: 304, headers: validators, body: undefined }
243
+
244
+ const headers = emptyRecord<HeaderValue>()
245
+ headers['content-type'] = info.contentType
246
+ if (info.disposition !== undefined) headers['content-disposition'] = info.disposition
247
+ headers['accept-ranges'] = 'bytes'
248
+ Object.assign(headers, validators)
249
+
250
+ const range = request.method === 'GET' ? requestedRange(request.headers, info.size, etag, seconds) : undefined
251
+ if (range === 'unsatisfiable') {
252
+ throw new HttpException(416, `Range Not Satisfiable (size: ${info.size} bytes)`, undefined, {
253
+ headers: { 'content-range': `bytes */${info.size}`, 'accept-ranges': 'bytes' },
254
+ })
255
+ }
256
+ if (range !== undefined) {
257
+ headers['content-range'] = `bytes ${range.start}-${range.end}/${info.size}`
258
+ headers['content-length'] = String(range.end - range.start + 1)
259
+ return { status: 206, headers, body: request.method === 'HEAD' ? undefined : range }
260
+ }
261
+ headers['content-length'] = String(info.size)
262
+ const body = request.method === 'HEAD' || info.size === 0 ? undefined : { start: 0, end: info.size - 1 }
263
+ return { status: 200, headers, body }
264
+ }
265
+
266
+ /** Strong entity tag from the size and modification time (like nginx: `"<mtime>-<size>"`, hex). */
267
+ function entityTag(size: number, lastModified: number): string {
268
+ return `"${Math.floor(lastModified).toString(16)}-${size.toString(16)}"`
269
+ }
270
+
271
+ /** RFC 9110 §13.2.2: 412, 304 or `undefined` (proceed). */
272
+ function evaluatePreconditions(
273
+ request: FileRequest,
274
+ etag: string | undefined,
275
+ seconds: number | undefined,
276
+ ): 304 | 412 | undefined {
277
+ const headers = request.headers
278
+ const safe = request.method === 'GET' || request.method === 'HEAD'
279
+ const ifMatch = headers['if-match']
280
+ if (ifMatch === undefined) {
281
+ const since = httpDate(headers['if-unmodified-since'])
282
+ if (since !== undefined && seconds !== undefined && seconds > since) return 412
283
+ } else {
284
+ if (!matchesEntityTag(ifMatch, etag, true)) return 412
285
+ }
286
+ const ifNoneMatch = headers['if-none-match']
287
+ if (ifNoneMatch !== undefined) {
288
+ if (matchesEntityTag(ifNoneMatch, etag, false)) return safe ? 304 : 412
289
+ } else if (safe) {
290
+ const since = httpDate(headers['if-modified-since'])
291
+ if (since !== undefined && seconds !== undefined && seconds <= since) return 304
292
+ }
293
+ return undefined
294
+ }
295
+
296
+ /** Whether an `If-Match` / `If-None-Match` list matches `etag` (`*` matches any existing file). */
297
+ function matchesEntityTag(header: string, etag: string | undefined, strong: boolean): boolean {
298
+ if (header.trim() === '*') return true
299
+ if (etag === undefined) return false
300
+ for (const raw of header.split(',')) {
301
+ const candidate = raw.trim()
302
+ if (candidate === '') continue
303
+ if (strong) {
304
+ if (candidate === etag) return true
305
+ } else if (stripWeak(candidate) === stripWeak(etag)) {
306
+ return true
307
+ }
308
+ }
309
+ return false
310
+ }
311
+
312
+ function stripWeak(tag: string): string {
313
+ return tag.startsWith('W/') ? tag.slice(2) : tag
314
+ }
315
+
316
+ /** Seconds of an HTTP date, `undefined` when absent or invalid. */
317
+ function httpDate(value: string | undefined): number | undefined {
318
+ if (value === undefined) return undefined
319
+ const time = Date.parse(value)
320
+ return Number.isNaN(time) ? undefined : Math.floor(time / 1000)
321
+ }
322
+
323
+ /** The range to send for a `Range` header (with `If-Range`), `undefined` for the whole file. */
324
+ function requestedRange(
325
+ headers: Readonly<Record<string, string | undefined>>,
326
+ size: number,
327
+ etag: string | undefined,
328
+ seconds: number | undefined,
329
+ ): ByteRange | 'unsatisfiable' | undefined {
330
+ const header = headers['range']
331
+ if (header === undefined) return undefined
332
+ const ifRange = headers['if-range']?.trim()
333
+ if (ifRange !== undefined) {
334
+ const isTag = ifRange.startsWith('"') || ifRange.startsWith('W/')
335
+ // an entity tag must match strongly; a date must be the exact modification date
336
+ const fresh = isTag
337
+ ? etag !== undefined && ifRange === etag
338
+ : seconds !== undefined && httpDate(ifRange) === seconds
339
+ if (!fresh) return undefined
340
+ }
341
+ return parseRange(header, size)
342
+ }
343
+
344
+ /** An inclusive byte range. */
345
+ export interface ByteRange {
346
+ readonly start: number
347
+ readonly end: number
348
+ }
349
+
350
+ /**
351
+ * Parses a `Range` header for a representation of `size` bytes. Only a single `bytes` range is
352
+ * honoured: other units, several ranges and malformed values return `undefined` (the whole
353
+ * representation is sent, as RFC 9110 allows); a valid range starting after the end returns
354
+ * `'unsatisfiable'` (416).
355
+ */
356
+ export function parseRange(header: string, size: number): ByteRange | 'unsatisfiable' | undefined {
357
+ const equals = header.indexOf('=')
358
+ if (equals === -1 || header.slice(0, equals).trim().toLowerCase() !== 'bytes') return undefined
359
+ const spec = header.slice(equals + 1)
360
+ if (spec.includes(',')) return undefined
361
+ const match = /^\s*(\d*)\s*-\s*(\d*)\s*$/.exec(spec)
362
+ if (match === null) return undefined
363
+ const [, first = '', last = ''] = match
364
+ if (first === '' && last === '') return undefined
365
+ if (first === '') {
366
+ const suffix = Number(last)
367
+ if (!Number.isSafeInteger(suffix)) return undefined
368
+ if (suffix === 0 || size === 0) return 'unsatisfiable'
369
+ return { start: Math.max(0, size - suffix), end: size - 1 }
370
+ }
371
+ const start = Number(first)
372
+ if (!Number.isSafeInteger(start)) return undefined
373
+ if (last === '') return start >= size ? 'unsatisfiable' : { start, end: size - 1 }
374
+ const end = Number(last)
375
+ if (!Number.isSafeInteger(end) || end < start) return undefined
376
+ return start >= size ? 'unsatisfiable' : { start, end: Math.min(end, size - 1) }
377
+ }
378
+
379
+ // -------------------------------------------------------------------------------------------------
380
+ // Headers
381
+ // -------------------------------------------------------------------------------------------------
382
+
383
+ function contentTypeOf(options: FileResponseOptions, blobType: string | undefined, name: string | undefined): string {
384
+ if (options.contentType !== undefined) return options.contentType
385
+ if (blobType !== undefined && blobType !== '') return blobType
386
+ return (
387
+ (options.filename === undefined ? undefined : contentTypeFor(options.filename)) ??
388
+ (name === undefined ? undefined : contentTypeFor(name)) ??
389
+ OCTET_STREAM
390
+ )
391
+ }
392
+
393
+ function dispositionOf(options: FileResponseOptions, name: string | undefined): string | undefined {
394
+ const type = options.disposition ?? (options.filename === undefined ? undefined : 'attachment')
395
+ if (type === undefined) return undefined
396
+ return contentDisposition(options.filename ?? name, type)
397
+ }
398
+
399
+ /** Characters that need no percent-encoding in an RFC 8187 `ext-value` (`attr-char`). */
400
+ const ATTR_CHAR = /^[A-Za-z0-9!#$&+\-.^_`|~]$/
401
+
402
+ /**
403
+ * Builds a `Content-Disposition` header value (RFC 6266): `attachment; filename="report.pdf"`, with
404
+ * an RFC 8187 / 5987 `filename*` parameter for names that are not plain ASCII
405
+ * (`attachment; filename="r_sum_.pdf"; filename*=UTF-8''r%C3%A9sum%C3%A9.pdf`). Directory parts and
406
+ * control characters of `filename` are removed.
407
+ */
408
+ export function contentDisposition(filename?: string, type: ContentDispositionType = 'attachment'): string {
409
+ if (type !== 'attachment' && type !== 'inline') {
410
+ throw new TypeError(`Invalid disposition ${JSON.stringify(type)} (expected "attachment" or "inline")`)
411
+ }
412
+ if (filename === undefined) return type
413
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are removed on purpose
414
+ const name = filename.replace(/^.*[\\/]/, '').replace(/[\u0000-\u001f\u007f]/g, '')
415
+ if (name === '') return type
416
+ const fallback = name.replace(/[^\u0020-\u007e]/g, '_').replace(/%/g, '_')
417
+ let value = `${type}; filename="${fallback.replace(/["\\]/g, '\\$&')}"`
418
+ if (fallback !== name) value += `; filename*=UTF-8''${encodeExtValue(name)}`
419
+ return value
420
+ }
421
+
422
+ function encodeExtValue(value: string): string {
423
+ let result = ''
424
+ for (const byte of new TextEncoder().encode(value)) {
425
+ const char = String.fromCharCode(byte)
426
+ result += byte < 0x80 && ATTR_CHAR.test(char) ? char : `%${byte.toString(16).toUpperCase().padStart(2, '0')}`
427
+ }
428
+ return result
429
+ }