@remix-run/static-middleware 0.2.0 → 0.3.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 { lookup } from 'mrmime';
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 = lookup(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,5 +1,5 @@
1
1
  import type { Middleware } from '@remix-run/fetch-router';
2
- import { type FileResponseOptions } from '@remix-run/fetch-router/response-helpers';
2
+ import { type FileResponseOptions } from '@remix-run/response/file';
3
3
  export interface StaticFilesOptions extends FileResponseOptions {
4
4
  /**
5
5
  * Filter function to determine which files should be served.
@@ -16,6 +16,11 @@ export interface StaticFilesOptions extends FileResponseOptions {
16
16
  * - `string[]`: Custom list of index files to try in order
17
17
  */
18
18
  index?: boolean | string[];
19
+ /**
20
+ * Whether to return an HTML page listing the files in a directory when the request path
21
+ * targets a directory. If both this and `index` are set, `index` takes precedence.
22
+ */
23
+ listFiles?: boolean;
19
24
  }
20
25
  /**
21
26
  * Creates a middleware that serves static files from the filesystem.
@@ -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,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;IAC1B;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,kBAAuB,GAAG,UAAU,CAiEtF"}
@@ -1,7 +1,8 @@
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
  *
@@ -30,7 +31,7 @@ import { file as sendFile, } from '@remix-run/fetch-router/response-helpers';
30
31
  export function staticFiles(root, options = {}) {
31
32
  // Ensure root is an absolute path
32
33
  root = path.resolve(root);
33
- let { filter, index: indexOption, ...fileOptions } = options;
34
+ let { filter, index: indexOption, listFiles, ...fileOptions } = options;
34
35
  // Normalize index option
35
36
  let index;
36
37
  if (indexOption === false) {
@@ -72,6 +73,10 @@ export function staticFiles(root, options = {}) {
72
73
  // Index file doesn't exist, continue to next
73
74
  }
74
75
  }
76
+ // If no index file found and listFiles is enabled, show directory listing
77
+ if (!filePath && listFiles) {
78
+ return generateDirectoryListing(targetPath, context.url.pathname);
79
+ }
75
80
  }
76
81
  }
77
82
  catch {
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.3.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,15 @@
28
28
  "devDependencies": {
29
29
  "@types/node": "^24.6.0",
30
30
  "typescript": "^5.9.3",
31
- "@remix-run/fetch-router": "0.10.0",
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"
31
+ "@remix-run/fetch-router": "0.12.0",
32
+ "@remix-run/method-override-middleware": "0.1.0",
33
+ "@remix-run/form-data-middleware": "0.1.0"
36
34
  },
37
35
  "peerDependencies": {
38
- "@remix-run/fetch-router": "^0.10.0",
36
+ "@remix-run/fetch-router": "^0.12.0",
39
37
  "@remix-run/fs": "^0.1.0",
40
- "@remix-run/lazy-file": "^4.0.0"
38
+ "@remix-run/response": "^0.1.0",
39
+ "@remix-run/html-template": "^0.3.0"
41
40
  },
42
41
  "keywords": [
43
42
  "fetch",
@@ -46,6 +45,9 @@
46
45
  "static-files",
47
46
  "file-server"
48
47
  ],
48
+ "dependencies": {
49
+ "mrmime": "^2.0.0"
50
+ },
49
51
  "scripts": {
50
52
  "build": "tsc -p tsconfig.build.json",
51
53
  "clean": "git clean -fdX",
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 { lookup } from 'mrmime'
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 = lookup(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,10 +2,9 @@ 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'
6
+
7
+ import { generateDirectoryListing } from './directory-listing.ts'
9
8
 
10
9
  export interface StaticFilesOptions extends FileResponseOptions {
11
10
  /**
@@ -23,6 +22,11 @@ export interface StaticFilesOptions extends FileResponseOptions {
23
22
  * - `string[]`: Custom list of index files to try in order
24
23
  */
25
24
  index?: boolean | string[]
25
+ /**
26
+ * Whether to return an HTML page listing the files in a directory when the request path
27
+ * targets a directory. If both this and `index` are set, `index` takes precedence.
28
+ */
29
+ listFiles?: boolean
26
30
  }
27
31
 
28
32
  /**
@@ -54,7 +58,7 @@ export function staticFiles(root: string, options: StaticFilesOptions = {}): Mid
54
58
  // Ensure root is an absolute path
55
59
  root = path.resolve(root)
56
60
 
57
- let { filter, index: indexOption, ...fileOptions } = options
61
+ let { filter, index: indexOption, listFiles, ...fileOptions } = options
58
62
 
59
63
  // Normalize index option
60
64
  let index: string[]
@@ -99,6 +103,11 @@ export function staticFiles(root: string, options: StaticFilesOptions = {}): Mid
99
103
  // Index file doesn't exist, continue to next
100
104
  }
101
105
  }
106
+
107
+ // If no index file found and listFiles is enabled, show directory listing
108
+ if (!filePath && listFiles) {
109
+ return generateDirectoryListing(targetPath, context.url.pathname)
110
+ }
102
111
  }
103
112
  } catch {
104
113
  // Path doesn't exist or isn't accessible, fall through