@remix-run/static-middleware 0.1.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,6 +1,6 @@
1
- import { type FileResponseOptions } from '@remix-run/fetch-router/response-helpers';
2
1
  import type { Middleware } from '@remix-run/fetch-router';
3
- export type StaticFilesOptions = FileResponseOptions & {
2
+ import { type FileResponseOptions } from '@remix-run/response/file';
3
+ export interface StaticFilesOptions extends FileResponseOptions {
4
4
  /**
5
5
  * Filter function to determine which files should be served.
6
6
  *
@@ -8,7 +8,20 @@ export type StaticFilesOptions = FileResponseOptions & {
8
8
  * @returns Whether to serve the file
9
9
  */
10
10
  filter?: (path: string) => boolean;
11
- };
11
+ /**
12
+ * Files to try and serve as the index file when the request path targets a directory.
13
+ *
14
+ * - `true` (default): Use default index files `['index.html', 'index.htm']`
15
+ * - `false`: Disable index file serving
16
+ * - `string[]`: Custom list of index files to try in order
17
+ */
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;
24
+ }
12
25
  /**
13
26
  * Creates a middleware that serves static files from the filesystem.
14
27
  *
@@ -1 +1 @@
1
- {"version":3,"file":"static.d.ts","sourceRoot":"","sources":["../../src/lib/static.ts"],"names":[],"mappings":"AAEA,OAAO,EAAQ,KAAK,mBAAmB,EAAE,MAAM,0CAA0C,CAAA;AACzF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAA;AAEzD,MAAM,MAAM,kBAAkB,GAAG,mBAAmB,GAAG;IACrD;;;;;OAKG;IACH,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAA;CACnC,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,kBAAuB,GAAG,UAAU,CAsBtF"}
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,5 +1,8 @@
1
- import { findFile } from '@remix-run/lazy-file/fs';
2
- import { file } from '@remix-run/fetch-router/response-helpers';
1
+ import * as path from 'node:path';
2
+ import * as fsp from 'node:fs/promises';
3
+ import { openFile } from '@remix-run/fs';
4
+ import { createFileResponse as sendFile } from '@remix-run/response/file';
5
+ import { generateDirectoryListing } from "./directory-listing.js";
3
6
  /**
4
7
  * Creates a middleware that serves static files from the filesystem.
5
8
  *
@@ -26,19 +29,63 @@ import { file } from '@remix-run/fetch-router/response-helpers';
26
29
  * })
27
30
  */
