// @ts-check
/**
* PromptJS v1.0.0 — CLI: `serve` Command / Perintah `serve`
* ============================================================================
*
* Dev server dengan live-reload via WebSocket. Compile `.pjs` on-the-fly,
* serve HTML + JS, dan push reload signal saat file berubah.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const http = require('http');
const { isInsideRoot } = require('../../utils/path-guard');
const { PromptJSEngine } = require('../../engine/promptjs');
const {
findPjsFiles,
printDiagnostics,
formatElapsed,
formatSize,
makeColors,
} = require('../utils');
// Minimal WebSocket server for live-reload
const LIVE_RELOAD_JS = `
// PromptJS v1.0.0 Live Reload
(function() {
var ws = new WebSocket('ws://' + location.host + '/__pjs_reload__');
ws.onmessage = function(e) {
try {
var msg = JSON.parse(e.data);
if (msg.type === 'reload') {
console.log('[pjs] File changed, reloading...');
location.reload();
} else if (msg.type === 'error') {
showPjsError(msg.errors);
} else if (msg.type === 'css') {
var style = document.getElementById('pjs-dev-css');
if (!style) {
style = document.createElement('style');
style.id = 'pjs-dev-css';
document.head.appendChild(style);
}
style.textContent = msg.css;
console.log('[pjs] CSS updated (HMR)');
}
} catch(err) {
if (e.data === 'reload') location.reload();
}
};
ws.onclose = function() {
console.log('[pjs] Live reload disconnected. Retrying in 2s...');
setTimeout(function() { location.reload(); }, 2000);
};
function showPjsError(errors) {
var overlay = document.getElementById('pjs-error-overlay');
if (!overlay) return;
overlay.style.display = 'block';
overlay.innerHTML = '⚠ PromptJS Compile Error
' +
errors.map(function(e) {
return '' + (e.code || 'E0000') + ': ' +
(e.message || 'Unknown error') +
(e.line ? ' (line ' + e.line + ')' : '');
}).join('
');
}
window.__pjsClearError = function() {
var overlay = document.getElementById('pjs-error-overlay');
if (overlay) overlay.style.display = 'none';
};
})();
`;
// Error overlay script (injected alongside live-reload)
const ERROR_OVERLAY_JS = `
// PromptJS v1.0.0 Error Overlay — auto-clear on successful compile
(function() {
window.addEventListener('error', function(e) {
var overlay = document.getElementById('pjs-error-overlay');
if (overlay && overlay.style.display === 'block') return;
// Only show JS runtime errors in dev
if (e.error) {
console.error('[pjs] Runtime error:', e.error.message);
}
});
})();
`;
// HTML wrapper for compiled .pjs output
/**
* Bungkus kode JS hasil compile menjadi HTML dengan live-reload script.
*
* @param {string} jsCode - Kode JS hasil compile
* @param {string} filePath - Path file `.pjs` asli
* @param {{ liveReload: boolean, css?: string, sourceMap?: string|null }} options - Opsi serve
* @returns {string} String HTML lengkap
*/
function wrapInHtml(jsCode, filePath, options) {
const title = path.basename(filePath, '.pjs');
const cssCode = options.css || '';
const cssTag = cssCode ? `` : '';
const reloadScript = options.liveReload ? `\n ` : '';
const errorOverlay = options.liveReload ? `\n ` : '';
return `
${result.errors
.map(
(e) =>
`${e.code || 'E0000'} ${escapeHtml(e.message)}` +
(e.suggestion
? `\n Saran: ${escapeHtml(e.suggestion)}`
: '')
)
.join('\n')}`;
return { html: errorHtml, js: null, error: true, elapsed };
}
const html = wrapInHtml(result.js, filePath, {
liveReload: !noReload,
css: result.css || '',
sourceMap: result.sourceMap || null,
});
process.stderr.write(
` ${cyan}${path.relative(process.cwd(), filePath)}${reset} ${green}✓${reset} ${gray}(${formatSize(result.js.length)} ${elapsed})${reset}\n`
);
return { html, js: result.js, error: false, elapsed };
}
/**
* HTTP request handler.
*/
function handleRequest(req, res) {
let urlPath = req.url.split('?')[0]; // Strip query string
// S-6 (v1.0.0): Decode percent-encoding agar `%2e%2e%2f` (..%2f) tidak lolos
// dari pemeriksaan traversal di bawah. URL malformed → 400.
try {
urlPath = decodeURIComponent(urlPath);
} catch {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('400 Bad Request');
return;
}
// WebSocket upgrade for live-reload
if (
urlPath === '/__pjs_reload__' &&
req.headers.upgrade &&
req.headers.upgrade.toLowerCase() === 'websocket'
) {
// Handled in upgrade event
return;
}
// Resolve file path
let filePath;
if (urlPath === '/') {
// Look for index.pjs or index.html
const indexPaths = ['index.pjs', 'index.html'];
filePath = null;
for (const ip of indexPaths) {
const candidate = path.join(rootDir, ip);
if (fs.existsSync(candidate)) {
filePath = candidate;
break;
}
}
if (!filePath) {
// Generate directory listing
serveDirectoryListing(rootDir, res);
return;
}
} else {
filePath = path.join(rootDir, urlPath);
}
// Security: prevent path traversal
// S-6 (v1.0.0): `resolved.startsWith(rootDir)` cacat — sibling-directory
// escape lolos (mis. rootDir "/srv/app" vs "/srv/app-secret/x").
// S-15 (v1.0.1): guard ini disentralisasi ke `src/utils/path-guard.js`
// (isInsideRoot pakai path.relative) agar konsisten lintas adapter & CLI.
const resolved = path.resolve(filePath);
if (!isInsideRoot(rootDir, resolved)) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('403 Forbidden');
return;
}
// Check if it's a .pjs file
if (resolved.endsWith('.pjs')) {
servePjs(resolved, req, res);
return;
}
// Serve static file
serveStatic(resolved, res);
}
/**
* Serve a compiled .pjs file as HTML.
*/
function servePjs(filePath, req, res) {
try {
const stat = fs.statSync(filePath);
const cached = compileCache.get(filePath);
// Use cache if file hasn't changed
if (cached && cached.mtime && cached.mtime.getTime() === stat.mtime.getTime()) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(cached.html);
return;
}
const result = compilePjs(filePath);
// Cache the result
compileCache.set(filePath, {
html: result.html,
js: result.js,
mtime: stat.mtime,
error: result.error,
});
res.writeHead(result.error ? 500 : 200, {
'Content-Type': 'text/html; charset=utf-8',
});
res.end(result.html);
} catch {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404 Not Found: ' + path.basename(filePath));
}
}
/**
* Serve a static file.
*/
function serveStatic(filePath, res) {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) {
// Try as directory with index
const indexPath = path.join(filePath, 'index.pjs');
if (fs.existsSync(indexPath)) {
servePjs(indexPath, null, res);
return;
}
const indexHtml = path.join(filePath, 'index.html');
if (fs.existsSync(indexHtml)) {
serveStatic(indexHtml, res);
return;
}
serveDirectoryListing(filePath, res);
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
const data = fs.readFileSync(filePath);
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
} catch {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404 Not Found');
}
}
/**
* Generate a simple directory listing.
*/
function serveDirectoryListing(dirPath, res) {
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
const items = entries
.filter((e) => !e.name.startsWith('.') && e.name !== 'node_modules')
.map((e) => {
const isDir = e.isDirectory();
const icon = isDir ? '📁' : e.name.endsWith('.pjs') ? '📄' : '📑';
const href = isDir ? e.name + '/' : e.name;
return `