busyserver 1.0.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/LICENSE +21 -0
- package/README.md +144 -0
- package/bin/busyserver.js +246 -0
- package/package.json +44 -0
- package/src/config.js +60 -0
- package/src/dashboard.js +32 -0
- package/src/mock.js +45 -0
- package/src/proxy.js +46 -0
- package/src/server.js +734 -0
- package/src/templates.js +330 -0
- package/src/watcher.js +106 -0
package/src/templates.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
|
|
4
|
+
function escapeHtml(str) {
|
|
5
|
+
return String(str)
|
|
6
|
+
.replaceAll("&", "&")
|
|
7
|
+
.replaceAll("<", "<")
|
|
8
|
+
.replaceAll(">", ">")
|
|
9
|
+
.replaceAll('"', """)
|
|
10
|
+
.replaceAll("'", "'");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatBytes(bytes) {
|
|
14
|
+
if (bytes === 0) return '0 B';
|
|
15
|
+
const k = 1024;
|
|
16
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
17
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
18
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const themeStyles = `
|
|
22
|
+
:root {
|
|
23
|
+
--bg: #ffffff;
|
|
24
|
+
--text: #0f172a;
|
|
25
|
+
--muted: #64748b;
|
|
26
|
+
--border: #e2e8f0;
|
|
27
|
+
--hover: #f1f5f9;
|
|
28
|
+
--primary: #0ea5e9;
|
|
29
|
+
--card: #ffffff;
|
|
30
|
+
--danger: #ef4444;
|
|
31
|
+
}
|
|
32
|
+
@media (prefers-color-scheme: dark) {
|
|
33
|
+
:root {
|
|
34
|
+
--bg: #0f172a;
|
|
35
|
+
--text: #f8fafc;
|
|
36
|
+
--muted: #94a3b8;
|
|
37
|
+
--border: #334155;
|
|
38
|
+
--hover: #1e293b;
|
|
39
|
+
--primary: #38bdf8;
|
|
40
|
+
--card: #1e293b;
|
|
41
|
+
--danger: #f87171;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
body {
|
|
45
|
+
margin: 0;
|
|
46
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
47
|
+
background-color: var(--bg);
|
|
48
|
+
color: var(--text);
|
|
49
|
+
line-height: 1.5;
|
|
50
|
+
}
|
|
51
|
+
* { box-sizing: border-box; }
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
function getErrorPage(statusCode, message, urlPath) {
|
|
55
|
+
return `<!DOCTYPE html>
|
|
56
|
+
<html lang="en">
|
|
57
|
+
<head>
|
|
58
|
+
<meta charset="UTF-8">
|
|
59
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
60
|
+
<title>${statusCode} - ${escapeHtml(message)}</title>
|
|
61
|
+
<style>
|
|
62
|
+
${themeStyles}
|
|
63
|
+
.container {
|
|
64
|
+
min-height: 100vh;
|
|
65
|
+
display: flex;
|
|
66
|
+
flex-direction: column;
|
|
67
|
+
align-items: center;
|
|
68
|
+
justify-content: center;
|
|
69
|
+
padding: 2rem;
|
|
70
|
+
text-align: center;
|
|
71
|
+
}
|
|
72
|
+
h1 {
|
|
73
|
+
font-size: 6rem;
|
|
74
|
+
margin: 0;
|
|
75
|
+
color: var(--danger);
|
|
76
|
+
line-height: 1;
|
|
77
|
+
}
|
|
78
|
+
h2 {
|
|
79
|
+
font-size: 2rem;
|
|
80
|
+
margin: 1rem 0;
|
|
81
|
+
}
|
|
82
|
+
p {
|
|
83
|
+
color: var(--muted);
|
|
84
|
+
margin-bottom: 2rem;
|
|
85
|
+
word-break: break-all;
|
|
86
|
+
max-width: 600px;
|
|
87
|
+
}
|
|
88
|
+
a {
|
|
89
|
+
color: var(--primary);
|
|
90
|
+
text-decoration: none;
|
|
91
|
+
font-weight: 500;
|
|
92
|
+
padding: 0.75rem 1.5rem;
|
|
93
|
+
border: 1px solid var(--primary);
|
|
94
|
+
border-radius: 8px;
|
|
95
|
+
transition: all 0.2s;
|
|
96
|
+
}
|
|
97
|
+
a:hover {
|
|
98
|
+
background-color: var(--primary);
|
|
99
|
+
color: var(--bg);
|
|
100
|
+
}
|
|
101
|
+
</style>
|
|
102
|
+
</head>
|
|
103
|
+
<body>
|
|
104
|
+
<div class="container">
|
|
105
|
+
<h1>${statusCode}</h1>
|
|
106
|
+
<h2>${escapeHtml(message)}</h2>
|
|
107
|
+
${urlPath ? `<p>${escapeHtml(urlPath)}</p>` : ''}
|
|
108
|
+
<a href="/">← Return Home</a>
|
|
109
|
+
</div>
|
|
110
|
+
</body>
|
|
111
|
+
</html>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getDirectoryListing(directory, requestPath) {
|
|
115
|
+
let entries = [];
|
|
116
|
+
try {
|
|
117
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return getErrorPage(500, "Internal Server Error", "Could not read directory.");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const items = entries
|
|
123
|
+
.filter(e => !e.name.startsWith('.'))
|
|
124
|
+
.map(entry => {
|
|
125
|
+
const isDir = entry.isDirectory();
|
|
126
|
+
let size = "-";
|
|
127
|
+
let mtime = "-";
|
|
128
|
+
let timestamp = 0;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const stat = fs.statSync(path.join(directory, entry.name));
|
|
132
|
+
if (!isDir) size = formatBytes(stat.size);
|
|
133
|
+
mtime = new Date(stat.mtime).toLocaleString();
|
|
134
|
+
timestamp = stat.mtimeMs;
|
|
135
|
+
} catch (e) {}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
name: entry.name,
|
|
139
|
+
isDir,
|
|
140
|
+
size,
|
|
141
|
+
mtime,
|
|
142
|
+
timestamp,
|
|
143
|
+
href: requestPath.endsWith('/') ? requestPath + encodeURIComponent(entry.name) : requestPath + '/' + encodeURIComponent(entry.name)
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Default sort: dirs first, then alphabetical
|
|
148
|
+
items.sort((a, b) => {
|
|
149
|
+
if (a.isDir && !b.isDir) return -1;
|
|
150
|
+
if (!a.isDir && b.isDir) return 1;
|
|
151
|
+
return a.name.localeCompare(b.name);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const parentPath = requestPath !== "/" ? path.posix.dirname(requestPath.replace(/\/$/, "")) : null;
|
|
155
|
+
|
|
156
|
+
return `<!DOCTYPE html>
|
|
157
|
+
<html lang="en">
|
|
158
|
+
<head>
|
|
159
|
+
<meta charset="UTF-8">
|
|
160
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
161
|
+
<title>Index of ${escapeHtml(requestPath)}</title>
|
|
162
|
+
<style>
|
|
163
|
+
${themeStyles}
|
|
164
|
+
body { padding: 2rem; max-width: 1200px; margin: 0 auto; }
|
|
165
|
+
header {
|
|
166
|
+
display: flex;
|
|
167
|
+
flex-direction: column;
|
|
168
|
+
gap: 1rem;
|
|
169
|
+
margin-bottom: 2rem;
|
|
170
|
+
}
|
|
171
|
+
@media (min-width: 640px) {
|
|
172
|
+
header {
|
|
173
|
+
flex-direction: row;
|
|
174
|
+
justify-content: space-between;
|
|
175
|
+
align-items: center;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
h1 { margin: 0; font-size: 1.5rem; word-break: break-all; }
|
|
179
|
+
.search-box {
|
|
180
|
+
padding: 0.75rem 1rem;
|
|
181
|
+
border-radius: 8px;
|
|
182
|
+
border: 1px solid var(--border);
|
|
183
|
+
background: var(--card);
|
|
184
|
+
color: var(--text);
|
|
185
|
+
width: 100%;
|
|
186
|
+
max-width: 300px;
|
|
187
|
+
}
|
|
188
|
+
.table-container {
|
|
189
|
+
overflow-x: auto;
|
|
190
|
+
background: var(--card);
|
|
191
|
+
border-radius: 12px;
|
|
192
|
+
border: 1px solid var(--border);
|
|
193
|
+
}
|
|
194
|
+
table { width: 100%; border-collapse: collapse; text-align: left; }
|
|
195
|
+
th {
|
|
196
|
+
padding: 1rem;
|
|
197
|
+
border-bottom: 2px solid var(--border);
|
|
198
|
+
color: var(--muted);
|
|
199
|
+
font-weight: 500;
|
|
200
|
+
cursor: pointer;
|
|
201
|
+
user-select: none;
|
|
202
|
+
}
|
|
203
|
+
th:hover { color: var(--text); }
|
|
204
|
+
td {
|
|
205
|
+
padding: 0.75rem 1rem;
|
|
206
|
+
border-bottom: 1px solid var(--border);
|
|
207
|
+
}
|
|
208
|
+
tr:last-child td { border-bottom: none; }
|
|
209
|
+
tr:hover td { background-color: var(--hover); }
|
|
210
|
+
a {
|
|
211
|
+
color: var(--text);
|
|
212
|
+
text-decoration: none;
|
|
213
|
+
display: flex;
|
|
214
|
+
align-items: center;
|
|
215
|
+
gap: 0.75rem;
|
|
216
|
+
}
|
|
217
|
+
.icon { font-size: 1.25rem; }
|
|
218
|
+
.size, .date { color: var(--muted); white-space: nowrap; }
|
|
219
|
+
.empty { padding: 3rem; text-align: center; color: var(--muted); }
|
|
220
|
+
</style>
|
|
221
|
+
</head>
|
|
222
|
+
<body>
|
|
223
|
+
<header>
|
|
224
|
+
<h1>📂 ${escapeHtml(requestPath)}</h1>
|
|
225
|
+
<input type="text" id="search" class="search-box" placeholder="Filter files..." aria-label="Filter files">
|
|
226
|
+
</header>
|
|
227
|
+
|
|
228
|
+
<div class="table-container">
|
|
229
|
+
<table id="file-table">
|
|
230
|
+
<thead>
|
|
231
|
+
<tr>
|
|
232
|
+
<th onclick="sortTable(0, 'str')">Name ↕</th>
|
|
233
|
+
<th onclick="sortTable(1, 'size')">Size ↕</th>
|
|
234
|
+
<th onclick="sortTable(2, 'date')">Last Modified ↕</th>
|
|
235
|
+
</tr>
|
|
236
|
+
</thead>
|
|
237
|
+
<tbody id="file-body">
|
|
238
|
+
${parentPath ? `
|
|
239
|
+
<tr>
|
|
240
|
+
<td><a href="${parentPath}/"><span class="icon">📁</span> ..</a></td>
|
|
241
|
+
<td class="size">-</td>
|
|
242
|
+
<td class="date">-</td>
|
|
243
|
+
</tr>` : ''}
|
|
244
|
+
${items.length === 0 ? `<tr><td colspan="3" class="empty">Directory is empty</td></tr>` : items.map(item => `
|
|
245
|
+
<tr class="file-row">
|
|
246
|
+
<td><a href="${item.href}"><span class="icon">${item.isDir ? '📁' : '📄'}</span> <span class="name">${escapeHtml(item.name)}${item.isDir ? '/' : ''}</span></a></td>
|
|
247
|
+
<td class="size" data-size="${item.isDir ? -1 : (item.size === '-' ? 0 : parseFloat(item.size))}">${escapeHtml(item.size)}</td>
|
|
248
|
+
<td class="date" data-time="${item.timestamp}">${escapeHtml(item.mtime)}</td>
|
|
249
|
+
</tr>`).join('\n')}
|
|
250
|
+
</tbody>
|
|
251
|
+
</table>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<script>
|
|
255
|
+
const search = document.getElementById('search');
|
|
256
|
+
const rows = document.querySelectorAll('.file-row');
|
|
257
|
+
|
|
258
|
+
search.addEventListener('input', (e) => {
|
|
259
|
+
const term = e.target.value.toLowerCase();
|
|
260
|
+
rows.forEach(row => {
|
|
261
|
+
const name = row.querySelector('.name').textContent.toLowerCase();
|
|
262
|
+
row.style.display = name.includes(term) ? '' : 'none';
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
let sortState = { col: 0, asc: true };
|
|
267
|
+
window.sortTable = function(col, type) {
|
|
268
|
+
const tbody = document.getElementById('file-body');
|
|
269
|
+
const rowArr = Array.from(rows);
|
|
270
|
+
|
|
271
|
+
if (sortState.col === col) {
|
|
272
|
+
sortState.asc = !sortState.asc;
|
|
273
|
+
} else {
|
|
274
|
+
sortState.col = col;
|
|
275
|
+
sortState.asc = true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
rowArr.sort((a, b) => {
|
|
279
|
+
let valA, valB;
|
|
280
|
+
if (col === 0) {
|
|
281
|
+
valA = a.querySelector('.name').textContent;
|
|
282
|
+
valB = b.querySelector('.name').textContent;
|
|
283
|
+
return sortState.asc ? valA.localeCompare(valB) : valB.localeCompare(valA);
|
|
284
|
+
} else if (col === 1) {
|
|
285
|
+
valA = parseFloat(a.querySelector('.size').getAttribute('data-size'));
|
|
286
|
+
valB = parseFloat(b.querySelector('.size').getAttribute('data-size'));
|
|
287
|
+
} else if (col === 2) {
|
|
288
|
+
valA = parseFloat(a.querySelector('.date').getAttribute('data-time'));
|
|
289
|
+
valB = parseFloat(b.querySelector('.date').getAttribute('data-time'));
|
|
290
|
+
}
|
|
291
|
+
return sortState.asc ? valA - valB : valB - valA;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
rowArr.forEach(row => tbody.appendChild(row));
|
|
295
|
+
}
|
|
296
|
+
</script>
|
|
297
|
+
</body>
|
|
298
|
+
</html>`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function injectLiveReload(html, enabled) {
|
|
302
|
+
if (!enabled) return html;
|
|
303
|
+
if (html.includes("__busyserver_ws")) return html;
|
|
304
|
+
|
|
305
|
+
const script = `<script>
|
|
306
|
+
(()=>{let t=0,mx=30,d=1e3;function co(){if(t>=mx)return;
|
|
307
|
+
const s=new WebSocket("ws://"+location.host+"/__busyserver_ws");
|
|
308
|
+
s.onopen=()=>{t=0;console.log("%c[BusyServer] Connected","color:#22c55e")};
|
|
309
|
+
s.onmessage=e=>{try{const m=JSON.parse(e.data);
|
|
310
|
+
if(m.type==="css"){document.querySelectorAll('link[rel="stylesheet"]').forEach(l=>{const h=l.href.split("?")[0];l.href=h+"?t="+Date.now()});console.log("%c[BusyServer] CSS updated","color:#a78bfa");return}
|
|
311
|
+
if(m.type==="image"){document.querySelectorAll("img").forEach(i=>{const u=i.src.split("?")[0];i.src=u+"?t="+Date.now()});console.log("%c[BusyServer] Images updated","color:#f59e0b");return}
|
|
312
|
+
location.reload()}catch{location.reload()}};
|
|
313
|
+
s.onclose=()=>{console.log("%c[BusyServer] Disconnected, reconnecting...","color:#f59e0b");t++;setTimeout(co,d)};
|
|
314
|
+
s.onerror=()=>{s.close()}}co()})();
|
|
315
|
+
</script>`;
|
|
316
|
+
|
|
317
|
+
if (html.includes("</body>")) {
|
|
318
|
+
return html.replace("</body>", script + "</body>");
|
|
319
|
+
}
|
|
320
|
+
if (html.includes("</html>")) {
|
|
321
|
+
return html.replace("</html>", script + "</html>");
|
|
322
|
+
}
|
|
323
|
+
return html + script;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
module.exports = {
|
|
327
|
+
getErrorPage,
|
|
328
|
+
getDirectoryListing,
|
|
329
|
+
injectLiveReload
|
|
330
|
+
};
|
package/src/watcher.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
|
|
3
|
+
const DEFAULT_IGNORED = [
|
|
4
|
+
"**/node_modules/**",
|
|
5
|
+
"**/.git/**",
|
|
6
|
+
"**/dist/**",
|
|
7
|
+
"**/.astro/**",
|
|
8
|
+
"**/.next/**",
|
|
9
|
+
"**/.cache/**",
|
|
10
|
+
"**/.busyserverc.json",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const IMAGE_EXTS = new Set([
|
|
14
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".ico",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const HTML_EXTS = new Set([".html", ".htm"]);
|
|
18
|
+
|
|
19
|
+
const JS_EXTS = new Set([".js", ".mjs", ".cjs", ".json", ".ts", ".tsx", ".jsx"]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Determine the type of change based on file extension.
|
|
23
|
+
*/
|
|
24
|
+
function getChangeType(filePath) {
|
|
25
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
26
|
+
|
|
27
|
+
if (ext === ".css") return "css";
|
|
28
|
+
if (IMAGE_EXTS.has(ext)) return "image";
|
|
29
|
+
if (HTML_EXTS.has(ext)) return "html";
|
|
30
|
+
if (JS_EXTS.has(ext)) return "js";
|
|
31
|
+
|
|
32
|
+
return "reload";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Start file watcher on the given root directory.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} root - Directory to watch
|
|
39
|
+
* @param {Function} onChange - Callback receiving { type, event, filePath }
|
|
40
|
+
* @param {Object} options
|
|
41
|
+
* @param {string[]} options.ignore - Additional glob patterns to ignore
|
|
42
|
+
* @param {boolean} options.enabled - If false, skip watching entirely
|
|
43
|
+
* @returns {import('chokidar').FSWatcher|null}
|
|
44
|
+
*/
|
|
45
|
+
async function startWatcher(root, onChange, options = {}) {
|
|
46
|
+
if (options.enabled === false) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const chokidarPkg = await import("chokidar");
|
|
51
|
+
const chokidar = chokidarPkg.default || chokidarPkg;
|
|
52
|
+
const ignored = [...DEFAULT_IGNORED];
|
|
53
|
+
|
|
54
|
+
if (Array.isArray(options.ignore)) {
|
|
55
|
+
for (const pattern of options.ignore) {
|
|
56
|
+
if (typeof pattern === "string" && pattern.length > 0) {
|
|
57
|
+
ignored.push(pattern);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const watcher = chokidar.watch(root, {
|
|
63
|
+
ignored,
|
|
64
|
+
ignoreInitial: true,
|
|
65
|
+
awaitWriteFinish: {
|
|
66
|
+
stabilityThreshold: 150,
|
|
67
|
+
pollInterval: 50,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Debounce: track pending changes per file
|
|
72
|
+
const pending = new Map();
|
|
73
|
+
const DEBOUNCE_MS = 100;
|
|
74
|
+
|
|
75
|
+
watcher.on("all", (event, filePath) => {
|
|
76
|
+
if (event !== "add" && event !== "change" && event !== "unlink") {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Clear any existing timer for this file
|
|
81
|
+
if (pending.has(filePath)) {
|
|
82
|
+
clearTimeout(pending.get(filePath));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Set new debounced timer
|
|
86
|
+
const timer = setTimeout(() => {
|
|
87
|
+
pending.delete(filePath);
|
|
88
|
+
|
|
89
|
+
const type = getChangeType(filePath);
|
|
90
|
+
const relativePath = path.relative(root, filePath);
|
|
91
|
+
|
|
92
|
+
onChange({
|
|
93
|
+
type,
|
|
94
|
+
event,
|
|
95
|
+
filePath,
|
|
96
|
+
relativePath,
|
|
97
|
+
});
|
|
98
|
+
}, DEBOUNCE_MS);
|
|
99
|
+
|
|
100
|
+
pending.set(filePath, timer);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return watcher;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { startWatcher, getChangeType };
|