@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 +2 -0
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/directory-listing.d.ts +2 -0
- package/dist/lib/directory-listing.d.ts.map +1 -0
- package/dist/lib/directory-listing.js +278 -0
- package/dist/lib/static.d.ts +16 -3
- package/dist/lib/static.d.ts.map +1 -1
- package/dist/lib/static.js +55 -8
- package/package.json +11 -5
- package/src/index.ts +1 -3
- package/src/lib/directory-listing.ts +297 -0
- package/src/lib/static.ts +69 -10
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
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,
|
|
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 @@
|
|
|
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
|
+
}
|
package/dist/lib/static.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
*
|
package/dist/lib/static.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"static.d.ts","sourceRoot":"","sources":["../../src/lib/static.ts"],"names":[],"mappings":"
|
|
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"}
|
package/dist/lib/static.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
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
|
-
|
|
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.
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
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.
|
|
32
|
-
"@remix-run/
|
|
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.
|
|
36
|
-
"@remix-run/
|
|
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
|
@@ -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
|
|
2
|
-
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
84
|
+
let targetPath = path.join(root, relativePath)
|
|
85
|
+
let filePath: string | undefined
|
|
56
86
|
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
}
|