@dnax/serve 0.0.0-stage → 0.0.1
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 +81 -2
- package/index.ts +8 -0
- package/package.json +25 -4
- package/src/cli.ts +594 -0
- package/src/listing.ts +173 -0
- package/src/mime.ts +154 -0
- package/src/server.ts +861 -0
package/src/listing.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
3
|
+
import type { Dirent, Stats } from "node:fs";
|
|
4
|
+
|
|
5
|
+
export interface ListingOptions {
|
|
6
|
+
hidePermissions: boolean;
|
|
7
|
+
dotfiles: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DIR_COLOR = "#a06a00";
|
|
11
|
+
|
|
12
|
+
function escapeHtml(value: string): string {
|
|
13
|
+
return value
|
|
14
|
+
.replace(/&/g, "&")
|
|
15
|
+
.replace(/</g, "<")
|
|
16
|
+
.replace(/>/g, ">")
|
|
17
|
+
.replace(/"/g, """)
|
|
18
|
+
.replace(/'/g, "'");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function permissions(mode: number, isDir: boolean): string {
|
|
22
|
+
let result = isDir ? "d" : "-";
|
|
23
|
+
for (const shift of [6, 3, 0]) {
|
|
24
|
+
const bits = (mode >> shift) & 7;
|
|
25
|
+
result += bits & 4 ? "r" : "-";
|
|
26
|
+
result += bits & 2 ? "w" : "-";
|
|
27
|
+
result += bits & 1 ? "x" : "-";
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatSize(size: number, isDir: boolean): string {
|
|
33
|
+
if (isDir) return "";
|
|
34
|
+
if (size < 1024) return `${size}B`;
|
|
35
|
+
const units = ["k", "M", "G", "T", "P", "E"];
|
|
36
|
+
let value = size;
|
|
37
|
+
let unit = -1;
|
|
38
|
+
do {
|
|
39
|
+
value /= 1024;
|
|
40
|
+
unit += 1;
|
|
41
|
+
} while (value >= 1024 && unit < units.length - 1);
|
|
42
|
+
return `${value.toFixed(1)}${units[unit] ?? "?"}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
46
|
+
|
|
47
|
+
function formatDate(date: Date): string {
|
|
48
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
49
|
+
const month = MONTHS[date.getMonth()] ?? "???";
|
|
50
|
+
const year = date.getFullYear();
|
|
51
|
+
const hour = String(date.getHours()).padStart(2, "0");
|
|
52
|
+
const minute = String(date.getMinutes()).padStart(2, "0");
|
|
53
|
+
return `${day}-${month}-${year} ${hour}:${minute}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ListingEntry {
|
|
57
|
+
name: string;
|
|
58
|
+
isDirectory: boolean;
|
|
59
|
+
stats?: Stats;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sortEntries(entries: ListingEntry[]): ListingEntry[] {
|
|
63
|
+
const collator = new Intl.Collator();
|
|
64
|
+
return entries.sort((a, b) => {
|
|
65
|
+
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
|
66
|
+
return collator.compare(a.name, b.name);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function statOrUndefined(path: string): Promise<Stats | undefined> {
|
|
71
|
+
try {
|
|
72
|
+
return await stat(path);
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const STYLE = `
|
|
79
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 2rem; color: #222; }
|
|
80
|
+
h1 { font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem; }
|
|
81
|
+
table { border-collapse: collapse; font-size: 0.875rem; width: 100%; max-width: 64rem; }
|
|
82
|
+
th { text-align: left; font-weight: 600; border-bottom: 1px solid #ddd; padding: 0.25rem 0.75rem 0.25rem 0; }
|
|
83
|
+
td { white-space: nowrap; padding: 0.15rem 0.75rem 0.15rem 0; }
|
|
84
|
+
td.size { text-align: right; }
|
|
85
|
+
td.perms { color: #777; }
|
|
86
|
+
a { color: #0b5fff; text-decoration: none; }
|
|
87
|
+
a:hover { text-decoration: underline; }
|
|
88
|
+
tr.dir a { color: ${DIR_COLOR}; font-weight: 500; }
|
|
89
|
+
address { margin-top: 1.5rem; color: #888; font-size: 0.8125rem; font-style: normal; }
|
|
90
|
+
`.trim();
|
|
91
|
+
|
|
92
|
+
export async function renderDirectoryListing(
|
|
93
|
+
dir: string,
|
|
94
|
+
root: string,
|
|
95
|
+
urlPath: string,
|
|
96
|
+
host: string,
|
|
97
|
+
options: ListingOptions,
|
|
98
|
+
): Promise<string> {
|
|
99
|
+
const entries: ListingEntry[] = [];
|
|
100
|
+
|
|
101
|
+
let dirents: Dirent[] = [];
|
|
102
|
+
try {
|
|
103
|
+
dirents = await readdir(dir, { withFileTypes: true });
|
|
104
|
+
} catch {
|
|
105
|
+
dirents = [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const dirent of dirents) {
|
|
109
|
+
if (!options.dotfiles && dirent.name.startsWith(".")) continue;
|
|
110
|
+
const stats = await statOrUndefined(join(dir, dirent.name));
|
|
111
|
+
entries.push({
|
|
112
|
+
name: dirent.name,
|
|
113
|
+
isDirectory: stats ? stats.isDirectory() : dirent.isDirectory(),
|
|
114
|
+
stats,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (relative(root, dir) !== "") {
|
|
119
|
+
const stats = await statOrUndefined(dirname(dir));
|
|
120
|
+
entries.push({ name: "..", isDirectory: true, stats });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
sortEntries(entries);
|
|
124
|
+
|
|
125
|
+
const rows = entries.map((entry) => {
|
|
126
|
+
const stats = entry.stats;
|
|
127
|
+
const displayName = escapeHtml(entry.name) + (entry.isDirectory ? "/" : "");
|
|
128
|
+
let href = `./${encodeURIComponent(entry.name)}`;
|
|
129
|
+
if (entry.isDirectory && entry.name !== "..") href += "/";
|
|
130
|
+
const perms = stats && !options.hidePermissions ? `<code>(${permissions(stats.mode, entry.isDirectory)})</code>` : "";
|
|
131
|
+
const modified = stats ? formatDate(stats.mtime) : "???";
|
|
132
|
+
const size = stats ? formatSize(stats.size, entry.isDirectory) : "???";
|
|
133
|
+
return (
|
|
134
|
+
`<tr class="${entry.isDirectory ? "dir" : "file"}">` +
|
|
135
|
+
`<td class="perms">${perms}</td>` +
|
|
136
|
+
`<td class="modified">${modified}</td>` +
|
|
137
|
+
`<td class="size"><code>${size}</code></td>` +
|
|
138
|
+
`<td class="name"><a href="${href}">${displayName}</a></td>` +
|
|
139
|
+
`</tr>`
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const title = `Index of ${escapeHtml(urlPath)}`;
|
|
144
|
+
|
|
145
|
+
return [
|
|
146
|
+
"<!doctype html>",
|
|
147
|
+
'<html lang="en">',
|
|
148
|
+
"<head>",
|
|
149
|
+
' <meta charset="utf-8">',
|
|
150
|
+
' <meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
151
|
+
` <title>${title}</title>`,
|
|
152
|
+
` <style>${STYLE}</style>`,
|
|
153
|
+
"</head>",
|
|
154
|
+
"<body>",
|
|
155
|
+
`<h1>${title}</h1>`,
|
|
156
|
+
"<table>",
|
|
157
|
+
'<thead><tr><th></th><th>Last modified</th><th>Size</th><th>Name</th></tr></thead>',
|
|
158
|
+
"<tbody>",
|
|
159
|
+
...rows,
|
|
160
|
+
"</tbody>",
|
|
161
|
+
"</table>",
|
|
162
|
+
`<address>dnx running @ ${escapeHtml(host)}</address>`,
|
|
163
|
+
"</body>",
|
|
164
|
+
"</html>",
|
|
165
|
+
"",
|
|
166
|
+
].join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function isDotfilePath(relativePath: string): boolean {
|
|
170
|
+
return relativePath
|
|
171
|
+
.split(sep)
|
|
172
|
+
.some((segment) => segment !== "." && segment !== ".." && segment.startsWith("."));
|
|
173
|
+
}
|
package/src/mime.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { extname } from "node:path";
|
|
2
|
+
|
|
3
|
+
const MIME: Readonly<Record<string, string>> = {
|
|
4
|
+
html: "text/html",
|
|
5
|
+
htm: "text/html",
|
|
6
|
+
xhtml: "application/xhtml+xml",
|
|
7
|
+
css: "text/css",
|
|
8
|
+
js: "text/javascript",
|
|
9
|
+
mjs: "text/javascript",
|
|
10
|
+
cjs: "text/javascript",
|
|
11
|
+
json: "application/json",
|
|
12
|
+
map: "application/json",
|
|
13
|
+
jsonld: "application/ld+json",
|
|
14
|
+
xml: "application/xml",
|
|
15
|
+
txt: "text/plain",
|
|
16
|
+
text: "text/plain",
|
|
17
|
+
md: "text/markdown",
|
|
18
|
+
markdown: "text/markdown",
|
|
19
|
+
csv: "text/csv",
|
|
20
|
+
tsv: "text/tab-separated-values",
|
|
21
|
+
yaml: "text/yaml",
|
|
22
|
+
yml: "text/yaml",
|
|
23
|
+
toml: "application/toml",
|
|
24
|
+
ini: "text/plain",
|
|
25
|
+
sh: "text/x-sh",
|
|
26
|
+
bash: "text/x-sh",
|
|
27
|
+
ts: "video/mp2t",
|
|
28
|
+
pdf: "application/pdf",
|
|
29
|
+
rtf: "application/rtf",
|
|
30
|
+
doc: "application/msword",
|
|
31
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
32
|
+
xls: "application/vnd.ms-excel",
|
|
33
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
34
|
+
ppt: "application/vnd.ms-powerpoint",
|
|
35
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
36
|
+
odt: "application/vnd.oasis.opendocument.text",
|
|
37
|
+
ods: "application/vnd.oasis.opendocument.spreadsheet",
|
|
38
|
+
odp: "application/vnd.oasis.opendocument.presentation",
|
|
39
|
+
epub: "application/epub+zip",
|
|
40
|
+
zip: "application/zip",
|
|
41
|
+
gz: "application/gzip",
|
|
42
|
+
tar: "application/x-tar",
|
|
43
|
+
rar: "application/vnd.rar",
|
|
44
|
+
"7z": "application/x-7z-compressed",
|
|
45
|
+
bz2: "application/x-bzip2",
|
|
46
|
+
xz: "application/x-xz",
|
|
47
|
+
wasm: "application/wasm",
|
|
48
|
+
bin: "application/octet-stream",
|
|
49
|
+
exe: "application/octet-stream",
|
|
50
|
+
dmg: "application/octet-stream",
|
|
51
|
+
iso: "application/octet-stream",
|
|
52
|
+
svg: "image/svg+xml",
|
|
53
|
+
svgz: "image/svg+xml",
|
|
54
|
+
png: "image/png",
|
|
55
|
+
apng: "image/apng",
|
|
56
|
+
jpg: "image/jpeg",
|
|
57
|
+
jpeg: "image/jpeg",
|
|
58
|
+
jpe: "image/jpeg",
|
|
59
|
+
gif: "image/gif",
|
|
60
|
+
webp: "image/webp",
|
|
61
|
+
avif: "image/avif",
|
|
62
|
+
ico: "image/x-icon",
|
|
63
|
+
bmp: "image/bmp",
|
|
64
|
+
tif: "image/tiff",
|
|
65
|
+
tiff: "image/tiff",
|
|
66
|
+
heic: "image/heic",
|
|
67
|
+
heif: "image/heif",
|
|
68
|
+
jxl: "image/jxl",
|
|
69
|
+
woff: "font/woff",
|
|
70
|
+
woff2: "font/woff2",
|
|
71
|
+
ttf: "font/ttf",
|
|
72
|
+
otf: "font/otf",
|
|
73
|
+
eot: "application/vnd.ms-fontobject",
|
|
74
|
+
mp3: "audio/mpeg",
|
|
75
|
+
m4a: "audio/mp4",
|
|
76
|
+
aac: "audio/aac",
|
|
77
|
+
wav: "audio/wav",
|
|
78
|
+
oga: "audio/ogg",
|
|
79
|
+
ogg: "audio/ogg",
|
|
80
|
+
opus: "audio/ogg",
|
|
81
|
+
flac: "audio/flac",
|
|
82
|
+
weba: "audio/webm",
|
|
83
|
+
mid: "audio/midi",
|
|
84
|
+
midi: "audio/midi",
|
|
85
|
+
mp4: "video/mp4",
|
|
86
|
+
m4v: "video/mp4",
|
|
87
|
+
mpeg: "video/mpeg",
|
|
88
|
+
mpg: "video/mpeg",
|
|
89
|
+
mov: "video/quicktime",
|
|
90
|
+
webm: "video/webm",
|
|
91
|
+
ogv: "video/ogg",
|
|
92
|
+
avi: "video/x-msvideo",
|
|
93
|
+
mkv: "video/x-matroska",
|
|
94
|
+
m3u8: "application/vnd.apple.mpegurl",
|
|
95
|
+
webmanifest: "application/manifest+json",
|
|
96
|
+
manifest: "application/manifest+json",
|
|
97
|
+
appcache: "text/cache-manifest",
|
|
98
|
+
vtt: "text/vtt",
|
|
99
|
+
srt: "application/x-subrip",
|
|
100
|
+
ipynb: "application/x-ipynb+json",
|
|
101
|
+
sql: "application/sql",
|
|
102
|
+
rss: "application/rss+xml",
|
|
103
|
+
atom: "application/atom+xml",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export type MimeOverrides = Readonly<Record<string, string>>;
|
|
107
|
+
|
|
108
|
+
export function lookupMime(filePath: string, overrides?: MimeOverrides): string {
|
|
109
|
+
const ext = extname(filePath).slice(1).toLowerCase();
|
|
110
|
+
return overrides?.[ext] ?? MIME[ext] ?? "application/octet-stream";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function withCharset(type: string): string {
|
|
114
|
+
if (/;\s*charset=/i.test(type)) return type;
|
|
115
|
+
if (
|
|
116
|
+
type.startsWith("text/") ||
|
|
117
|
+
type === "application/json" ||
|
|
118
|
+
type === "application/xml" ||
|
|
119
|
+
type === "application/xhtml+xml" ||
|
|
120
|
+
type === "application/manifest+json" ||
|
|
121
|
+
type === "application/rss+xml" ||
|
|
122
|
+
type === "application/atom+xml" ||
|
|
123
|
+
type === "image/svg+xml"
|
|
124
|
+
) {
|
|
125
|
+
return `${type}; charset=utf-8`;
|
|
126
|
+
}
|
|
127
|
+
return type;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function parseMimeTypes(input: string): MimeOverrides {
|
|
131
|
+
const text = input.trim();
|
|
132
|
+
if (text.startsWith("{")) {
|
|
133
|
+
const parsed = JSON.parse(text) as Record<string, unknown>;
|
|
134
|
+
const result: Record<string, string> = {};
|
|
135
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
136
|
+
if (Array.isArray(value)) {
|
|
137
|
+
for (const ext of value) result[String(ext).replace(/^\./, "").toLowerCase()] = key;
|
|
138
|
+
} else if (typeof value === "string") {
|
|
139
|
+
result[key.replace(/^\./, "").toLowerCase()] = value;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const result: Record<string, string> = {};
|
|
146
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
147
|
+
const line = rawLine.replace(/#.*/, "").trim();
|
|
148
|
+
if (!line) continue;
|
|
149
|
+
const [type, ...exts] = line.split(/\s+/);
|
|
150
|
+
if (!type) continue;
|
|
151
|
+
for (const ext of exts) result[ext.replace(/^\./, "").toLowerCase()] = type;
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
}
|