koa-files 5.0.0 → 5.0.2

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/README.md CHANGED
@@ -15,11 +15,32 @@
15
15
  $ npm install koa-files
16
16
  ```
17
17
 
18
+ ## Quick start
19
+
20
+ ```ts
21
+ import Koa from 'koa';
22
+ import { server } from 'koa-files';
23
+
24
+ const app = new Koa();
25
+
26
+ app.use(
27
+ server('public', {
28
+ headers: {
29
+ 'Cache-Control': 'public, max-age=31536000, immutable'
30
+ }
31
+ })
32
+ );
33
+
34
+ app.listen(3000);
35
+ ```
36
+
37
+ Then visit `http://127.0.0.1:3000/app.js` to serve `public/app.js`.
38
+
18
39
  ## API
19
40
 
20
41
  ```ts
21
42
  import { Middleware } from 'koa';
22
- import fs, { Stats } from 'node:fs';
43
+ import { PathLike, Stats } from 'node:fs';
23
44
 
24
45
  interface Headers {
25
46
  [key: string]: string | string[];
@@ -38,10 +59,21 @@ interface HeadersFunction {
38
59
  }
39
60
 
40
61
  export interface FileSystem {
41
- stat: typeof fs.stat;
42
- open: typeof fs.open;
43
- read: typeof fs.read;
44
- close: typeof fs.close;
62
+ close(fd: number, callback?: (error?: Error | null) => void): void;
63
+ read<T extends ArrayBufferView>(
64
+ fd: number,
65
+ buffer: T,
66
+ offset: number,
67
+ length: number,
68
+ position: number | bigint | null,
69
+ callback: (error: Error | null | undefined, bytesRead: number, buffer: T) => void
70
+ ): void;
71
+ stat(path: PathLike, callback: (error: Error | null | undefined, stats: Stats) => void): void;
72
+ open(
73
+ path: PathLike,
74
+ flags: string | number | undefined,
75
+ callback: (error: Error | null | undefined, fd: number) => void
76
+ ): void;
45
77
  }
46
78
 
47
79
  export interface Options {
@@ -62,6 +94,7 @@ export function server(root: string, options?: Options): Middleware;
62
94
 
63
95
  - Root directory string.
64
96
  - Nothing above this root directory can be served.
97
+ - `root` is resolved with `path.resolve`, so relative paths are based on the process working directory.
65
98
 
66
99
  ### Options
67
100
 
@@ -75,6 +108,7 @@ export function server(root: string, options?: Options): Middleware;
75
108
  - Defaults to `false`.
76
109
  - If true, serves after `await next()`.
77
110
  - Allowing any downstream middleware to respond first.
111
+ - Useful when you want route handlers to take priority over static files.
78
112
 
79
113
  ##### `etag`
80
114
 
@@ -101,17 +135,28 @@ export function server(root: string, options?: Options): Middleware;
101
135
 
102
136
  - Defaults to `65536` (64 KiB).
103
137
  - Set the high water mark for the read stream.
138
+ - Supports function form to customize by file path and stats.
104
139
 
105
140
  ##### `ignore`
106
141
 
107
142
  - Defaults to `undefined`.
108
143
  - Function that determines if a file should be ignored.
144
+ - Return `true` to skip static serving for the current file.
109
145
 
110
146
  ##### `headers`
111
147
 
112
148
  - Defaults to `undefined`.
113
149
  - Set headers to be sent.
114
150
  - See docs: [Headers in MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers).
151
+ - Supports function form to compute headers dynamically.
152
+
153
+ ### Behavior notes
154
+
155
+ - Only `GET` and `HEAD` requests are handled.
156
+ - `Range` requests are supported (including multipart range responses).
157
+ - Conditional requests are supported via `ETag` and `Last-Modified`.
158
+ - Requests containing invalid paths (e.g. null bytes) respond with `400`.
159
+ - Files outside `root` are never served.
115
160
 
116
161
  ## Example
117
162
 
@@ -157,9 +202,48 @@ app.listen(port, () => {
157
202
  });
158
203
  ```
159
204
 
205
+ ## More examples
206
+
207
+ ### Prefer application routes first (`defer: true`)
208
+
209
+ ```ts
210
+ app.use(async (ctx, next) => {
211
+ if (ctx.path === '/healthz') {
212
+ ctx.body = 'ok';
213
+ return;
214
+ }
215
+
216
+ await next();
217
+ });
218
+
219
+ app.use(server('public', { defer: true }));
220
+ ```
221
+
222
+ ### Dynamic ignore rule
223
+
224
+ ```ts
225
+ app.use(
226
+ server('public', {
227
+ ignore: path => path.endsWith('.map')
228
+ })
229
+ );
230
+ ```
231
+
232
+ ### Dynamic stream buffer
233
+
234
+ ```ts
235
+ app.use(
236
+ server('public', {
237
+ highWaterMark: (path, stats) => (stats.size > 10 * 1024 * 1024 ? 256 * 1024 : 64 * 1024)
238
+ })
239
+ );
240
+ ```
241
+
160
242
  ## Features
161
243
 
162
- Support multipart range and download resumption.
244
+ - Static file middleware for Koa.
245
+ - Supports multipart range and download resumption.
246
+ - Supports conditional requests (`ETag`, `Last-Modified`).
163
247
 
