smippo 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/LICENSE +21 -0
- package/README.md +116 -0
- package/bin/smippo.js +5 -0
- package/package.json +100 -0
- package/src/cli.js +437 -0
- package/src/crawler.js +408 -0
- package/src/filter.js +155 -0
- package/src/index.js +60 -0
- package/src/interactive.js +391 -0
- package/src/link-extractor.js +212 -0
- package/src/link-rewriter.js +293 -0
- package/src/manifest.js +163 -0
- package/src/page-capture.js +151 -0
- package/src/progress.js +190 -0
- package/src/resource-saver.js +210 -0
- package/src/robots.js +104 -0
- package/src/screenshot.js +185 -0
- package/src/server.js +603 -0
- package/src/utils/logger.js +74 -0
- package/src/utils/path.js +76 -0
- package/src/utils/url.js +295 -0
- package/src/utils/version.js +14 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import {exec} from 'child_process';
|
|
7
|
+
|
|
8
|
+
// MIME type mapping
|
|
9
|
+
const MIME_TYPES = {
|
|
10
|
+
'.html': 'text/html; charset=utf-8',
|
|
11
|
+
'.htm': 'text/html; charset=utf-8',
|
|
12
|
+
'.css': 'text/css; charset=utf-8',
|
|
13
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
14
|
+
'.mjs': 'application/javascript; charset=utf-8',
|
|
15
|
+
'.json': 'application/json; charset=utf-8',
|
|
16
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
17
|
+
'.png': 'image/png',
|
|
18
|
+
'.jpg': 'image/jpeg',
|
|
19
|
+
'.jpeg': 'image/jpeg',
|
|
20
|
+
'.gif': 'image/gif',
|
|
21
|
+
'.webp': 'image/webp',
|
|
22
|
+
'.svg': 'image/svg+xml',
|
|
23
|
+
'.ico': 'image/x-icon',
|
|
24
|
+
'.bmp': 'image/bmp',
|
|
25
|
+
'.woff': 'font/woff',
|
|
26
|
+
'.woff2': 'font/woff2',
|
|
27
|
+
'.ttf': 'font/ttf',
|
|
28
|
+
'.otf': 'font/otf',
|
|
29
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
30
|
+
'.mp3': 'audio/mpeg',
|
|
31
|
+
'.mp4': 'video/mp4',
|
|
32
|
+
'.webm': 'video/webm',
|
|
33
|
+
'.ogg': 'audio/ogg',
|
|
34
|
+
'.wav': 'audio/wav',
|
|
35
|
+
'.pdf': 'application/pdf',
|
|
36
|
+
'.zip': 'application/zip',
|
|
37
|
+
'.tar': 'application/x-tar',
|
|
38
|
+
'.gz': 'application/gzip',
|
|
39
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
40
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
41
|
+
'.har': 'application/json; charset=utf-8',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if a port is available
|
|
46
|
+
*/
|
|
47
|
+
async function isPortAvailable(port) {
|
|
48
|
+
return new Promise(resolve => {
|
|
49
|
+
const server = http.createServer();
|
|
50
|
+
server.listen(port, '127.0.0.1');
|
|
51
|
+
server.on('listening', () => {
|
|
52
|
+
server.close();
|
|
53
|
+
resolve(true);
|
|
54
|
+
});
|
|
55
|
+
server.on('error', () => {
|
|
56
|
+
resolve(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Find the next available port starting from the given port
|
|
63
|
+
*/
|
|
64
|
+
async function findAvailablePort(startPort = 8080, maxAttempts = 100) {
|
|
65
|
+
for (let port = startPort; port < startPort + maxAttempts; port++) {
|
|
66
|
+
if (await isPortAvailable(port)) {
|
|
67
|
+
return port;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Could not find an available port between ${startPort} and ${startPort + maxAttempts}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Open URL in default browser
|
|
77
|
+
*/
|
|
78
|
+
function openBrowser(url) {
|
|
79
|
+
const platform = process.platform;
|
|
80
|
+
let command;
|
|
81
|
+
|
|
82
|
+
if (platform === 'darwin') {
|
|
83
|
+
command = `open "${url}"`;
|
|
84
|
+
} else if (platform === 'win32') {
|
|
85
|
+
command = `start "" "${url}"`;
|
|
86
|
+
} else {
|
|
87
|
+
command = `xdg-open "${url}"`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
exec(command, error => {
|
|
91
|
+
if (error) {
|
|
92
|
+
// Silently fail - browser opening is optional
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get MIME type for a file
|
|
99
|
+
*/
|
|
100
|
+
function getMimeType(filePath) {
|
|
101
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
102
|
+
return MIME_TYPES[ext] || 'application/octet-stream';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Generate a directory listing HTML page
|
|
107
|
+
*/
|
|
108
|
+
async function generateDirectoryListing(dirPath, urlPath, rootDir) {
|
|
109
|
+
const entries = await fs.readdir(dirPath, {withFileTypes: true});
|
|
110
|
+
|
|
111
|
+
// Filter out hidden files and .smippo directory
|
|
112
|
+
const filteredEntries = entries.filter(e => !e.name.startsWith('.'));
|
|
113
|
+
|
|
114
|
+
// Separate directories and files
|
|
115
|
+
const dirs = filteredEntries
|
|
116
|
+
.filter(e => e.isDirectory())
|
|
117
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
118
|
+
const files = filteredEntries
|
|
119
|
+
.filter(e => e.isFile())
|
|
120
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
121
|
+
|
|
122
|
+
// Check for manifest to get site info
|
|
123
|
+
let siteInfo = null;
|
|
124
|
+
const manifestPath = path.join(rootDir, '.smippo', 'manifest.json');
|
|
125
|
+
if (await fs.pathExists(manifestPath)) {
|
|
126
|
+
try {
|
|
127
|
+
siteInfo = await fs.readJson(manifestPath);
|
|
128
|
+
} catch {
|
|
129
|
+
// Ignore manifest read errors
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const isRoot = urlPath === '/' || urlPath === '';
|
|
134
|
+
const parentPath = urlPath === '/' ? null : path.dirname(urlPath);
|
|
135
|
+
|
|
136
|
+
// Generate HTML
|
|
137
|
+
const html = `<!DOCTYPE html>
|
|
138
|
+
<html lang="en">
|
|
139
|
+
<head>
|
|
140
|
+
<meta charset="UTF-8">
|
|
141
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
142
|
+
<title>Smippo - ${isRoot ? 'Captured Sites' : urlPath}</title>
|
|
143
|
+
<style>
|
|
144
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
145
|
+
body {
|
|
146
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
147
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
148
|
+
min-height: 100vh;
|
|
149
|
+
color: #e4e4e7;
|
|
150
|
+
padding: 2rem;
|
|
151
|
+
}
|
|
152
|
+
.container { max-width: 900px; margin: 0 auto; }
|
|
153
|
+
.header {
|
|
154
|
+
text-align: center;
|
|
155
|
+
margin-bottom: 2rem;
|
|
156
|
+
padding: 2rem;
|
|
157
|
+
background: rgba(255,255,255,0.05);
|
|
158
|
+
border-radius: 16px;
|
|
159
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
160
|
+
}
|
|
161
|
+
.logo {
|
|
162
|
+
font-size: 2.5rem;
|
|
163
|
+
font-weight: 800;
|
|
164
|
+
background: linear-gradient(135deg, #60a5fa, #a78bfa);
|
|
165
|
+
-webkit-background-clip: text;
|
|
166
|
+
-webkit-text-fill-color: transparent;
|
|
167
|
+
margin-bottom: 0.5rem;
|
|
168
|
+
}
|
|
169
|
+
.subtitle { color: #71717a; font-size: 0.9rem; }
|
|
170
|
+
.site-info {
|
|
171
|
+
margin-top: 1rem;
|
|
172
|
+
padding: 1rem;
|
|
173
|
+
background: rgba(96, 165, 250, 0.1);
|
|
174
|
+
border-radius: 8px;
|
|
175
|
+
font-size: 0.85rem;
|
|
176
|
+
}
|
|
177
|
+
.site-info a { color: #60a5fa; text-decoration: none; }
|
|
178
|
+
.breadcrumb {
|
|
179
|
+
margin-bottom: 1.5rem;
|
|
180
|
+
padding: 0.75rem 1rem;
|
|
181
|
+
background: rgba(255,255,255,0.05);
|
|
182
|
+
border-radius: 8px;
|
|
183
|
+
font-size: 0.9rem;
|
|
184
|
+
}
|
|
185
|
+
.breadcrumb a { color: #60a5fa; text-decoration: none; }
|
|
186
|
+
.breadcrumb span { color: #71717a; margin: 0 0.5rem; }
|
|
187
|
+
.listing {
|
|
188
|
+
background: rgba(255,255,255,0.03);
|
|
189
|
+
border-radius: 12px;
|
|
190
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
191
|
+
overflow: hidden;
|
|
192
|
+
}
|
|
193
|
+
.listing-header {
|
|
194
|
+
padding: 1rem 1.5rem;
|
|
195
|
+
background: rgba(255,255,255,0.05);
|
|
196
|
+
border-bottom: 1px solid rgba(255,255,255,0.1);
|
|
197
|
+
font-weight: 600;
|
|
198
|
+
color: #a1a1aa;
|
|
199
|
+
font-size: 0.85rem;
|
|
200
|
+
text-transform: uppercase;
|
|
201
|
+
letter-spacing: 0.05em;
|
|
202
|
+
}
|
|
203
|
+
.entry {
|
|
204
|
+
display: flex;
|
|
205
|
+
align-items: center;
|
|
206
|
+
padding: 1rem 1.5rem;
|
|
207
|
+
border-bottom: 1px solid rgba(255,255,255,0.05);
|
|
208
|
+
transition: background 0.2s;
|
|
209
|
+
text-decoration: none;
|
|
210
|
+
color: inherit;
|
|
211
|
+
}
|
|
212
|
+
.entry:hover { background: rgba(255,255,255,0.05); }
|
|
213
|
+
.entry:last-child { border-bottom: none; }
|
|
214
|
+
.entry-icon {
|
|
215
|
+
width: 40px;
|
|
216
|
+
height: 40px;
|
|
217
|
+
border-radius: 8px;
|
|
218
|
+
display: flex;
|
|
219
|
+
align-items: center;
|
|
220
|
+
justify-content: center;
|
|
221
|
+
margin-right: 1rem;
|
|
222
|
+
font-size: 1.2rem;
|
|
223
|
+
}
|
|
224
|
+
.entry-icon.dir { background: rgba(96, 165, 250, 0.2); }
|
|
225
|
+
.entry-icon.file { background: rgba(167, 139, 250, 0.2); }
|
|
226
|
+
.entry-icon.html { background: rgba(251, 146, 60, 0.2); }
|
|
227
|
+
.entry-name { flex: 1; font-weight: 500; }
|
|
228
|
+
.entry-meta { color: #71717a; font-size: 0.85rem; }
|
|
229
|
+
.empty {
|
|
230
|
+
padding: 3rem;
|
|
231
|
+
text-align: center;
|
|
232
|
+
color: #71717a;
|
|
233
|
+
}
|
|
234
|
+
.footer {
|
|
235
|
+
text-align: center;
|
|
236
|
+
margin-top: 2rem;
|
|
237
|
+
color: #52525b;
|
|
238
|
+
font-size: 0.8rem;
|
|
239
|
+
}
|
|
240
|
+
</style>
|
|
241
|
+
</head>
|
|
242
|
+
<body>
|
|
243
|
+
<div class="container">
|
|
244
|
+
<div class="header">
|
|
245
|
+
<div class="logo">📦 Smippo</div>
|
|
246
|
+
<div class="subtitle">${isRoot ? 'Captured Sites Browser' : 'Directory Listing'}</div>
|
|
247
|
+
${
|
|
248
|
+
siteInfo && isRoot
|
|
249
|
+
? `
|
|
250
|
+
<div class="site-info">
|
|
251
|
+
Originally captured from <a href="${siteInfo.rootUrl}" target="_blank">${siteInfo.rootUrl}</a>
|
|
252
|
+
${siteInfo.capturedAt ? ` on ${new Date(siteInfo.capturedAt).toLocaleDateString()}` : ''}
|
|
253
|
+
</div>`
|
|
254
|
+
: ''
|
|
255
|
+
}
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
${
|
|
259
|
+
!isRoot
|
|
260
|
+
? `
|
|
261
|
+
<div class="breadcrumb">
|
|
262
|
+
<a href="/">Home</a>
|
|
263
|
+
${urlPath
|
|
264
|
+
.split('/')
|
|
265
|
+
.filter(Boolean)
|
|
266
|
+
.map((part, i, arr) => {
|
|
267
|
+
const href = '/' + arr.slice(0, i + 1).join('/');
|
|
268
|
+
return `<span>/</span><a href="${href}">${part}</a>`;
|
|
269
|
+
})
|
|
270
|
+
.join('')}
|
|
271
|
+
</div>`
|
|
272
|
+
: ''
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
<div class="listing">
|
|
276
|
+
<div class="listing-header">
|
|
277
|
+
${isRoot ? 'Captured Sites' : `Contents of ${urlPath}`}
|
|
278
|
+
</div>
|
|
279
|
+
${
|
|
280
|
+
parentPath !== null
|
|
281
|
+
? `
|
|
282
|
+
<a href="${parentPath || '/'}" class="entry">
|
|
283
|
+
<div class="entry-icon dir">⬆️</div>
|
|
284
|
+
<div class="entry-name">..</div>
|
|
285
|
+
<div class="entry-meta">Parent Directory</div>
|
|
286
|
+
</a>`
|
|
287
|
+
: ''
|
|
288
|
+
}
|
|
289
|
+
${dirs
|
|
290
|
+
.map(
|
|
291
|
+
d => `
|
|
292
|
+
<a href="${urlPath === '/' ? '' : urlPath}/${d.name}" class="entry">
|
|
293
|
+
<div class="entry-icon dir">📁</div>
|
|
294
|
+
<div class="entry-name">${d.name}</div>
|
|
295
|
+
<div class="entry-meta">Directory</div>
|
|
296
|
+
</a>`,
|
|
297
|
+
)
|
|
298
|
+
.join('')}
|
|
299
|
+
${files
|
|
300
|
+
.map(f => {
|
|
301
|
+
const ext = path.extname(f.name).toLowerCase();
|
|
302
|
+
const isHtml = ext === '.html' || ext === '.htm';
|
|
303
|
+
return `
|
|
304
|
+
<a href="${urlPath === '/' ? '' : urlPath}/${f.name}" class="entry">
|
|
305
|
+
<div class="entry-icon ${isHtml ? 'html' : 'file'}">${isHtml ? '📄' : '📎'}</div>
|
|
306
|
+
<div class="entry-name">${f.name}</div>
|
|
307
|
+
<div class="entry-meta">${ext || 'File'}</div>
|
|
308
|
+
</a>`;
|
|
309
|
+
})
|
|
310
|
+
.join('')}
|
|
311
|
+
${
|
|
312
|
+
dirs.length === 0 && files.length === 0
|
|
313
|
+
? `
|
|
314
|
+
<div class="empty">No files found</div>`
|
|
315
|
+
: ''
|
|
316
|
+
}
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
<div class="footer">
|
|
320
|
+
Powered by Smippo • Modern Website Copier
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
</body>
|
|
324
|
+
</html>`;
|
|
325
|
+
|
|
326
|
+
return html;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Create and start an HTTP server for serving captured sites
|
|
331
|
+
*/
|
|
332
|
+
export async function createServer(options = {}) {
|
|
333
|
+
const {
|
|
334
|
+
directory = './site',
|
|
335
|
+
port: requestedPort = 8080,
|
|
336
|
+
host = '127.0.0.1',
|
|
337
|
+
open = false,
|
|
338
|
+
cors = true,
|
|
339
|
+
verbose = false,
|
|
340
|
+
quiet = false,
|
|
341
|
+
} = options;
|
|
342
|
+
|
|
343
|
+
// Resolve directory to absolute path
|
|
344
|
+
const rootDir = path.resolve(directory);
|
|
345
|
+
|
|
346
|
+
// Check if directory exists
|
|
347
|
+
if (!(await fs.pathExists(rootDir))) {
|
|
348
|
+
throw new Error(`Directory not found: ${rootDir}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Find available port
|
|
352
|
+
const port = await findAvailablePort(parseInt(requestedPort, 10));
|
|
353
|
+
|
|
354
|
+
// Create HTTP server
|
|
355
|
+
const server = http.createServer(async (req, res) => {
|
|
356
|
+
const startTime = Date.now();
|
|
357
|
+
|
|
358
|
+
// Parse URL and decode
|
|
359
|
+
const urlPath = decodeURIComponent(req.url.split('?')[0]);
|
|
360
|
+
|
|
361
|
+
// Build file path
|
|
362
|
+
let filePath = path.join(rootDir, urlPath);
|
|
363
|
+
|
|
364
|
+
// Security: prevent directory traversal
|
|
365
|
+
if (!filePath.startsWith(rootDir)) {
|
|
366
|
+
res.writeHead(403);
|
|
367
|
+
res.end('Forbidden');
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
const stats = await fs.stat(filePath);
|
|
373
|
+
|
|
374
|
+
// If it's a directory, look for index.html or show listing
|
|
375
|
+
if (stats.isDirectory()) {
|
|
376
|
+
const indexPath = path.join(filePath, 'index.html');
|
|
377
|
+
if (await fs.pathExists(indexPath)) {
|
|
378
|
+
filePath = indexPath;
|
|
379
|
+
} else {
|
|
380
|
+
// Try looking for a .html file with the same name
|
|
381
|
+
const htmlPath = filePath.replace(/\/$/, '') + '.html';
|
|
382
|
+
if (await fs.pathExists(htmlPath)) {
|
|
383
|
+
filePath = htmlPath;
|
|
384
|
+
} else {
|
|
385
|
+
// Generate directory listing
|
|
386
|
+
const listingHtml = await generateDirectoryListing(
|
|
387
|
+
filePath,
|
|
388
|
+
urlPath,
|
|
389
|
+
rootDir,
|
|
390
|
+
);
|
|
391
|
+
const headers = {
|
|
392
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
393
|
+
'Content-Length': Buffer.byteLength(listingHtml),
|
|
394
|
+
'Cache-Control': 'no-cache',
|
|
395
|
+
};
|
|
396
|
+
if (cors) {
|
|
397
|
+
headers['Access-Control-Allow-Origin'] = '*';
|
|
398
|
+
}
|
|
399
|
+
res.writeHead(200, headers);
|
|
400
|
+
res.end(listingHtml);
|
|
401
|
+
logRequest(req, 200, Date.now() - startTime, verbose, quiet);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Read and serve the file
|
|
408
|
+
const content = await fs.readFile(filePath);
|
|
409
|
+
const mimeType = getMimeType(filePath);
|
|
410
|
+
|
|
411
|
+
// Set headers
|
|
412
|
+
const headers = {
|
|
413
|
+
'Content-Type': mimeType,
|
|
414
|
+
'Content-Length': content.length,
|
|
415
|
+
'Cache-Control': 'no-cache',
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// Add CORS headers if enabled
|
|
419
|
+
if (cors) {
|
|
420
|
+
headers['Access-Control-Allow-Origin'] = '*';
|
|
421
|
+
headers['Access-Control-Allow-Methods'] = 'GET, HEAD, OPTIONS';
|
|
422
|
+
headers['Access-Control-Allow-Headers'] = '*';
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
res.writeHead(200, headers);
|
|
426
|
+
res.end(content);
|
|
427
|
+
|
|
428
|
+
logRequest(req, 200, Date.now() - startTime, verbose, quiet);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (error.code === 'ENOENT') {
|
|
431
|
+
// Try adding .html extension
|
|
432
|
+
const htmlPath = filePath + '.html';
|
|
433
|
+
if (await fs.pathExists(htmlPath)) {
|
|
434
|
+
const content = await fs.readFile(htmlPath);
|
|
435
|
+
const headers = {
|
|
436
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
437
|
+
'Content-Length': content.length,
|
|
438
|
+
'Cache-Control': 'no-cache',
|
|
439
|
+
};
|
|
440
|
+
if (cors) {
|
|
441
|
+
headers['Access-Control-Allow-Origin'] = '*';
|
|
442
|
+
}
|
|
443
|
+
res.writeHead(200, headers);
|
|
444
|
+
res.end(content);
|
|
445
|
+
logRequest(req, 200, Date.now() - startTime, verbose, quiet);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
res.writeHead(404);
|
|
450
|
+
res.end('Not Found');
|
|
451
|
+
logRequest(req, 404, Date.now() - startTime, verbose, quiet);
|
|
452
|
+
} else {
|
|
453
|
+
res.writeHead(500);
|
|
454
|
+
res.end('Internal Server Error');
|
|
455
|
+
logRequest(req, 500, Date.now() - startTime, verbose, quiet);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// Handle server errors
|
|
461
|
+
server.on('error', error => {
|
|
462
|
+
if (error.code === 'EADDRINUSE') {
|
|
463
|
+
console.error(chalk.red(`Port ${port} is already in use`));
|
|
464
|
+
} else {
|
|
465
|
+
console.error(chalk.red(`Server error: ${error.message}`));
|
|
466
|
+
}
|
|
467
|
+
process.exit(1);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// Start listening
|
|
471
|
+
return new Promise(resolve => {
|
|
472
|
+
server.listen(port, host, () => {
|
|
473
|
+
const url = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`;
|
|
474
|
+
|
|
475
|
+
if (!quiet) {
|
|
476
|
+
console.log('');
|
|
477
|
+
console.log(
|
|
478
|
+
chalk.cyan(' ╭─────────────────────────────────────────────╮'),
|
|
479
|
+
);
|
|
480
|
+
console.log(
|
|
481
|
+
chalk.cyan(' │ │'),
|
|
482
|
+
);
|
|
483
|
+
console.log(
|
|
484
|
+
chalk.cyan(' │ ') +
|
|
485
|
+
chalk.bold.white('Smippo Server') +
|
|
486
|
+
chalk.cyan(' │'),
|
|
487
|
+
);
|
|
488
|
+
console.log(
|
|
489
|
+
chalk.cyan(' │ │'),
|
|
490
|
+
);
|
|
491
|
+
console.log(
|
|
492
|
+
chalk.cyan(' │ ') +
|
|
493
|
+
chalk.dim('Local: ') +
|
|
494
|
+
chalk.bold.green(url) +
|
|
495
|
+
chalk.cyan(' │'),
|
|
496
|
+
);
|
|
497
|
+
if (host === '0.0.0.0') {
|
|
498
|
+
console.log(
|
|
499
|
+
chalk.cyan(' │ ') +
|
|
500
|
+
chalk.dim('Network: ') +
|
|
501
|
+
chalk.bold.green(`http://[your-ip]:${port}`) +
|
|
502
|
+
chalk.cyan(' │'),
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
console.log(
|
|
506
|
+
chalk.cyan(' │ │'),
|
|
507
|
+
);
|
|
508
|
+
console.log(
|
|
509
|
+
chalk.cyan(' │ ') +
|
|
510
|
+
chalk.dim('Serving: ') +
|
|
511
|
+
chalk.white(truncatePath(rootDir, 30)) +
|
|
512
|
+
chalk.cyan(' │'),
|
|
513
|
+
);
|
|
514
|
+
console.log(
|
|
515
|
+
chalk.cyan(' │ │'),
|
|
516
|
+
);
|
|
517
|
+
console.log(
|
|
518
|
+
chalk.cyan(' │ ') +
|
|
519
|
+
chalk.dim('Press ') +
|
|
520
|
+
chalk.white('Ctrl+C') +
|
|
521
|
+
chalk.dim(' to stop') +
|
|
522
|
+
chalk.cyan(' │'),
|
|
523
|
+
);
|
|
524
|
+
console.log(
|
|
525
|
+
chalk.cyan(' │ │'),
|
|
526
|
+
);
|
|
527
|
+
console.log(
|
|
528
|
+
chalk.cyan(' ╰─────────────────────────────────────────────╯'),
|
|
529
|
+
);
|
|
530
|
+
console.log('');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Open browser if requested
|
|
534
|
+
if (open) {
|
|
535
|
+
openBrowser(url);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
resolve({
|
|
539
|
+
server,
|
|
540
|
+
port,
|
|
541
|
+
host,
|
|
542
|
+
url,
|
|
543
|
+
close: () => new Promise(res => server.close(res)),
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Log a request
|
|
551
|
+
*/
|
|
552
|
+
function logRequest(req, status, duration, verbose, quiet) {
|
|
553
|
+
if (quiet) return;
|
|
554
|
+
if (!verbose && status === 200) return;
|
|
555
|
+
|
|
556
|
+
const statusColor =
|
|
557
|
+
status < 300 ? chalk.green : status < 400 ? chalk.yellow : chalk.red;
|
|
558
|
+
const method = chalk.dim(req.method.padEnd(4));
|
|
559
|
+
const url = req.url.length > 50 ? req.url.slice(0, 47) + '...' : req.url;
|
|
560
|
+
const time = chalk.dim(`${duration}ms`);
|
|
561
|
+
|
|
562
|
+
console.log(` ${method} ${statusColor(status)} ${url} ${time}`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Truncate a path for display
|
|
567
|
+
*/
|
|
568
|
+
function truncatePath(p, maxLen) {
|
|
569
|
+
if (p.length <= maxLen) return p.padEnd(maxLen);
|
|
570
|
+
return '...' + p.slice(-(maxLen - 3));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Serve command for CLI
|
|
575
|
+
*/
|
|
576
|
+
export async function serve(options) {
|
|
577
|
+
try {
|
|
578
|
+
const serverInfo = await createServer({
|
|
579
|
+
directory: options.output || options.directory || './site',
|
|
580
|
+
port: options.port || 8080,
|
|
581
|
+
host: options.host || '127.0.0.1',
|
|
582
|
+
open: options.open,
|
|
583
|
+
cors: options.cors !== false,
|
|
584
|
+
verbose: options.verbose,
|
|
585
|
+
quiet: options.quiet,
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// Keep process running
|
|
589
|
+
process.on('SIGINT', async () => {
|
|
590
|
+
console.log(chalk.dim('\n Shutting down server...'));
|
|
591
|
+
await serverInfo.close();
|
|
592
|
+
process.exit(0);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
process.on('SIGTERM', async () => {
|
|
596
|
+
await serverInfo.close();
|
|
597
|
+
process.exit(0);
|
|
598
|
+
});
|
|
599
|
+
} catch (error) {
|
|
600
|
+
console.error(chalk.red(`\n✗ Error: ${error.message}`));
|
|
601
|
+
process.exit(1);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
|
|
4
|
+
export class Logger {
|
|
5
|
+
constructor(options = {}) {
|
|
6
|
+
this.verbose = options.verbose || false;
|
|
7
|
+
this.quiet = options.quiet || false;
|
|
8
|
+
this.logFile = options.logFile || null;
|
|
9
|
+
this.logBuffer = [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
info(message) {
|
|
13
|
+
if (!this.quiet) {
|
|
14
|
+
console.log(chalk.cyan('ℹ'), message);
|
|
15
|
+
}
|
|
16
|
+
this._writeToFile('INFO', message);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
success(message) {
|
|
20
|
+
if (!this.quiet) {
|
|
21
|
+
console.log(chalk.green('✓'), message);
|
|
22
|
+
}
|
|
23
|
+
this._writeToFile('SUCCESS', message);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
warn(message) {
|
|
27
|
+
if (!this.quiet) {
|
|
28
|
+
console.log(chalk.yellow('⚠'), message);
|
|
29
|
+
}
|
|
30
|
+
this._writeToFile('WARN', message);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
error(message, error) {
|
|
34
|
+
console.error(chalk.red('✗'), message);
|
|
35
|
+
if (error && this.verbose) {
|
|
36
|
+
console.error(chalk.red(error.stack || error));
|
|
37
|
+
}
|
|
38
|
+
this._writeToFile(
|
|
39
|
+
'ERROR',
|
|
40
|
+
`${message}${error ? `: ${error.message}` : ''}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
debug(message) {
|
|
45
|
+
if (this.verbose) {
|
|
46
|
+
console.log(chalk.gray('⋯'), chalk.gray(message));
|
|
47
|
+
}
|
|
48
|
+
this._writeToFile('DEBUG', message);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_writeToFile(level, message) {
|
|
52
|
+
if (!this.logFile) return;
|
|
53
|
+
|
|
54
|
+
const timestamp = new Date().toISOString();
|
|
55
|
+
const line = `[${timestamp}] [${level}] ${message}\n`;
|
|
56
|
+
this.logBuffer.push(line);
|
|
57
|
+
|
|
58
|
+
// Flush buffer periodically
|
|
59
|
+
if (this.logBuffer.length >= 10) {
|
|
60
|
+
this.flush();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async flush() {
|
|
65
|
+
if (!this.logFile || this.logBuffer.length === 0) return;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await fs.appendFile(this.logFile, this.logBuffer.join(''));
|
|
69
|
+
this.logBuffer = [];
|
|
70
|
+
} catch (error) {
|
|
71
|
+
// Ignore file write errors
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|