@remix-run/static-middleware 0.2.0 → 0.4.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/README.md CHANGED
@@ -35,6 +35,8 @@ router.get('/', () => new Response('Home'))
35
35
 
36
36
  ### With Cache Control
37
37
 
38
+ Internally, the `staticFiles()` middleware uses the [`createFileResponse()` helper from `@remix-run/response`](https://github.com/remix-run/remix/tree/main/packages/response/README.md#file-responses) to send files with full HTTP semantics. This means it also accepts the same options as the `createFileResponse()` helper.
39
+
38
40
  ```ts
39
41
  let router = createRouter({
40
42
  middleware: [
package/dist/index.d.ts CHANGED
@@ -1,3 +1,2 @@
1
- export { staticFiles } from './lib/static.ts';
2
- export type { StaticFilesOptions } from './lib/static.ts';
1
+ export { type StaticFilesOptions, staticFiles } from './lib/static.ts';
3
2
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAC7C,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,kBAAkB,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA"}
@@ -0,0 +1,2 @@
1
+ export declare function generateDirectoryListing(dirPath: string, pathname: string): Promise<Response>;
2
+ //# sourceMappingURL=directory-listing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"directory-listing.d.ts","sourceRoot":"","sources":["../../src/lib/directory-listing.ts"],"names":[],"mappings":"AAaA,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,QAAQ,CAAC,CAqPnB"}
@@ -0,0 +1,278 @@
1
+ import * as path from 'node:path';
2
+ import * as fsp from 'node:fs/promises';
3
+ import { html } from '@remix-run/html-template';
4
+ import { createHtmlResponse } from '@remix-run/response/html';
5
+ import { detectMimeType } from '@remix-run/mime';
6
+ export async function generateDirectoryListing(dirPath, pathname) {
7
+ let entries = [];
8
+ try {
9
+ let dirents = await fsp.readdir(dirPath, { withFileTypes: true });
10
+ for (let dirent of dirents) {
11
+ let fullPath = path.join(dirPath, dirent.name);
12
+ let isDirectory = dirent.isDirectory();
13
+ let size = 0;
14
+ let type = '';
15
+ if (isDirectory) {
16
+ size = await calculateDirectorySize(fullPath);
17
+ }
18
+ else {
19
+ try {
20
+ let stats = await fsp.stat(fullPath);
21
+ size = stats.size;
22
+ let mimeType = detectMimeType(dirent.name);
23
+ type = mimeType || 'application/octet-stream';
24
+ }
25
+ catch {
26
+ // Unable to stat file, use defaults
27
+ }
28
+ }
29
+ entries.push({
30
+ name: dirent.name,
31
+ isDirectory,
32
+ size,
33
+ type,
34
+ });
35
+ }
36
+ }
37
+ catch {
38
+ return new Response('Error reading directory', { status: 500 });
39
+ }
40
+ // Sort: directories first, then alphabetically
41
+ entries.sort((a, b) => {
42
+ if (a.isDirectory && !b.isDirectory)
43
+ return -1;
44
+ if (!a.isDirectory && b.isDirectory)
45
+ return 1;
46
+ return a.name.localeCompare(b.name, undefined, { numeric: true });
47
+ });
48
+ // Build table rows
49
+ let tableRows = [];
50
+ let folderIcon = html.raw `<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 3.5h4.5l1.5 1.5h6v8h-12z"/></svg>`;
51
+ let fileIcon = html.raw `<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 2h7l3 3v9H3z"/><path d="M10 2v3h3"/></svg>`;
52
+ // Add parent directory link if not at root
53
+ if (pathname !== '/' && pathname !== '') {
54
+ let parentPath = pathname.replace(/\/$/, '').split('/').slice(0, -1).join('/') || '/';
55
+ tableRows.push(html `
56
+ <tr class="file-row">
57
+ <td class="name-cell">
58
+ <a href="${parentPath}">${folderIcon} ..</a>
59
+ </td>
60
+ <td class="size-cell"></td>
61
+ <td class="type-cell"></td>
62
+ </tr>
63
+ `);
64
+ }
65
+ for (let entry of entries) {
66
+ let icon = entry.isDirectory ? folderIcon : fileIcon;
67
+ let href = pathname.endsWith('/') ? pathname + entry.name : pathname + '/' + entry.name;
68
+ let sizeDisplay = formatFileSize(entry.size);
69
+ let typeDisplay = entry.isDirectory ? 'Folder' : entry.type;
70
+ tableRows.push(html `
71
+ <tr class="file-row">
72
+ <td class="name-cell">
73
+ <a href="${href}">${icon} ${entry.name}</a>
74
+ </td>
75
+ <td class="size-cell">${sizeDisplay}</td>
76
+ <td class="type-cell">${typeDisplay}</td>
77
+ </tr>
78
+ `);
79
+ }
80
+ return createHtmlResponse(html `
81
+ <html lang="en">
82
+ <head>
83
+ <meta charset="utf-8" />
84
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
85
+ <title>Index of ${pathname}</title>
86
+ <style>
87
+ * {
88
+ box-sizing: border-box;
89
+ }
90
+ body {
91
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
92
+ Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
93
+ margin: 0;
94
+ padding: 2rem 1rem;
95
+ background: #fff;
96
+ color: #333;
97
+ font-size: 14px;
98
+ }
99
+ .container {
100
+ max-width: 1200px;
101
+ margin: 0 auto;
102
+ }
103
+ h1 {
104
+ margin: 0 0 1rem 0;
105
+ padding: 0;
106
+ font-size: 1.25rem;
107
+ font-weight: 600;
108
+ color: #333;
109
+ }
110
+ .icon {
111
+ width: 14px;
112
+ height: 14px;
113
+ display: inline-block;
114
+ vertical-align: text-top;
115
+ margin-right: 0.5rem;
116
+ color: #666;
117
+ }
118
+ table {
119
+ width: 100%;
120
+ border-collapse: collapse;
121
+ border: 1px solid #e0e0e0;
122
+ }
123
+ thead tr {
124
+ background: #fafafa;
125
+ }
126
+ th {
127
+ padding: 0.5rem 1rem;
128
+ text-align: left;
129
+ font-weight: 600;
130
+ font-size: 0.8125rem;
131
+ color: #666;
132
+ border-bottom: 1px solid #e0e0e0;
133
+ }
134
+ td {
135
+ border-bottom: 1px solid #f0f0f0;
136
+ }
137
+ tr:last-child td {
138
+ border-bottom: none;
139
+ }
140
+ .file-row {
141
+ position: relative;
142
+ }
143
+ .file-row:hover {
144
+ background: #fafafa;
145
+ }
146
+ .name-cell {
147
+ width: 50%;
148
+ padding: 0;
149
+ }
150
+ .name-cell a {
151
+ display: block;
152
+ padding: 0.375rem 1rem;
153
+ color: #0066cc;
154
+ text-decoration: none;
155
+ }
156
+ .name-cell a::after {
157
+ content: '';
158
+ position: absolute;
159
+ top: 0;
160
+ left: 0;
161
+ right: 0;
162
+ bottom: 0;
163
+ }
164
+ .file-row:hover .name-cell a {
165
+ text-decoration: underline;
166
+ }
167
+ .size-cell {
168
+ width: 25%;
169
+ padding: 0.375rem 1rem;
170
+ text-align: left;
171
+ color: #666;
172
+ font-variant-numeric: tabular-nums;
173
+ }
174
+ .type-cell {
175
+ width: 25%;
176
+ padding: 0.375rem 1rem;
177
+ color: #666;
178
+ white-space: nowrap;
179
+ }
180
+ @media (max-width: 768px) {
181
+ body {
182
+ padding: 1rem 0.5rem;
183
+ }
184
+ h1 {
185
+ font-size: 1.125rem;
186
+ margin-bottom: 0.75rem;
187
+ }
188
+ thead {
189
+ display: none;
190
+ }
191
+ .name-cell a,
192
+ .size-cell,
193
+ .type-cell {
194
+ padding: 0.375rem 0.75rem;
195
+ }
196
+ .type-cell {
197
+ display: none;
198
+ }
199
+ .name-cell {
200
+ width: 60%;
201
+ }
202
+ .size-cell {
203
+ width: 40%;
204
+ text-align: right;
205
+ }
206
+ }
207
+ @media (max-width: 480px) {
208
+ body {
209
+ font-size: 13px;
210
+ }
211
+ .name-cell a,
212
+ .size-cell,
213
+ .type-cell {
214
+ padding: 0.375rem 0.5rem;
215
+ }
216
+ h1 {
217
+ font-size: 1rem;
218
+ }
219
+ .icon {
220
+ width: 12px;
221
+ height: 12px;
222
+ }
223
+ }
224
+ </style>
225
+ </head>
226
+ <body>
227
+ <div class="container">
228
+ <h1>Index of ${pathname}</h1>
229
+ <table>
230
+ <thead>
231
+ <tr>
232
+ <th class="name">Name</th>
233
+ <th class="size">Size</th>
234
+ <th class="type">Type</th>
235
+ </tr>
236
+ </thead>
237
+ <tbody>
238
+ ${tableRows}
239
+ </tbody>
240
+ </table>
241
+ </div>
242
+ </body>
243
+ </html>
244
+ `);
245
+ }
246
+ async function calculateDirectorySize(dirPath) {
247
+ let totalSize = 0;
248
+ try {
249
+ let dirents = await fsp.readdir(dirPath, { withFileTypes: true });
250
+ for (let dirent of dirents) {
251
+ let fullPath = path.join(dirPath, dirent.name);
252
+ try {
253
+ if (dirent.isDirectory()) {
254
+ totalSize += await calculateDirectorySize(fullPath);
255
+ }
256
+ else if (dirent.isFile()) {
257
+ let stats = await fsp.stat(fullPath);
258
+ totalSize += stats.size;
259
+ }
260
+ }
261
+ catch {
262
+ // Skip files/folders we can't access
263
+ }
264
+ }
265
+ }
266
+ catch {
267
+ // If we can't read the directory, return 0
268
+ }
269
+ return totalSize;
270
+ }
271
+ function formatFileSize(bytes) {
272
+ if (bytes === 0)
273
+ return '0 B';
274
+ let units = ['B', 'kB', 'MB', 'GB', 'TB'];
275
+ let i = Math.floor(Math.log(bytes) / Math.log(1024));
276
+ let size = bytes / Math.pow(1024, i);
277
+ return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
278
+ }
@@ -1,46 +1,74 @@
1
1
  import type { Middleware } from '@remix-run/fetch-router';