164
248
  ## License
165
249
 
@@ -2,7 +2,7 @@
2
2
  * @module koa-files
3
3
  * @package koa-files
4
4
  * @license MIT
5
- * @version 5.0.0
5
+ * @version 5.0.2
6
6
  * @author nuintun <nuintun@qq.com>
7
7
  * @description A static files serving middleware for koa.
8
8
  * @see https://github.com/nuintun/koa-files#readme
@@ -99,12 +99,12 @@ class ReadStream extends node_stream.Readable {
99
99
  // Change read state.
100
100
  switch (this.#readState) {
101
101
  case 0 /* ReadState.PREFIX */:
102
- this.#readState = 1 /* ReadState.RANGE */;
102
+ this.#readState = 1; /* ReadState.RANGE */
103
103
  this.#readFileRange(fd, range, size - bytesRead);
104
104
  break;
105
105
  case 2 /* ReadState.SUFFIX */:
106
106
  this.#currentRangeIndex++;
107
- this.#readState = 0 /* ReadState.PREFIX */;
107
+ this.#readState = 0; /* ReadState.PREFIX */
108
108
  const nextRange = this.#getRange();
109
109
  if (nextRange == null) {
110
110
  this.push(null);
@@ -147,14 +147,14 @@ class ReadStream extends node_stream.Readable {
147
147
  // file range read completed.
148
148
  if (bytesRead < size) {
149
149
  this.#bytesRead = 0;
150
- this.#readState = 2 /* ReadState.SUFFIX */;
150
+ this.#readState = 2; /* ReadState.SUFFIX */
151
151
  this.#readPadding(fd, range, size - bytesRead);
152
152
  }
153
153
  }
154
154
  });
155
155
  } else {
156
156
  this.#bytesRead = 0;
157
- this.#readState = 2 /* ReadState.SUFFIX */;
157
+ this.#readState = 2; /* ReadState.SUFFIX */
158
158
  this.#readPadding(fd, range, size);
159
159
  }
160
160
  }
package/cjs/Service.cjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * @module koa-files
3
3
  * @package koa-files
4
4
  * @license MIT
5
- * @version 5.0.0
5
+ * @version 5.0.2
6
6
  * @author nuintun <nuintun@qq.com>
7
7
  * @description A static files serving middleware for koa.
8
8
  * @see https://github.com/nuintun/koa-files#readme
@@ -10,7 +10,6 @@
10
10
 
11
11
  'use strict';
12
12
 
13
- const createETag = require('etag');
14
13
  const fs = require('node:fs');
15
14
  const ReadStream = require('./ReadStream.cjs');
16
15
  const type = require('./utils/type.cjs');
@@ -23,7 +22,6 @@ function _interopDefault(e) {
23
22
  return e && e.__esModule ? e : { default: e };
24
23
  }
25
24
 
26
- const createETag__default = /*#__PURE__*/ _interopDefault(createETag);
27
25
  const fs__default = /*#__PURE__*/ _interopDefault(fs);
28
26
 