28
31
  export function staticFiles(root, options = {}) {
29
- let { filter, ...fileOptions } = options;
32
+ // Ensure root is an absolute path
33
+ root = path.resolve(root);
34
+ let { filter, index: indexOption, listFiles, ...fileOptions } = options;
35
+ // Normalize index option
36
+ let index;
37
+ if (indexOption === false) {
38
+ index = [];
39
+ }
40
+ else if (indexOption === true || indexOption === undefined) {
41
+ index = ['index.html', 'index.htm'];
42
+ }
43
+ else {
44
+ index = indexOption;
45
+ }
30
46
  return async (context, next) => {
31
- if (context.request.method !== 'GET' && context.request.method !== 'HEAD') {
47
+ if (context.method !== 'GET' && context.method !== 'HEAD') {
32
48
  return next();
33
49
  }
34
50
  let relativePath = context.url.pathname.replace(/^\/+/, '');
35
51
  if (filter && !filter(relativePath)) {
36
52
  return next();
37
53
  }
38
- let fileToServe = await findFile(root, relativePath);
39
- if (!fileToServe) {
40
- return next();
54
+ let targetPath = path.join(root, relativePath);
55
+ let filePath;
56
+ try {
57
+ let stats = await fsp.stat(targetPath);
58
+ if (stats.isFile()) {
59
+ filePath = targetPath;
60
+ }
61
+ else if (stats.isDirectory()) {
62
+ // Try each index file in turn
63
+ for (let indexFile of index) {
64
+ let indexPath = path.join(targetPath, indexFile);
65
+ try {
66
+ let indexStats = await fsp.stat(indexPath);
67
+ if (indexStats.isFile()) {
68
+ filePath = indexPath;
69
+ break;
70
+ }
71
+ }
72
+ catch {
73
+ // Index file doesn't exist, continue to next
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
+ }
80
+ }
81
+ }
82
+ catch {
83
+ // Path doesn't exist or isn't accessible, fall through
84
+ }
85
+ if (filePath) {
86
+ let fileName = path.relative(root, filePath);
87
+ let file = openFile(filePath, { name: fileName });
88
+ return sendFile(file, context.request, fileOptions);
41
89
  }
42
- return file(fileToServe, context.request, fileOptions);
43
90
  };
44
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remix-run/static-middleware",
3
- "version": "0.1.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,12 +28,15 @@
28
28
  "devDependencies": {
29
29
  "@types/node": "^24.6.0",
30
30
  "typescript": "^5.9.3",
31
- "@remix-run/fetch-router": "0.9.0",
32
- "@remix-run/lazy-file": "3.8.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"
33
34
  },
34
35
  "peerDependencies": {
35
- "@remix-run/fetch-router": "^0.9.0",
36
- "@remix-run/lazy-file": "^3.8.0"
36
+ "@remix-run/fetch-router": "^0.12.0",
37
+ "@remix-run/fs": "^0.1.0",
38
+ "@remix-run/response": "^0.1.0",
39
+ "@remix-run/html-template": "^0.3.0"
37
40
  },
38
41
  "keywords": [
39
42
  "fetch",
@@ -42,6 +45,9 @@
42
45
  "static-files",
43
46
  "file-server"
44
47
  ],
48
+ "dependencies": {
49
+ "mrmime": "^2.0.0"
50
+ },
45
51
  "scripts": {
46
52
  "build": "tsc -p tsconfig.build.json",
47
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
@@ -1,9 +1,12 @@
1
- import { findFile } from '@remix-run/lazy-file/fs'
2
-
3
- import { file, type FileResponseOptions } from '@remix-run/fetch-router/response-helpers'
1
+ import * as path from 'node:path'
2
+ import * as fsp from 'node:fs/promises'
3
+ import { openFile } from '@remix-run/fs'
4
4
  import type { Middleware } from '@remix-run/fetch-router'
5
+ import { createFileResponse as sendFile, type FileResponseOptions } from '@remix-run/response/file'
6
+
7
+ import { generateDirectoryListing } from './directory-listing.ts'
5
8
 
6
- export type StaticFilesOptions = FileResponseOptions & {
9
+ export interface StaticFilesOptions extends FileResponseOptions {
7
10
  /**
8
11
  * Filter function to determine which files should be served.
9
12
  *
@@ -11,6 +14,19 @@ export type StaticFilesOptions = FileResponseOptions & {
11
14
  * @returns Whether to serve the file
12
15
  */
13
16
  filter?: (path: string) => boolean
17
+ /**
18
+ * Files to try and serve as the index file when the request path targets a directory.
19
+ *
20
+ * - `true` (default): Use default index files `['index.html', 'index.htm']`
21
+ * - `false`: Disable index file serving
22
+ * - `string[]`: Custom list of index files to try in order
23
+ */
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
14
30
  }
15
31
 
16
32
  /**
@@ -39,10 +55,23 @@ export type StaticFilesOptions = FileResponseOptions & {
39
55
  * })
40
56
  */
41
57
  export function staticFiles(root: string, options: StaticFilesOptions = {}): Middleware {
42
- let { filter, ...fileOptions } = options
58
+ // Ensure root is an absolute path
59
+ root = path.resolve(root)
60
+
61
+ let { filter, index: indexOption, listFiles, ...fileOptions } = options
62
+
63
+ // Normalize index option
64
+ let index: string[]
65
+ if (indexOption === false) {
66
+ index = []
67
+ } else if (indexOption === true || indexOption === undefined) {
68
+ index = ['index.html', 'index.htm']
69
+ } else {
70
+ index = indexOption
71
+ }
43
72
 
44
73
  return async (context, next) => {
45
- if (context.request.method !== 'GET' && context.request.method !== 'HEAD') {
74
+ if (context.method !== 'GET' && context.method !== 'HEAD') {
46
75
  return next()
47
76
  }
48
77
 
@@ -52,12 +81,42 @@ export function staticFiles(root: string, options: StaticFilesOptions = {}): Mid
52
81
  return next()
53
82
  }
54
83
 
55
- let fileToServe = await findFile(root, relativePath)
84
+ let targetPath = path.join(root, relativePath)
85
+ let filePath: string | undefined
56
86
 
57
- if (!fileToServe) {
58
- return next()
87
+ try {
88
+ let stats = await fsp.stat(targetPath)
89
+
90
+ if (stats.isFile()) {
91
+ filePath = targetPath
92
+ } else if (stats.isDirectory()) {
93
+ // Try each index file in turn
94
+ for (let indexFile of index) {
95
+ let indexPath = path.join(targetPath, indexFile)
96
+ try {
97
+ let indexStats = await fsp.stat(indexPath)
98
+ if (indexStats.isFile()) {
99
+ filePath = indexPath
100
+ break
101
+ }
102
+ } catch {
103
+ // Index file doesn't exist, continue to next
104
+ }
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
+ }
111
+ }
112
+ } catch {
113
+ // Path doesn't exist or isn't accessible, fall through
59
114
  }
60
115
 
61
- return file(fileToServe, context.request, fileOptions)
116
+ if (filePath) {
117
+ let fileName = path.relative(root, filePath)
118
+ let file = openFile(filePath, { name: fileName })
119
+ return sendFile(file, context.request, fileOptions)
120
+ }
62
121
  }
63
122
  }