2
- import { type FileResponseOptions } from '@remix-run/fetch-router/response-helpers';
3
- export interface StaticFilesOptions extends FileResponseOptions {
2
+ import { type FileResponseOptions } from '@remix-run/response/file';
3
+ /**
4
+ * Function that determines if HTTP Range requests should be supported for a given file.
5
+ *
6
+ * @param file - The File object being served
7
+ * @returns true if range requests should be supported
8
+ */
9
+ export type AcceptRangesFunction = (file: File) => boolean;
10
+ /**
11
+ * Options for the `staticFiles` middleware.
12
+ */
13
+ export interface StaticFilesOptions extends Omit<FileResponseOptions, 'acceptRanges'> {
4
14
  /**
5
15
  * Filter function to determine which files should be served.
6
16
  *
7
17
  * @param path The relative path being requested
8
- * @returns Whether to serve the file
18
+ * @return Whether to serve the file
9
19
  */
10
20
  filter?: (path: string) => boolean;
21
+ /**
22
+ * Whether to support HTTP Range requests for partial content.
23
+ *
24
+ * Can be a boolean or a function that receives the file.
25
+ * When enabled, includes Accept-Ranges header and handles Range requests
26
+ * with 206 Partial Content responses.
27
+ *
28
+ * Defaults to enabling ranges only for non-compressible MIME types,
29
+ * as defined by `isCompressibleMimeType()` from `@remix-run/mime`.
30
+ *
31
+ * Note: Range requests and compression are mutually exclusive. When
32
+ * `Accept-Ranges: bytes` is present in the response headers, the compression
33
+ * middleware will not compress the response. This is why the default behavior
34
+ * enables ranges only for non-compressible types.
35
+ *
36
+ * @example
37
+ * // Force range request support for all files
38
+ * acceptRanges: true
39
+ *
40
+ * @example
41
+ * // Enable ranges for videos only
42
+ * acceptRanges: (file) => file.type.startsWith('video/')
43
+ */
44
+ acceptRanges?: boolean | AcceptRangesFunction;
11
45
  /**
12
46
  * Files to try and serve as the index file when the request path targets a directory.
13
47
  *
14
- * - `true` (default): Use default index files `['index.html', 'index.htm']`
48
+ * - `true`: Use default index files `['index.html', 'index.htm']`
15
49
  * - `false`: Disable index file serving
16
50
  * - `string[]`: Custom list of index files to try in order
51
+ *
52
+ * @default true
17
53
  */
18
54
  index?: boolean | string[];
55
+ /**
56
+ * Whether to return an HTML page listing the files in a directory when the request path
57
+ * targets a directory. If both this and `index` are set, `index` takes precedence.
58
+ *
59
+ * @default false
60
+ */
61
+ listFiles?: boolean;
19
62
  }
20
63
  /**
21
64
  * Creates a middleware that serves static files from the filesystem.
22
65
  *
23
- * Uses the URL pathname to resolve files, removing the leading slash to make it
24
- * a relative path. The middleware always falls through to the handler if the file
25
- * is not found or an error occurs.
66
+ * Uses the URL pathname to resolve files, removing the leading slash to make it a relative path.
67
+ * The middleware always falls through to the handler if the file is not found or an error occurs.
26
68
  *
27
69
  * @param root The root directory to serve files from (absolute or relative to cwd)
28
- * @param options (optional) configuration for file responses
29
- *
30
- * @example
31
- * let router = createRouter({
32
- * middleware: [staticFiles('./public')],
33
- * })
34
- *
35
- * @example
36
- * // With cache control
37
- * let router = createRouter({
38
- * middleware: [
39
- * staticFiles('./public', {
40
- * cacheControl: 'public, max-age=3600',
41
- * }),
42
- * ],
43
- * })
70
+ * @param options Configuration for file responses
71
+ * @return The static files middleware
44
72
  */
45
73
  export declare function staticFiles(root: string, options?: StaticFilesOptions): Middleware;
46
74
  //# sourceMappingURL=static.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"static.d.ts","sourceRoot":"","sources":["../../src/lib/static.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAA;AACzD,OAAO,EAEL,KAAK,mBAAmB,EACzB,MAAM,0CAA0C,CAAA;AAEjD,MAAM,WAAW,kBAAmB,SAAQ,mBAAmB;IAC7D;;;;;OAKG;IACH,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAA;IAClC;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,OAAO,GAAG,MAAM,EAAE,CAAA;CAC3B;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,kBAAuB,GAAG,UAAU,CA4DtF"}
1
+ {"version":3,"file":"static.d.ts","sourceRoot":"","sources":["../../src/lib/static.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAA;AACzD,OAAO,EAAkC,KAAK,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAInG;;;;;GAKG;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAA;AAE1D;;GAEG;AACH,MAAM,WAAW,kBAAmB,SAAQ,IAAI,CAAC,mBAAmB,EAAE,cAAc,CAAC;IACnF;;;;;OAKG;IACH,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAA;IAElC;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,YAAY,CAAC,EAAE,OAAO,GAAG,oBAAoB,CAAA;IAE7C;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,OAAO,GAAG,MAAM,EAAE,CAAA;IAC1B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,kBAAuB,GAAG,UAAU,CA8EtF"}
@@ -1,36 +1,22 @@
1
1
  import * as path from 'node:path';
2
2
  import * as fsp from 'node:fs/promises';
3
3
  import { openFile } from '@remix-run/fs';
4
- import { file as sendFile, } from '@remix-run/fetch-router/response-helpers';
4
+ import { createFileResponse as sendFile } from '@remix-run/response/file';
5
+ import { generateDirectoryListing } from "./directory-listing.js";
5
6
  /**
6
7
  * Creates a middleware that serves static files from the filesystem.
7
8
  *
8
- * Uses the URL pathname to resolve files, removing the leading slash to make it
9
- * a relative path. The middleware always falls through to the handler if the file
10
- * is not found or an error occurs.
9
+ * Uses the URL pathname to resolve files, removing the leading slash to make it a relative path.
10
+ * The middleware always falls through to the handler if the file is not found or an error occurs.
11
11
  *
12
12
  * @param root The root directory to serve files from (absolute or relative to cwd)
13
- * @param options (optional) configuration for file responses
14
- *
15
- * @example
16
- * let router = createRouter({
17
- * middleware: [staticFiles('./public')],
18
- * })
19
- *
20
- * @example
21
- * // With cache control
22
- * let router = createRouter({
23
- * middleware: [
24
- * staticFiles('./public', {
25
- * cacheControl: 'public, max-age=3600',
26
- * }),
27
- * ],
28
- * })
13
+ * @param options Configuration for file responses
14
+ * @return The static files middleware
29
15
  */
30
16
  export function staticFiles(root, options = {}) {
31
17
  // Ensure root is an absolute path
32
18
  root = path.resolve(root);
33
- let { filter, index: indexOption, ...fileOptions } = options;
19
+ let { acceptRanges, filter, index: indexOption, listFiles, ...fileOptions } = options;
34
20
  // Normalize index option
35
21
  let index;
36
22
  if (indexOption === false) {
@@ -72,6 +58,10 @@ export function staticFiles(root, options = {}) {
72
58
  // Index file doesn't exist, continue to next
73
59
  }
74
60
  }
61
+ // If no index file found and listFiles is enabled, show directory listing
62
+ if (!filePath && listFiles) {
63
+ return generateDirectoryListing(targetPath, context.url.pathname);
64
+ }
75
65
  }
76
66
  }
77
67
  catch {
@@ -80,7 +70,17 @@ export function staticFiles(root, options = {}) {
80
70
  if (filePath) {
81
71
  let fileName = path.relative(root, filePath);
82
72
  let file = openFile(filePath, { name: fileName });
83
- return sendFile(file, context.request, fileOptions);
73
+ let finalFileOptions = { ...fileOptions };
74
+ // If acceptRanges is a function, evaluate it with the file
75
+ // Otherwise, pass it directly to sendFile
76
+ if (typeof acceptRanges === 'function') {
77
+ finalFileOptions.acceptRanges = acceptRanges(file);
78
+ }
79
+ else if (acceptRanges !== undefined) {
80
+ finalFileOptions.acceptRanges = acceptRanges;
81
+ }
82
+ return sendFile(file, context.request, finalFileOptions);
84
83
  }
84
+ return next();
85
85
  };
86
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remix-run/static-middleware",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Middleware for serving static files from the filesystem",
5
5
  "author": "Michael Jackson <mjijackson@gmail.com>",
6
6
  "license": "MIT",
@@ -28,16 +28,18 @@
28
28
  "devDependencies": {
29
29
  "@types/node": "^24.6.0",
30
30
  "typescript": "^5.9.3",
31
- "@remix-run/fetch-router": "0.10.0",
31
+ "@remix-run/fetch-router": "0.12.0",
32
32
  "@remix-run/form-data-middleware": "0.1.0",
33
- "@remix-run/lazy-file": "4.0.0",
34
- "@remix-run/fs": "0.1.0",
35
- "@remix-run/method-override-middleware": "0.1.0"
33
+ "@remix-run/method-override-middleware": "0.1.1",
34
+ "@remix-run/response": "^0.2.0",
35
+ "@remix-run/mime": "^0.1.0"
36
36
  },
37
37
  "peerDependencies": {
38
- "@remix-run/fetch-router": "^0.10.0",
39
- "@remix-run/fs": "^0.1.0",
40
- "@remix-run/lazy-file": "^4.0.0"
38
+ "@remix-run/fetch-router": "^0.12.0",
39
+ "@remix-run/fs": "^0.2.0",
40
+ "@remix-run/mime": "^0.1.0",
41
+ "@remix-run/response": "^0.2.0",
42
+ "@remix-run/html-template": "^0.3.0"
41
43
  },
42
44
  "keywords": [
43
45
  "fetch",
package/src/index.ts CHANGED
@@ -1,3 +1 @@
1
- export { staticFiles } from './lib/static.ts'
2
- export type { StaticFilesOptions } from './lib/static.ts'
3
-
1
+ export { type StaticFilesOptions, staticFiles } from './lib/static.ts'
@@ -0,0 +1,297 @@
1
+ import * as path from 'node:path'
2
+ import * as fsp from 'node:fs/promises'
3
+ import { html } from '@remix-run/html-template'
4
+ import { createHtmlResponse } from '@remix-run/response/html'
5
+ import { detectMimeType } from '@remix-run/mime'
6
+
7
+ interface DirectoryEntry {
8
+ name: string
9
+ isDirectory: boolean
10
+ size: number
11
+ type: string
12
+ }
13
+
14
+ export async function generateDirectoryListing(
15
+ dirPath: string,
16
+ pathname: string,
17
+ ): Promise<Response> {
18
+ let entries: DirectoryEntry[] = []
19
+
20
+ try {
21
+ let dirents = await fsp.readdir(dirPath, { withFileTypes: true })
22
+
23
+ for (let dirent of dirents) {
24
+ let fullPath = path.join(dirPath, dirent.name)
25
+ let isDirectory = dirent.isDirectory()
26
+ let size = 0
27
+ let type = ''
28
+
29
+ if (isDirectory) {
30
+ size = await calculateDirectorySize(fullPath)
31
+ } else {
32
+ try {
33
+ let stats = await fsp.stat(fullPath)
34
+ size = stats.size
35
+ let mimeType = detectMimeType(dirent.name)
36
+ type = mimeType || 'application/octet-stream'
37
+ } catch {
38
+ // Unable to stat file, use defaults
39
+ }
40
+ }
41
+
42
+ entries.push({
43
+ name: dirent.name,
44
+ isDirectory,
45
+ size,
46
+ type,
47
+ })
48
+ }
49
+ } catch {
50
+ return new Response('Error reading directory', { status: 500 })
51
+ }
52
+
53
+ // Sort: directories first, then alphabetically
54
+ entries.sort((a, b) => {
55
+ if (a.isDirectory && !b.isDirectory) return -1
56
+ if (!a.isDirectory && b.isDirectory) return 1
57
+ return a.name.localeCompare(b.name, undefined, { numeric: true })
58
+ })
59
+
60
+ // Build table rows
61
+ let tableRows = []
62
+
63
+ let folderIcon = html.raw`<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 3.5h4.5l1.5 1.5h6v8h-12z"/></svg>`
64
+ let fileIcon = html.raw`<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 2h7l3 3v9H3z"/><path d="M10 2v3h3"/></svg>`
65
+
66
+ // Add parent directory link if not at root
67
+ if (pathname !== '/' && pathname !== '') {
68
+ let parentPath = pathname.replace(/\/$/, '').split('/').slice(0, -1).join('/') || '/'
69
+ tableRows.push(html`
70
+ <tr class="file-row">
71
+ <td class="name-cell">
72
+ <a href="${parentPath}">${folderIcon} ..</a>
73
+ </td>
74
+ <td class="size-cell"></td>
75
+ <td class="type-cell"></td>
76
+ </tr>
77
+ `)
78
+ }
79
+
80
+ for (let entry of entries) {
81
+ let icon = entry.isDirectory ? folderIcon : fileIcon
82
+ let href = pathname.endsWith('/') ? pathname + entry.name : pathname + '/' + entry.name
83
+ let sizeDisplay = formatFileSize(entry.size)
84
+ let typeDisplay = entry.isDirectory ? 'Folder' : entry.type
85
+
86
+ tableRows.push(html`
87
+ <tr class="file-row">
88
+ <td class="name-cell">
89
+ <a href="${href}">${icon} ${entry.name}</a>
90
+ </td>
91
+ <td class="size-cell">${sizeDisplay}</td>
92
+ <td class="type-cell">${typeDisplay}</td>
93
+ </tr>
94
+ `)
95
+ }
96
+
97
+ return createHtmlResponse(html`
98
+ <html lang="en">
99
+ <head>
100
+ <meta charset="utf-8" />
101
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
102
+ <title>Index of ${pathname}</title>
103
+ <style>
104
+ * {
105
+ box-sizing: border-box;
106
+ }
107
+ body {
108
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
109
+ Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
110
+ margin: 0;
111
+ padding: 2rem 1rem;
112
+ background: #fff;
113
+ color: #333;
114
+ font-size: 14px;
115
+ }
116
+ .container {
117
+ max-width: 1200px;
118
+ margin: 0 auto;
119
+ }
120
+ h1 {
121
+ margin: 0 0 1rem 0;
122
+ padding: 0;
123
+ font-size: 1.25rem;
124
+ font-weight: 600;
125
+ color: #333;
126
+ }
127
+ .icon {
128
+ width: 14px;
129
+ height: 14px;
130
+ display: inline-block;
131
+ vertical-align: text-top;
132
+ margin-right: 0.5rem;
133
+ color: #666;
134
+ }
135
+ table {
136
+ width: 100%;
137
+ border-collapse: collapse;
138
+ border: 1px solid #e0e0e0;
139
+ }
140
+ thead tr {
141
+ background: #fafafa;
142
+ }
143
+ th {
144
+ padding: 0.5rem 1rem;
145
+ text-align: left;
146
+ font-weight: 600;
147
+ font-size: 0.8125rem;
148
+ color: #666;
149
+ border-bottom: 1px solid #e0e0e0;
150
+ }
151
+ td {
152
+ border-bottom: 1px solid #f0f0f0;
153
+ }
154
+ tr:last-child td {
155
+ border-bottom: none;
156
+ }
157
+ .file-row {
158
+ position: relative;
159
+ }
160
+ .file-row:hover {
161
+ background: #fafafa;
162
+ }
163
+ .name-cell {
164
+ width: 50%;
165
+ padding: 0;
166
+ }
167
+ .name-cell a {
168
+ display: block;
169
+ padding: 0.375rem 1rem;
170
+ color: #0066cc;
171
+ text-decoration: none;
172
+ }
173
+ .name-cell a::after {
174
+ content: '';
175
+ position: absolute;
176
+ top: 0;
177
+ left: 0;
178
+ right: 0;
179
+ bottom: 0;
180
+ }
181
+ .file-row:hover .name-cell a {
182
+ text-decoration: underline;
183
+ }
184
+ .size-cell {
185
+ width: 25%;
186
+ padding: 0.375rem 1rem;
187
+ text-align: left;
188
+ color: #666;
189
+ font-variant-numeric: tabular-nums;
190
+ }
191
+ .type-cell {
192
+ width: 25%;
193
+ padding: 0.375rem 1rem;
194
+ color: #666;
195
+ white-space: nowrap;
196
+ }
197
+ @media (max-width: 768px) {
198
+ body {
199
+ padding: 1rem 0.5rem;
200
+ }
201
+ h1 {
202
+ font-size: 1.125rem;
203
+ margin-bottom: 0.75rem;
204
+ }
205
+ thead {
206
+ display: none;
207
+ }
208
+ .name-cell a,
209
+ .size-cell,
210
+ .type-cell {
211
+ padding: 0.375rem 0.75rem;
212
+ }
213
+ .type-cell {
214
+ display: none;
215
+ }
216
+ .name-cell {
217
+ width: 60%;
218
+ }
219
+ .size-cell {
220
+ width: 40%;
221
+ text-align: right;
222
+ }
223
+ }
224
+ @media (max-width: 480px) {
225
+ body {
226
+ font-size: 13px;
227
+ }
228
+ .name-cell a,
229
+ .size-cell,
230
+ .type-cell {
231
+ padding: 0.375rem 0.5rem;
232
+ }
233
+ h1 {
234
+ font-size: 1rem;
235
+ }
236
+ .icon {
237
+ width: 12px;
238
+ height: 12px;
239
+ }
240
+ }
241
+ </style>
242
+ </head>
243
+ <body>
244
+ <div class="container">
245
+ <h1>Index of ${pathname}</h1>
246
+ <table>
247
+ <thead>
248
+ <tr>
249
+ <th class="name">Name</th>
250
+ <th class="size">Size</th>
251
+ <th class="type">Type</th>
252
+ </tr>
253
+ </thead>
254
+ <tbody>
255
+ ${tableRows}
256
+ </tbody>
257
+ </table>
258
+ </div>
259
+ </body>
260
+ </html>
261
+ `)
262
+ }
263
+
264
+ async function calculateDirectorySize(dirPath: string): Promise<number> {
265
+ let totalSize = 0
266
+
267
+ try {
268
+ let dirents = await fsp.readdir(dirPath, { withFileTypes: true })
269
+
270
+ for (let dirent of dirents) {
271
+ let fullPath = path.join(dirPath, dirent.name)
272
+
273
+ try {
274
+ if (dirent.isDirectory()) {
275
+ totalSize += await calculateDirectorySize(fullPath)
276
+ } else if (dirent.isFile()) {
277
+ let stats = await fsp.stat(fullPath)
278
+ totalSize += stats.size
279
+ }
280
+ } catch {
281
+ // Skip files/folders we can't access
282
+ }
283
+ }
284
+ } catch {
285
+ // If we can't read the directory, return 0
286
+ }
287
+
288
+ return totalSize
289
+ }
290
+
291
+ function formatFileSize(bytes: number): string {
292
+ if (bytes === 0) return '0 B'
293
+ let units = ['B', 'kB', 'MB', 'GB', 'TB']
294
+ let i = Math.floor(Math.log(bytes) / Math.log(1024))
295
+ let size = bytes / Math.pow(1024, i)
296
+ return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i]
297
+ }
package/src/lib/static.ts CHANGED
@@ -2,59 +2,89 @@ import * as path from 'node:path'
2
2
  import * as fsp from 'node:fs/promises'
3
3
  import { openFile } from '@remix-run/fs'
4
4
  import type { Middleware } from '@remix-run/fetch-router'
5
- import {
6
- file as sendFile,
7
- type FileResponseOptions,
8
- } from '@remix-run/fetch-router/response-helpers'
5
+ import { createFileResponse as sendFile, type FileResponseOptions } from '@remix-run/response/file'
9
6
 
10
- export interface StaticFilesOptions extends FileResponseOptions {
7
+ import { generateDirectoryListing } from './directory-listing.ts'
8
+
9
+ /**
10
+ * Function that determines if HTTP Range requests should be supported for a given file.
11
+ *
12
+ * @param file - The File object being served
13
+ * @returns true if range requests should be supported
14
+ */
15
+ export type AcceptRangesFunction = (file: File) => boolean
16
+
17
+ /**
18
+ * Options for the `staticFiles` middleware.
19
+ */
20
+ export interface StaticFilesOptions extends Omit<FileResponseOptions, 'acceptRanges'> {
11
21
  /**
12
22
  * Filter function to determine which files should be served.
13
23
  *
14
24
  * @param path The relative path being requested
15
- * @returns Whether to serve the file
25
+ * @return Whether to serve the file
16
26
  */
17
27
  filter?: (path: string) => boolean
28
+
29
+ /**
30
+ * Whether to support HTTP Range requests for partial content.
31
+ *
32
+ * Can be a boolean or a function that receives the file.
33
+ * When enabled, includes Accept-Ranges header and handles Range requests
34
+ * with 206 Partial Content responses.
35
+ *
36
+ * Defaults to enabling ranges only for non-compressible MIME types,
37
+ * as defined by `isCompressibleMimeType()` from `@remix-run/mime`.
38
+ *
39
+ * Note: Range requests and compression are mutually exclusive. When
40
+ * `Accept-Ranges: bytes` is present in the response headers, the compression
41
+ * middleware will not compress the response. This is why the default behavior
42
+ * enables ranges only for non-compressible types.
43
+ *
44
+ * @example
45
+ * // Force range request support for all files
46
+ * acceptRanges: true
47
+ *
48
+ * @example
49
+ * // Enable ranges for videos only
50
+ * acceptRanges: (file) => file.type.startsWith('video/')
51
+ */
52
+ acceptRanges?: boolean | AcceptRangesFunction
53
+
18
54
  /**
19
55
  * Files to try and serve as the index file when the request path targets a directory.
20
56
  *
21
- * - `true` (default): Use default index files `['index.html', 'index.htm']`
57
+ * - `true`: Use default index files `['index.html', 'index.htm']`
22
58
  * - `false`: Disable index file serving
23
59
  * - `string[]`: Custom list of index files to try in order
60
+ *
61
+ * @default true
24
62
  */
25
63
  index?: boolean | string[]
64
+ /**
65
+ * Whether to return an HTML page listing the files in a directory when the request path
66
+ * targets a directory. If both this and `index` are set, `index` takes precedence.
67
+ *
68
+ * @default false
69
+ */
70
+ listFiles?: boolean
26
71
  }
27
72
 
28
73
  /**
29
74
  * Creates a middleware that serves static files from the filesystem.
30
75
  *
31
- * Uses the URL pathname to resolve files, removing the leading slash to make it
32
- * a relative path. The middleware always falls through to the handler if the file
33
- * is not found or an error occurs.
76
+ * Uses the URL pathname to resolve files, removing the leading slash to make it a relative path.
77
+ * The middleware always falls through to the handler if the file is not found or an error occurs.
34
78
  *
35
79
  * @param root The root directory to serve files from (absolute or relative to cwd)
36
- * @param options (optional) configuration for file responses
37
- *
38
- * @example
39
- * let router = createRouter({
40
- * middleware: [staticFiles('./public')],
41
- * })
42
- *
43
- * @example
44
- * // With cache control
45
- * let router = createRouter({
46
- * middleware: [
47
- * staticFiles('./public', {
48
- * cacheControl: 'public, max-age=3600',
49
- * }),
50
- * ],
51
- * })
80
+ * @param options Configuration for file responses
81
+ * @return The static files middleware
52
82
  */
53
83
  export function staticFiles(root: string, options: StaticFilesOptions = {}): Middleware {
54
84
  // Ensure root is an absolute path
55
85
  root = path.resolve(root)
56
86
 
57
- let { filter, index: indexOption, ...fileOptions } = options
87
+ let { acceptRanges, filter, index: indexOption, listFiles, ...fileOptions } = options
58
88
 
59
89
  // Normalize index option
60
90
  let index: string[]
@@ -99,6 +129,11 @@ export function staticFiles(root: string, options: StaticFilesOptions = {}): Mid
99
129
  // Index file doesn't exist, continue to next
100
130
  }
101
131
  }
132
+
133
+ // If no index file found and listFiles is enabled, show directory listing
134
+ if (!filePath && listFiles) {
135
+ return generateDirectoryListing(targetPath, context.url.pathname)
136
+ }
102
137
  }
103
138
  } catch {
104
139
  // Path doesn't exist or isn't accessible, fall through
@@ -107,7 +142,20 @@ export function staticFiles(root: string, options: StaticFilesOptions = {}): Mid
107
142
  if (filePath) {
108
143
  let fileName = path.relative(root, filePath)
109
144
  let file = openFile(filePath, { name: fileName })
110
- return sendFile(file, context.request, fileOptions)
145
+
146
+ let finalFileOptions: FileResponseOptions = { ...fileOptions }
147
+
148
+ // If acceptRanges is a function, evaluate it with the file
149
+ // Otherwise, pass it directly to sendFile
150
+ if (typeof acceptRanges === 'function') {
151
+ finalFileOptions.acceptRanges = acceptRanges(file)
152
+ } else if (acceptRanges !== undefined) {
153
+ finalFileOptions.acceptRanges = acceptRanges
154
+ }
155
+
156
+ return sendFile(file, context.request, finalFileOptions)
111
157
  }
158
+
159
+ return next()
112
160
  }
113
161
  }