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/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
+ }