29
27
  /**
@@ -86,7 +84,7 @@ class Service {
86
84
  response.remove('ETag');
87
85
  } else if (!response.get('ETag')) {
88
86
  // Set weak ETag.
89
- response.set('ETag', createETag__default.default(stats));
87
+ response.set('ETag', http.createETag(stats));
90
88
  }
91
89
  // Last-Modified.
92
90
  if (options.lastModified === false) {
@@ -165,10 +163,8 @@ class Service {
165
163
  }
166
164
  // Head request.
167
165
  if (method === 'HEAD') {
168
- // Set Content-Length.
166
+ // Set Content-Length, koa keeps it and sends no body for HEAD.
169
167
  response.length = stats.size;
170
- // Set body null
171
- response.body = null;
172
168
  // File found.
173
169
  return true;
174
170
  }
@@ -176,14 +172,12 @@ class Service {
176
172
  const ranges = http.parseRanges(context, stats);
177
173
  // 416
178
174
  if (ranges === -1) {
179
- // Set Content-Range.
180
- response.set('Content-Range', `bytes */${stats.size}`);
181
- // Unsatisfiable 416.
182
- return context.throw(416);
183
- }
184
- // 400.
185
- if (ranges === -2) {
186
- return context.throw(400);
175
+ // koa strips all response headers on error, pass Content-Range via err.headers.
176
+ return context.throw(416, {
177
+ headers: {
178
+ 'Content-Range': `bytes */${stats.size}`
179
+ }
180
+ });
187
181
  }
188
182
  // Set response body.
189
183
  response.body = new ReadStream.ReadStream(path$1, ranges, {
package/cjs/index.cjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * @module koa-files
3
3
  * @package koa-files
4
4
  * @license MIT
5
- * @version 5.0.0
5
+ * @version 5.0.2
6
6
  * @author nuintun <nuintun@qq.com>
7
7
  * @description A static files serving middleware for koa.
8
8
  * @see https://github.com/nuintun/koa-files#readme
package/cjs/utils/fs.cjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * @module koa-files
3
3
  * @package koa-files
4
4
  * @license MIT
5
- * @version 5.0.0
5
+ * @version 5.0.2
6
6
  * @author nuintun <nuintun@qq.com>
7
7
  * @description A static files serving middleware for koa.
8
8
  * @see https://github.com/nuintun/koa-files#readme
@@ -2,7 +2,7 @@
2
2
  * @module koa-files
3
3
  * @package koa-files
4
4
  * @license MIT
5
- * @version 5.0.0
5
+ * @version 5.0.2
6
6
  * @author nuintun <nuintun@qq.com>
7
7
  * @description A static files serving middleware for koa.
8
8
  * @see https://github.com/nuintun/koa-files#readme
@@ -10,9 +10,9 @@
10
10
 
11
11
  'use strict';
12
12
 
13
- const hash = require('./hash.cjs');
14
13
  const node_buffer = require('node:buffer');
15
14
  const parseRange = require('range-parser');
15
+ const node_crypto = require('node:crypto');
16
16
 
17
17
  function _interopDefault(e) {
18
18
  return e && e.__esModule ? e : { default: e };
@@ -24,6 +24,7 @@ const parseRange__default = /*#__PURE__*/ _interopDefault(parseRange);
24
24
  * @module http
25
25
  */
26
26
  const SPLIT_ETAG_RE = /\s*,\s*/;
27
+ const BOUNDARY_SCRATCH = node_buffer.Buffer.allocUnsafeSlow(24);
27
28
  const SINGLE_ETAG_RE = /^(?:W\/)?"[ !#-\x7E\x80-\xFF]+"$/;
28
29
  /**
29
30
  * @function decodeURI
@@ -37,6 +38,13 @@ function decodeURI(URI) {
37
38
  return -1;
38
39
  }
39
40
  }
41
+ /**
42
+ * @function generateBoundary
43
+ * @description Generate a boundary string.
44
+ */
45
+ function generateBoundary() {
46
+ return node_crypto.getRandomValues(BOUNDARY_SCRATCH).toString('base64url');
47
+ }
40
48
  /**
41
49
  * @function isConditionalGET
42
50
  * @description Check if request is conditional GET.
@@ -50,6 +58,14 @@ function isConditionalGET({ request }) {
50
58
  request.get('If-Unmodified-Since')
51
59
  );
52
60
  }
61
+ /**
62
+ * @function createETag
63
+ * @description Generate a weak entity tag from file stats.
64
+ * @param stats The file stats.
65
+ */
66
+ function createETag(stats) {
67
+ return `W/"${stats.size.toString(16)}-${stats.mtime.getTime().toString(16)}"`;
68
+ }
53
69
  /**
54
70
  * @function isETagMatch
55
71
  * @see https://httpwg.org/specs/rfc9110.html
@@ -58,25 +74,45 @@ function isConditionalGET({ request }) {
58
74
  * @param isIfMatch The flag of if-match.
59
75
  */
60
76
  function isETagMatch(match, etag, isIfMatch) {
77
+ // Trim match.
78
+ match = match.trim();
79
+ // Check If-Match.
80
+ if (isIfMatch && match === '*') {
81
+ // The condition is true if the selected representation exists.
82
+ return true;
83
+ }
61
84
  // Trim etag.
62
85
  etag = etag.trim();
63
86
  // Weak tags cannot be matched and return false.
64
87
  if (!etag || etag.startsWith('W/')) {
65
88
  return false;
66
89
  }
67
- // Trim match.
68
- match = match.trim();
69
90
  // Check If-Match.
70
91
  if (isIfMatch) {
71
- if (match === '*') {
72
- return true;
73
- }
74
92
  // If-Match maybe a list of etag.
75
93
  return match.split(SPLIT_ETAG_RE).includes(etag);
76
94
  }
77
95
  // Check If-Range.
78
96
  return match === etag;
79
97
  }
98
+ /**
99
+ * @function isRangeFresh
100
+ * @description Check if request range fresh.
101
+ * @param context Koa context.
102
+ */
103
+ function isRangeFresh({ request, response }) {
104
+ const ifRange = request.get('If-Range');
105
+ // No If-Range.
106
+ if (!ifRange) {
107
+ return true;
108
+ }
109
+ // If-Range as modified date failed.
110
+ if (SINGLE_ETAG_RE.test(ifRange)) {
111
+ return isETagMatch(ifRange, response.get('ETag'));
112
+ }
113
+ // Check if Last-Modified is valid and equal to If-Range date
114
+ return Date.parse(response.get('Last-Modified')) <= Date.parse(ifRange);
115
+ }
80
116
  /**
81
117
  * @function isPreconditionFailed
82
118
  * @description Check if request precondition failed.
@@ -99,24 +135,6 @@ function isPreconditionFailed({ request, response }) {
99
135
  // Check precondition passed.
100
136
  return false;
101
137
  }
102
- /**
103
- * @function isRangeFresh
104
- * @description Check if request range fresh.
105
- * @param context Koa context.
106
- */
107
- function isRangeFresh({ request, response }) {
108
- const ifRange = request.get('If-Range');
109
- // No If-Range.
110
- if (!ifRange) {
111
- return true;
112
- }
113
- // If-Range as modified date failed.
114
- if (SINGLE_ETAG_RE.test(ifRange)) {
115
- return isETagMatch(ifRange, response.get('ETag'));
116
- }
117
- // Check if Last-Modified is valid and equal to If-Range date
118
- return Date.parse(response.get('Last-Modified')) <= Date.parse(ifRange);
119
- }
120
138
  /**
121
139
  * @function parseRanges
122
140
  * @description Parse ranges.
@@ -134,10 +152,16 @@ function parseRanges(context, stats) {
134
152
  // Parse range -1 -2 or [].
135
153
  const parsed = parseRange__default.default(size, range, { combine: true });
136
154
  // -1 signals an unsatisfiable range.
137
- // -2 signals a malformed header string.
138
- if (parsed === -1 || parsed === -2) {
155
+ if (parsed === -1) {
139
156
  return parsed;
140
157
  }
158
+ // -2 signals a malformed or unknown-unit range, ignore it (RFC 9110 §14.2).
159
+ if (parsed === -2) {
160
+ // Set Content-Length.
161
+ response.length = size;
162
+ // Return ranges.
163
+ return [{ offset: 0, length: size }];
164
+ }
141
165
  // Ranges ok, support multiple ranges.
142
166
  if (parsed.type === 'bytes') {
143
167
  // Set 206 status.
@@ -152,7 +176,7 @@ function parseRanges(context, stats) {
152
176
  // Parsed entries.
153
177
  const entries = parsed.entries();
154
178
  // Range boundary.
155
- const boundary = `${hash.generate(32)}`;
179
+ const boundary = generateBoundary();
156
180
  // Multipart Content-Type.
157
181
  const contentType = `Content-Type: ${response.type}`;
158
182
  // Range suffix.
@@ -165,9 +189,7 @@ function parseRanges(context, stats) {
165
189
  const length = end - start + 1;
166
190
  const head = index > 0 ? '\r\n' : '';
167
191
  const contentRange = `Content-Range: bytes ${start}-${end}/${size}`;
168
- const prefix = node_buffer.Buffer.from(
169
- `${head}--${boundary}\r\n${contentType}\r\n${contentRange}\r\n\r\n`
170
- );
192
+ const prefix = node_buffer.Buffer.from(`${head}--${boundary}\r\n${contentType}\r\n${contentRange}\r\n\r\n`);
171
193
  // Compute Content-Length
172
194
  contentLength += prefix.length + length;
173
195
  // Cache range.
@@ -200,7 +222,11 @@ function parseRanges(context, stats) {
200
222
  return [{ offset: 0, length: size }];
201
223
  }
202
224
 
225
+ exports.createETag = createETag;
203
226
  exports.decodeURI = decodeURI;
227
+ exports.generateBoundary = generateBoundary;
204
228
  exports.isConditionalGET = isConditionalGET;
229
+ exports.isETagMatch = isETagMatch;
205
230
  exports.isPreconditionFailed = isPreconditionFailed;
231
+ exports.isRangeFresh = isRangeFresh;
206
232
  exports.parseRanges = parseRanges;
@@ -16,12 +16,37 @@ export interface Range {
16
16
  * @param URI The URI to decode.
17
17
  */
18
18
  export declare function decodeURI(URI: string): string | -1;
19
+ /**
20
+ * @function generateBoundary
21
+ * @description Generate a boundary string.
22
+ */
23
+ export declare function generateBoundary(): string;
19
24
  /**
20
25
  * @function isConditionalGET
21
26
  * @description Check if request is conditional GET.
22
27
  * @param context The koa context.
23
28
  */
24
29
  export declare function isConditionalGET({ request }: Context): boolean;
30
+ /**
31
+ * @function createETag
32
+ * @description Generate a weak entity tag from file stats.
33
+ * @param stats The file stats.
34
+ */
35
+ export declare function createETag(stats: Stats): string;
36
+ /**
37
+ * @function isETagMatch
38
+ * @see https://httpwg.org/specs/rfc9110.html
39
+ * @param match The match value.
40
+ * @param etag The etag value.
41
+ * @param isIfMatch The flag of if-match.
42
+ */
43
+ export declare function isETagMatch(match: string, etag: string, isIfMatch?: boolean): boolean;
44
+ /**
45
+ * @function isRangeFresh
46
+ * @description Check if request range fresh.
47
+ * @param context Koa context.
48
+ */
49
+ export declare function isRangeFresh({ request, response }: Context): boolean;
25
50
  /**
26
51
  * @function isPreconditionFailed
27
52
  * @description Check if request precondition failed.
@@ -34,4 +59,4 @@ export declare function isPreconditionFailed({ request, response }: Context): bo
34
59
  * @param context The koa context.
35
60
  * @param stats The file stats.
36
61
  */
37
- export declare function parseRanges(context: Context, stats: Stats): -1 | -2 | Range[];
62
+ export declare function parseRanges(context: Context, stats: Stats): -1 | Range[];
@@ -2,7 +2,7 @@
2
2
  * @module koa-files
3
3
  * @package koa-files
4
4
  * @license MIT
5
- * @version 5.0.0
5
+ * @version 5.0.2
6
6
  * @author nuintun <nuintun@qq.com>
7
7
  * @description A static files serving middleware for koa.
8
8
  * @see https://github.com/nuintun/koa-files#readme
@@ -2,7 +2,7 @@
2
2
  * @module koa-files
3
3
  * @package koa-files
4
4
  * @license MIT
5
- * @version 5.0.0
5
+ * @version 5.0.2
6
6
  * @author nuintun <nuintun@qq.com>
7
7
  * @description A static files serving middleware for koa.
8
8
  * @see https://github.com/nuintun/koa-files#readme
package/esm/ReadStream.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * @module koa-files
3
3
  * @package koa-files
4
4
  * @license MIT
5
- * @version 5.0.0
5
+ * @version 5.0.2
6
6
  * @author nuintun <nuintun@qq.com>
7
7
  * @description A static files serving middleware for koa.
8
8
  * @see https://github.com/nuintun/koa-files#readme
@@ -97,12 +97,12 @@ class ReadStream extends Readable {
97
97
  // Change read state.
98
98
  switch (this.#readState) {
99
99
  case 0 /* ReadState.PREFIX */:
100
- this.#readState = 1 /* ReadState.RANGE */;
100
+ this.#readState = 1; /* ReadState.RANGE */
101
101
  this.#readFileRange(fd, range, size - bytesRead);
102
102
  break;
103
103
  case 2 /* ReadState.SUFFIX */:
104
104
  this.#currentRangeIndex++;
105
- this.#readState = 0 /* ReadState.PREFIX */;
105
+ this.#readState = 0; /* ReadState.PREFIX */
106
106
  const nextRange = this.#getRange();
107
107
  if (nextRange == null) {
108
108
  this.push(null);
@@ -145,14 +145,14 @@ class ReadStream extends Readable {
145
145
  // file range read completed.
146
146
  if (bytesRead < size) {
147
147
  this.#bytesRead = 0;
148
- this.#readState = 2 /* ReadState.SUFFIX */;
148
+ this.#readState = 2; /* ReadState.SUFFIX */
149
149
  this.#readPadding(fd, range, size - bytesRead);
150
150
  }
151
151
  }
152
152
  });
153
153
  } else {
154
154
  this.#bytesRead = 0;
155
- this.#readState = 2 /* ReadState.SUFFIX */;
155
+ this.#readState = 2; /* ReadState.SUFFIX */
156
156
  this.#readPadding(fd, range, size);
157
157
  }
158
158
  }
package/esm/Service.js CHANGED
@@ -2,20 +2,19 @@
2
2
  * @module koa-files
3
3
  * @package koa-files
4
4
  * @license MIT
5
- * @version 5.0.0
5
+ * @version 5.0.2
6
6
  * @author nuintun <nuintun@qq.com>
7
7
  * @description A static files serving middleware for koa.
8
8
  * @see https://github.com/nuintun/koa-files#readme
9
9
  */
10
10
 
11
- import createETag from 'etag';
12
11
  import fs from 'node:fs';
13
12
  import { ReadStream } from './ReadStream.js';
14
13
  import { isFunction } from './utils/type.js';
15
14
  import { stat } from './utils/fs.js';
16
15
  import { resolve, extname, join } from 'node:path';
17
16
  import { unixify, isOutRoot, hasTrailingSlash } from './utils/path.js';
18
- import { decodeURI, isConditionalGET, isPreconditionFailed, parseRanges } from './utils/http.js';
17
+ import { createETag, decodeURI, isConditionalGET, isPreconditionFailed, parseRanges } from './utils/http.js';
19
18
 
20
19
  /**
21
20
  * @module Service
@@ -156,10 +155,8 @@ class Service {
156
155
  }
157
156
  // Head request.
158
157
  if (method === 'HEAD') {
159
- // Set Content-Length.
158
+ // Set Content-Length, koa keeps it and sends no body for HEAD.
160
159
  response.length = stats.size;
161
- // Set body null
162
- response.body = null;
163
160
  // File found.
164
161
  return true;
165
162
  }
@@ -167,14 +164,12 @@ class Service {
167
164
  const ranges = parseRanges(context, stats);
168
165
  // 416
169
166
  if (ranges === -1) {
170
- // Set Content-Range.
171
- response.set('Content-Range', `bytes */${stats.size}`);
172
- // Unsatisfiable 416.
173
- return context.throw(416);
174
- }
175
- // 400.
176
- if (ranges === -2) {
177
- return context.throw(400);
167
+ // koa strips all response headers on error, pass Content-Range via err.headers.
168
+ return context.throw(416, {
169
+ headers: {
170
+ 'Content-Range': `bytes */${stats.size}`
171
+ }
172
+ });
178
173
  }
179
174
  // Set response body.
180
175
  response.body = new ReadStream(path, ranges, {
package/esm/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * @module koa-files
3
3
  * @package koa-files
4
4
  * @license MIT
5
- * @version 5.0.0
5
+ * @version 5.0.2
6
6
  * @author nuintun <nuintun@qq.com>
7
7
  * @description A static files serving middleware for koa.
8
8
  * @see https://github.com/nuintun/koa-files#readme
package/esm/utils/fs.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * @module koa-files
3
3
  * @package koa-files
4
4
  * @license MIT
5
- * @version 5.0.0
5
+ * @version 5.0.2
6
6
  * @author nuintun <nuintun@qq.com>
7
7
  * @description A static files serving middleware for koa.
8
8
  * @see https://github.com/nuintun/koa-files#readme
@@ -16,12 +16,37 @@ export interface Range {
16
16
  * @param URI The URI to decode.
17
17
  */
18
18
  export declare function decodeURI(URI: string): string | -1;
19
+ /**
20
+ * @function generateBoundary
21
+ * @description Generate a boundary string.
22
+ */
23
+ export declare function generateBoundary(): string;
19
24
  /**
20
25
  * @function isConditionalGET
21
26
  * @description Check if request is conditional GET.
22
27
  * @param context The koa context.
23
28
  */
24
29
  export declare function isConditionalGET({ request }: Context): boolean;
30
+ /**
31
+ * @function createETag
32
+ * @description Generate a weak entity tag from file stats.
33
+ * @param stats The file stats.
34
+ */
35
+ export declare function createETag(stats: Stats): string;
36
+ /**
37
+ * @function isETagMatch
38
+ * @see https://httpwg.org/specs/rfc9110.html
39
+ * @param match The match value.
40
+ * @param etag The etag value.
41
+ * @param isIfMatch The flag of if-match.
42
+ */
43
+ export declare function isETagMatch(match: string, etag: string, isIfMatch?: boolean): boolean;
44
+ /**
45
+ * @function isRangeFresh
46
+ * @description Check if request range fresh.
47
+ * @param context Koa context.
48
+ */
49
+ export declare function isRangeFresh({ request, response }: Context): boolean;
25
50
  /**
26
51
  * @function isPreconditionFailed
27
52
  * @description Check if request precondition failed.
@@ -34,4 +59,4 @@ export declare function isPreconditionFailed({ request, response }: Context): bo
34
59
  * @param context The koa context.
35
60
  * @param stats The file stats.
36
61
  */
37
- export declare function parseRanges(context: Context, stats: Stats): -1 | -2 | Range[];
62
+ export declare function parseRanges(context: Context, stats: Stats): -1 | Range[];
package/esm/utils/http.js CHANGED
@@ -2,20 +2,21 @@
2
2
  * @module koa-files
3
3
  * @package koa-files
4
4
  * @license MIT
5
- * @version 5.0.0
5
+ * @version 5.0.2
6
6
  * @author nuintun <nuintun@qq.com>
7
7
  * @description A static files serving middleware for koa.
8
8
  * @see https://github.com/nuintun/koa-files#readme
9
9
  */
10
10
 
11
- import { generate } from './hash.js';
12
11
  import { Buffer } from 'node:buffer';
13
12
  import parseRange from 'range-parser';
13
+ import { getRandomValues } from 'node:crypto';
14
14
 
15
15
  /**
16
16
  * @module http
17
17
  */
18
18
  const SPLIT_ETAG_RE = /\s*,\s*/;
19
+ const BOUNDARY_SCRATCH = Buffer.allocUnsafeSlow(24);
19
20
  const SINGLE_ETAG_RE = /^(?:W\/)?"[ !#-\x7E\x80-\xFF]+"$/;
20
21
  /**
21
22
  * @function decodeURI
@@ -29,6 +30,13 @@ function decodeURI(URI) {
29
30
  return -1;
30
31
  }
31
32
  }
33
+ /**
34
+ * @function generateBoundary
35
+ * @description Generate a boundary string.
36
+ */
37
+ function generateBoundary() {
38
+ return getRandomValues(BOUNDARY_SCRATCH).toString('base64url');
39
+ }
32
40
  /**
33
41
  * @function isConditionalGET
34
42
  * @description Check if request is conditional GET.
@@ -42,6 +50,14 @@ function isConditionalGET({ request }) {
42
50
  request.get('If-Unmodified-Since')
43
51
  );
44
52
  }
53
+ /**
54
+ * @function createETag
55
+ * @description Generate a weak entity tag from file stats.
56
+ * @param stats The file stats.
57
+ */
58
+ function createETag(stats) {
59
+ return `W/"${stats.size.toString(16)}-${stats.mtime.getTime().toString(16)}"`;
60
+ }
45
61
  /**
46
62
  * @function isETagMatch
47
63
  * @see https://httpwg.org/specs/rfc9110.html
@@ -50,25 +66,45 @@ function isConditionalGET({ request }) {
50
66
  * @param isIfMatch The flag of if-match.
51
67
  */
52
68
  function isETagMatch(match, etag, isIfMatch) {
69
+ // Trim match.
70
+ match = match.trim();
71
+ // Check If-Match.
72
+ if (isIfMatch && match === '*') {
73
+ // The condition is true if the selected representation exists.
74
+ return true;
75
+ }
53
76
  // Trim etag.
54
77
  etag = etag.trim();
55
78
  // Weak tags cannot be matched and return false.
56
79
  if (!etag || etag.startsWith('W/')) {
57
80
  return false;
58
81
  }
59
- // Trim match.
60
- match = match.trim();
61
82
  // Check If-Match.
62
83
  if (isIfMatch) {
63
- if (match === '*') {
64
- return true;
65
- }
66
84
  // If-Match maybe a list of etag.
67
85
  return match.split(SPLIT_ETAG_RE).includes(etag);
68
86
  }
69
87
  // Check If-Range.
70
88
  return match === etag;
71
89
  }
90
+ /**
91
+ * @function isRangeFresh
92
+ * @description Check if request range fresh.
93
+ * @param context Koa context.
94
+ */
95
+ function isRangeFresh({ request, response }) {
96
+ const ifRange = request.get('If-Range');
97
+ // No If-Range.
98
+ if (!ifRange) {
99
+ return true;
100
+ }
101
+ // If-Range as modified date failed.
102
+ if (SINGLE_ETAG_RE.test(ifRange)) {
103
+ return isETagMatch(ifRange, response.get('ETag'));
104
+ }
105
+ // Check if Last-Modified is valid and equal to If-Range date
106
+ return Date.parse(response.get('Last-Modified')) <= Date.parse(ifRange);
107
+ }
72
108
  /**
73
109
  * @function isPreconditionFailed
74
110
  * @description Check if request precondition failed.
@@ -91,24 +127,6 @@ function isPreconditionFailed({ request, response }) {
91
127
  // Check precondition passed.
92
128
  return false;
93
129
  }
94
- /**
95
- * @function isRangeFresh
96
- * @description Check if request range fresh.
97
- * @param context Koa context.
98
- */
99
- function isRangeFresh({ request, response }) {
100
- const ifRange = request.get('If-Range');
101
- // No If-Range.
102
- if (!ifRange) {
103
- return true;
104
- }
105
- // If-Range as modified date failed.
106
- if (SINGLE_ETAG_RE.test(ifRange)) {
107
- return isETagMatch(ifRange, response.get('ETag'));
108
- }
109
- // Check if Last-Modified is valid and equal to If-Range date
110
- return Date.parse(response.get('Last-Modified')) <= Date.parse(ifRange);
111
- }
112
130
  /**
113
131
  * @function parseRanges
114
132
  * @description Parse ranges.
@@ -126,10 +144,16 @@ function parseRanges(context, stats) {
126
144
  // Parse range -1 -2 or [].
127
145
  const parsed = parseRange(size, range, { combine: true });
128
146
  // -1 signals an unsatisfiable range.
129
- // -2 signals a malformed header string.
130
- if (parsed === -1 || parsed === -2) {
147
+ if (parsed === -1) {
131
148
  return parsed;
132
149
  }
150
+ // -2 signals a malformed or unknown-unit range, ignore it (RFC 9110 §14.2).
151
+ if (parsed === -2) {
152
+ // Set Content-Length.
153
+ response.length = size;
154
+ // Return ranges.
155
+ return [{ offset: 0, length: size }];
156
+ }
133
157
  // Ranges ok, support multiple ranges.
134
158
  if (parsed.type === 'bytes') {
135
159
  // Set 206 status.
@@ -144,7 +168,7 @@ function parseRanges(context, stats) {
144
168
  // Parsed entries.
145
169
  const entries = parsed.entries();
146
170
  // Range boundary.
147
- const boundary = `${generate(32)}`;
171
+ const boundary = generateBoundary();
148
172
  // Multipart Content-Type.
149
173
  const contentType = `Content-Type: ${response.type}`;
150
174
  // Range suffix.
@@ -157,9 +181,7 @@ function parseRanges(context, stats) {
157
181
  const length = end - start + 1;
158
182
  const head = index > 0 ? '\r\n' : '';
159
183
  const contentRange = `Content-Range: bytes ${start}-${end}/${size}`;
160
- const prefix = Buffer.from(
161
- `${head}--${boundary}\r\n${contentType}\r\n${contentRange}\r\n\r\n`
162
- );
184
+ const prefix = Buffer.from(`${head}--${boundary}\r\n${contentType}\r\n${contentRange}\r\n\r\n`);
163
185
  // Compute Content-Length
164
186
  contentLength += prefix.length + length;
165
187
  // Cache range.
@@ -192,4 +214,13 @@ function parseRanges(context, stats) {
192
214
  return [{ offset: 0, length: size }];
193
215
  }
194
216
 
195
- export { decodeURI, isConditionalGET, isPreconditionFailed, parseRanges };
217
+ export {
218
+ createETag,
219
+ decodeURI,
220
+ generateBoundary,
221
+ isConditionalGET,
222
+ isETagMatch,
223
+ isPreconditionFailed,
224
+ isRangeFresh,
225
+ parseRanges
226
+ };
package/esm/utils/path.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * @module koa-files
3
3
  * @package koa-files
4
4
  * @license MIT
5
- * @version 5.0.0
5
+ * @version 5.0.2
6
6
  * @author nuintun <nuintun@qq.com>
7
7
  * @description A static files serving middleware for koa.
8
8
  * @see https://github.com/nuintun/koa-files#readme
package/esm/utils/type.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * @module koa-files
3
3
  * @package koa-files
4
4
  * @license MIT
5
- * @version 5.0.0
5
+ * @version 5.0.2
6
6
  * @author nuintun <nuintun@qq.com>
7
7
  * @description A static files serving middleware for koa.
8
8
  * @see https://github.com/nuintun/koa-files#readme
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koa-files",
3
- "version": "5.0.0",
3
+ "version": "5.0.2",
4
4
  "description": "A static files serving middleware for koa.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -41,31 +41,11 @@
41
41
  "url": "https://github.com/nuintun/koa-files/issues"
42
42
  },
43
43
  "homepage": "https://github.com/nuintun/koa-files#readme",
44
- "dependencies": {
45
- "etag": "^1.8.1",
46
- "range-parser": "^1.2.1",
47
- "tslib": "^2.8.1"
48
- },
49
- "devDependencies": {
50
- "@rollup/plugin-typescript": "^12.3.0",
51
- "@types/etag": "^1.8.4",
52
- "@types/koa": "^3.0.2",
53
- "@types/node": "^25.5.2",
54
- "@types/range-parser": "^1.2.7",
55
- "dts-paths": "^2.2.1",
56
- "koa": "^3.2.0",
57
- "prettier": "^3.8.1",
58
- "rimraf": "^6.1.3",
59
- "rollup": "^4.60.1",
60
- "typescript": "^6.0.2"
61
- },
62
- "peerDependencies": {
63
- "koa": ">=2.0.0"
64
- },
65
44
  "scripts": {
66
45
  "lint": "tsc --noEmit",
67
46
  "pretest": "pnpm build",
68
47
  "clean": "node tools/clean.ts",
48
+ "prepublishOnly": "pnpm build",
69
49
  "test": "node --test tests/index.ts",
70
50
  "build:cjs": "rollup -c tools/rollup.cjs.ts",
71
51
  "build:esm": "rollup -c tools/rollup.esm.ts",
@@ -73,5 +53,25 @@
73
53
  "build": "pnpm clean && pnpm build:main && pnpm format",
74
54
  "format": "prettier --write . --ignore-path .prettierignore",
75
55
  "build:main": "pnpm build:cjs && pnpm build:esm && pnpm build:fix-types"
56
+ },
57
+ "dependencies": {
58
+ "range-parser": "^1.3.0",
59
+ "tslib": "^2.8.1"
60
+ },
61
+ "devDependencies": {
62
+ "@rollup/plugin-typescript": "^12.3.0",
63
+ "@types/etag": "^1.8.4",
64
+ "@types/koa": "^3.0.3",
65
+ "@types/node": "^26.6.5",
66
+ "@types/range-parser": "^1.2.7",
67
+ "dts-paths": "^2.2.2",
68
+ "koa": "^3.2.1",
69
+ "prettier": "^3.9.10",
70
+ "rimraf": "^6.1.3",
71
+ "rollup": "^4.64.12",
72
+ "typescript": "^6.0.3"
73
+ },
74
+ "peerDependencies": {
75
+ "koa": ">=2.0.0"
76
76
  }
77
- }
77
+ }
@@ -1,47 +0,0 @@
1
- /**
2
- * @module koa-files
3
- * @package koa-files
4
- * @license MIT
5
- * @version 5.0.0
6
- * @author nuintun <nuintun@qq.com>
7
- * @description A static files serving middleware for koa.
8
- * @see https://github.com/nuintun/koa-files#readme
9
- */
10
-
11
- 'use strict';
12
-
13
- const node_crypto = require('node:crypto');
14
-
15
- /**
16
- * @module hash
17
- */
18
- // prettier-ignore
19
- const CHARS = [
20
- // 0-9
21
- '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
22
- // A-M
23
- 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
24
- // N-Z
25
- 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
26
- // a-m
27
- 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
28
- // n-z
29
- 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'
30
- ];
31
- /**
32
- * @function generate
33
- * @description Generate a hash.
34
- * @param length The length of hash.
35
- */
36
- function generate(length) {
37
- let hash = '';
38
- const randomValues = node_crypto.getRandomValues(new Uint8Array(length));
39
- // Create hash.
40
- for (const value of randomValues) {
41
- hash += CHARS[value % 62];
42
- }
43
- // Return hash.
44
- return hash;
45
- }
46
-
47
- exports.generate = generate;
package/esm/utils/hash.js DELETED
@@ -1,45 +0,0 @@
1
- /**
2
- * @module koa-files
3
- * @package koa-files
4
- * @license MIT
5
- * @version 5.0.0
6
- * @author nuintun <nuintun@qq.com>
7
- * @description A static files serving middleware for koa.
8
- * @see https://github.com/nuintun/koa-files#readme
9
- */
10
-
11
- import { getRandomValues } from 'node:crypto';
12
-
13
- /**
14
- * @module hash
15
- */
16
- // prettier-ignore
17
- const CHARS = [
18
- // 0-9
19
- '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
20
- // A-M
21
- 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
22
- // N-Z
23
- 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
24
- // a-m
25
- 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
26
- // n-z
27
- 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'
28
- ];
29
- /**
30
- * @function generate
31
- * @description Generate a hash.
32
- * @param length The length of hash.
33
- */
34
- function generate(length) {
35
- let hash = '';
36
- const randomValues = getRandomValues(new Uint8Array(length));
37
- // Create hash.
38
- for (const value of randomValues) {
39
- hash += CHARS[value % 62];
40
- }
41
- // Return hash.
42
- return hash;
43
- }
44
-
45
- export { generate };