@znight/http 0.0.0-stage → 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 +2333 -2
- package/dist/adapter-CGSceVEN.d.mts +876 -0
- package/dist/compiler.d.mts +156 -0
- package/dist/compiler.mjs +2180 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/express.d.mts +39 -0
- package/dist/express.mjs +135 -0
- package/dist/express.mjs.map +1 -0
- package/dist/fastify.d.mts +33 -0
- package/dist/fastify.mjs +191 -0
- package/dist/fastify.mjs.map +1 -0
- package/dist/hono.d.mts +49 -0
- package/dist/hono.mjs +319 -0
- package/dist/hono.mjs.map +1 -0
- package/dist/index.d.mts +1254 -0
- package/dist/index.mjs +5618 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-BUnkdSsa.mjs +183 -0
- package/dist/node-BUnkdSsa.mjs.map +1 -0
- package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
- package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
- package/dist/node.d.mts +30 -0
- package/dist/node.mjs +1 -0
- package/dist/path-JI87C4k8.d.mts +109 -0
- package/dist/path.d.mts +2 -0
- package/dist/path.mjs +245 -0
- package/dist/path.mjs.map +1 -0
- package/dist/size-D8rsQcV0.mjs +184 -0
- package/dist/size-D8rsQcV0.mjs.map +1 -0
- package/dist/utils-DCbu3a4w.mjs +27 -0
- package/dist/utils-DCbu3a4w.mjs.map +1 -0
- package/package.json +142 -4
- package/src/adapter.ts +240 -0
- package/src/adapters/express.ts +201 -0
- package/src/adapters/fastify.ts +280 -0
- package/src/adapters/hono.ts +488 -0
- package/src/adapters/node-io.ts +368 -0
- package/src/adapters/node.ts +135 -0
- package/src/adapters/shared.ts +508 -0
- package/src/body.ts +135 -0
- package/src/coerce.ts +195 -0
- package/src/compiler/analyze.ts +1513 -0
- package/src/compiler/ast.ts +90 -0
- package/src/compiler/coercion.ts +267 -0
- package/src/compiler/generate.ts +374 -0
- package/src/compiler/index.ts +76 -0
- package/src/compiler/model.ts +260 -0
- package/src/compiler/route-decorators.ts +405 -0
- package/src/compiler/validate.ts +288 -0
- package/src/context.ts +533 -0
- package/src/cookie-settings.ts +68 -0
- package/src/cookie-signer.ts +106 -0
- package/src/cookies.ts +726 -0
- package/src/cors.ts +349 -0
- package/src/decisions.ts +331 -0
- package/src/decorators.ts +486 -0
- package/src/event-channel.ts +197 -0
- package/src/exceptions.ts +257 -0
- package/src/files.ts +429 -0
- package/src/headers.ts +39 -0
- package/src/index.ts +185 -0
- package/src/inject-sse.ts +309 -0
- package/src/interfaces.ts +101 -0
- package/src/metadata.ts +162 -0
- package/src/mime.ts +157 -0
- package/src/multipart.ts +597 -0
- package/src/option-error.ts +31 -0
- package/src/path.ts +288 -0
- package/src/pipeline.ts +918 -0
- package/src/plugin-routes.ts +188 -0
- package/src/plugin.ts +1432 -0
- package/src/proxy.ts +236 -0
- package/src/query.ts +30 -0
- package/src/response.ts +240 -0
- package/src/router.ts +119 -0
- package/src/security-headers.ts +161 -0
- package/src/serialize.ts +264 -0
- package/src/size.ts +66 -0
- package/src/sse-parse.ts +194 -0
- package/src/sse.ts +650 -0
- package/src/standard-schema.ts +81 -0
- package/src/status.ts +78 -0
- package/src/stream.ts +200 -0
- package/src/tokens.ts +21 -0
- package/src/uploads.ts +176 -0
- package/src/utils.ts +31 -0
- 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
|
+
}
|