UNPKG

polyv-live-cli

Version:

CLI tool for managing PolyV live streaming services.

578 lines β€’ 30.9 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.StreamHandler = void 0; const base_handler_1 = require("./base.handler"); const errors_1 = require("../utils/errors"); const formatter_1 = require("../utils/formatter"); const ffmpeg_1 = require("../utils/ffmpeg"); const stream_verification_1 = require("../utils/stream-verification"); const formatter_2 = require("../utils/formatter"); const child_process_1 = require("child_process"); const fs = __importStar(require("fs")); class StreamHandler extends base_handler_1.BaseHandler { constructor(streamService) { super(); this.streamService = streamService; } async getStreamKey(options) { return this.executeWithErrorHandling(async () => { this.validateGetStreamKeyOptions(options); const credentials = await this.streamService.getStreamKey({ channelId: options.channelId }); try { const statusInfo = await this.streamService.getStreamStatus({ channelId: options.channelId }); console.log('\nπŸ“Š Stream Status:'); console.log(` ${(0, formatter_1.formatCompactStatus)(statusInfo)}`); if (statusInfo.isLive && statusInfo.metrics) { console.log(` Performance: ${statusInfo.metrics.fps.toFixed(1)} FPS, ${statusInfo.metrics.bandwidthText}`); } } catch (error) { console.log('\n⚠️ Status: Unable to retrieve current status'); } const outputFormat = options.output || 'table'; if (outputFormat === 'json') { this.displayStreamCredentialsAsJson(credentials); } else { this.displayStreamCredentialsAsTable(credentials); } this.displaySecurityWarning(); }, 'stream.getStreamKey'); } validateGetStreamKeyOptions(options) { if (!options.channelId || typeof options.channelId !== 'string' || options.channelId.trim() === '') { throw new errors_1.PolyVValidationError('Channel ID cannot be empty', 'channelId', options.channelId, 'required'); } if (options.output && !['table', 'json'].includes(options.output)) { throw new errors_1.PolyVValidationError('Output format must be either "table" or "json"', 'output', options.output, 'invalid_value'); } } displayStreamCredentialsAsTable(credentials) { const displayModel = this.createSecureDisplayModel(credentials); const streamInfo = { 'Channel ID': credentials.channelId, 'RTMP URL': credentials.rtmpUrl, 'Stream Key': displayModel.streamKey, 'Deploy Address': credentials.deployAddress || '-', 'Input Address': credentials.inAddress || '-' }; console.log('\nπŸ“‘ Stream Information:'); this.displayData(streamInfo, 'table'); if (credentials.metrics.fps > 0 || credentials.metrics.bandwidth > 0) { const performanceInfo = { 'FPS': credentials.metrics.fps.toFixed(2), 'LFR': credentials.metrics.lfr.toFixed(2), 'Bandwidth': (0, formatter_2.formatBandwidth)(credentials.metrics.bandwidth) }; console.log('\nπŸ“Š Performance Metrics:'); this.displayData(performanceInfo, 'table'); } if (displayModel.isMasked) { console.log('\nπŸ’‘ Stream key is partially hidden for security. Use --output json to see full credentials.'); } } displayStreamCredentialsAsJson(credentials) { console.log('\nπŸ“‘ Stream Information (Full):'); console.log(JSON.stringify(credentials, null, 2)); } createSecureDisplayModel(credentials) { const streamKey = credentials.streamKey; let maskedStreamKey = streamKey; let isMasked = false; if (streamKey && streamKey.length > 8) { const prefix = streamKey.substring(0, 4); const suffix = streamKey.substring(streamKey.length - 4); const maskLength = streamKey.length - 8; const mask = '*'.repeat(maskLength); maskedStreamKey = `${prefix}${mask}${suffix}`; isMasked = true; } return { channelId: credentials.channelId, rtmpUrl: credentials.rtmpUrl, streamKey: maskedStreamKey, isMasked, streamInfo: { fps: credentials.metrics.fps, bandwidth: credentials.metrics.bandwidth } }; } async startStream(options) { return this.executeWithErrorHandling(async () => { this.validateStartStreamOptions(options); const response = await this.streamService.startStream({ channelId: options.channelId }); if (response.data === 'success') { this.displaySuccess(`Stream started successfully for channel ${options.channelId}`); try { await this.displayEnhancedStatus(options.channelId); } catch (error) { this.displayStreamStatus(options.channelId, 'live'); } } else { this.displayError(`Failed to start stream for channel ${options.channelId}`); } }, 'stream.startStream'); } validateStartStreamOptions(options) { if (!options.channelId || typeof options.channelId !== 'string' || options.channelId.trim() === '') { throw new errors_1.PolyVValidationError('Channel ID cannot be empty', 'channelId', options.channelId, 'required'); } } displayStreamStatus(channelId, status) { const statusInfo = { 'Channel ID': channelId, 'Stream Status': status, 'Timestamp': new Date().toISOString() }; console.log('\nπŸ“Š Stream Status:'); this.displayData(statusInfo, 'table'); } async stopStream(options) { return this.executeWithErrorHandling(async () => { this.validateStopStreamOptions(options); const response = await this.streamService.stopStream({ channelId: options.channelId }); if (response.data === 'success') { this.displaySuccess(`Stream stopped successfully for channel ${options.channelId}`); try { await this.displayEnhancedStatus(options.channelId); } catch (error) { this.displayStreamStatus(options.channelId, 'stopped'); } } else { this.displayError(`Failed to stop stream for channel ${options.channelId}`); } }, 'stream.stopStream'); } validateStopStreamOptions(options) { if (!options.channelId || typeof options.channelId !== 'string' || options.channelId.trim() === '') { throw new errors_1.PolyVValidationError('Channel ID cannot be empty', 'channelId', options.channelId, 'required'); } } async getStreamStatus(options) { return this.executeWithErrorHandling(async () => { this.validateStreamStatusOptions(options); const statusInfo = await this.streamService.getStreamStatus({ channelId: options.channelId }); const outputFormat = options.output || 'table'; if (outputFormat === 'json') { this.displayStreamStatusAsJson(statusInfo); } else { this.displayStreamStatusAsTable(statusInfo); } }, 'stream.getStreamStatus'); } validateStreamStatusOptions(options) { if (!options.channelId || typeof options.channelId !== 'string' || options.channelId.trim() === '') { throw new errors_1.PolyVValidationError('Channel ID cannot be empty', 'channelId', options.channelId, 'required'); } if (options.output && !['table', 'json'].includes(options.output)) { throw new errors_1.PolyVValidationError('Output format must be either "table" or "json"', 'output', options.output, 'invalid_value'); } } displayStreamStatusAsTable(statusInfo) { console.log('\nπŸ“Š Stream Status Information:'); const formattedStatus = (0, formatter_1.formatStreamStatusForTable)(statusInfo); this.displayData(formattedStatus, 'table'); if (statusInfo.isLive && statusInfo.metrics) { console.log('\nπŸ’‘ Stream is currently live and broadcasting'); if (statusInfo.network && statusInfo.network.streamName) { console.log(` Stream Name: ${statusInfo.network.streamName}`); } } else if (statusInfo.status === 'waiting') { console.log('\n⏳ Stream is ready but not yet started'); } else if (statusInfo.status === 'stopped') { console.log('\n⏹️ Stream has ended'); } else if (statusInfo.status === 'error') { console.log('\n❌ Stream has an error condition'); } } displayStreamStatusAsJson(statusInfo) { console.log('\nπŸ“Š Stream Status Information (Full):'); const formattedStatus = (0, formatter_1.formatStreamStatusForJson)(statusInfo); console.log(JSON.stringify(formattedStatus, null, 2)); } async displayEnhancedStatus(channelId) { const statusInfo = await this.streamService.getStreamStatus({ channelId }); console.log('\nπŸ“Š Current Status:'); const compactStatus = (0, formatter_1.formatCompactStatus)(statusInfo); console.log(` ${compactStatus}`); if (statusInfo.isLive && statusInfo.metrics) { console.log(` Performance: ${statusInfo.metrics.fps.toFixed(1)} FPS, ${statusInfo.metrics.bandwidthText}`); } if (statusInfo.error) { console.log(` ⚠️ Error: ${statusInfo.error.message}`); } } displaySecurityWarning() { console.log('\nπŸ”’ Security Notice:'); console.log(' β€’ Keep your stream key confidential'); console.log(' β€’ Do not share stream credentials in public channels'); console.log(' β€’ Regenerate stream key if compromised'); } async pushStream(options) { return this.executeWithErrorHandling(async () => { this.validatePushStreamOptions(options); console.log('πŸ” Checking FFmpeg installation...'); const ffmpegInstalled = await (0, ffmpeg_1.isFFmpegInstalled)(); if (!ffmpegInstalled) { throw new Error('FFmpeg is not installed or not found in PATH. Please install FFmpeg to use this feature.'); } console.log('βœ… FFmpeg is available'); console.log('πŸ”‘ Getting stream credentials...'); const credentials = await this.streamService.getStreamKey({ channelId: options.channelId }); console.log('βœ… Stream credentials retrieved'); if (options.verify) { const interval = options.verificationInterval || stream_verification_1.DEFAULT_VERIFICATION_SETTINGS.INTERVAL; const threshold = options.qualityThreshold || stream_verification_1.DEFAULT_VERIFICATION_SETTINGS.QUALITY_THRESHOLD; console.log('πŸ” Verification mode enabled:'); console.log(` Interval: ${interval}s, Quality threshold: ${threshold} FPS`); } if (options.showViewerLinks || options.verify) { console.log('πŸ”— Viewer Links:'); const viewerLinks = (0, stream_verification_1.generateViewerLinks)(options.channelId); viewerLinks.forEach(link => { console.log(` β€’ ${link.protocol}: ${link.url}`); }); console.log(''); } const rtmpUrl = `${credentials.rtmpUrl}/${credentials.streamKey}`; const ffmpegArgs = [ '-re', '-i', options.file, '-c:v', 'copy', '-c:a', 'aac', '-f', 'flv', rtmpUrl ]; console.log('🎬 Starting stream push...'); console.log(` File: ${options.file}`); console.log(` Channel: ${options.channelId}`); console.log(' Press Ctrl+C to stop streaming'); console.log(''); const ffmpegProcess = (0, child_process_1.spawn)('ffmpeg', ffmpegArgs); let verificationInterval = null; let verificationCheckCount = 0; if (options.verify) { const interval = (options.verificationInterval || stream_verification_1.DEFAULT_VERIFICATION_SETTINGS.INTERVAL) * 1000; const threshold = options.qualityThreshold || stream_verification_1.DEFAULT_VERIFICATION_SETTINGS.QUALITY_THRESHOLD; verificationInterval = setInterval(async () => { try { verificationCheckCount++; const statusInfo = await this.streamService.getStreamStatus({ channelId: options.channelId }); const verificationPoint = (0, stream_verification_1.createVerificationPoint)(verificationCheckCount, statusInfo, threshold); const statusEmoji = (0, stream_verification_1.getStatusEmoji)(verificationPoint.status); console.log(`\n⏱️ Verification Check #${verificationCheckCount} (${this.formatDuration(verificationCheckCount * interval / 1000)}):`); console.log(` Status: ${statusEmoji} ${verificationPoint.status} | FPS: ${(0, stream_verification_1.formatFPS)(verificationPoint.metrics.fps)} | Bandwidth: ${(0, formatter_2.formatBandwidth)(verificationPoint.metrics.bandwidth)}`); if (verificationPoint.issues.length > 0) { verificationPoint.issues.forEach(issue => { console.log(` Issue: ${issue.message}`); }); } } catch (error) { console.log(` ⚠️ Verification check failed: ${error instanceof Error ? error.message : String(error)}`); } }, interval); if (process.env['NODE_ENV'] !== 'test' && verificationInterval?.unref) { verificationInterval.unref(); } } ffmpegProcess.stdout.on('data', (data) => { process.stdout.write(data); }); ffmpegProcess.stderr.on('data', (data) => { process.stderr.write(data); }); return new Promise((resolve, reject) => { ffmpegProcess.on('close', (code) => { if (verificationInterval) { clearInterval(verificationInterval); } if (code === 0) { console.log('\nβœ… Stream completed successfully'); if (options.verify && verificationCheckCount > 0) { console.log(`πŸ“Š Verification Summary: ${verificationCheckCount} checks performed`); } resolve(); } else if (code === null) { console.log('\n⏹️ Stream stopped by user'); if (options.verify && verificationCheckCount > 0) { console.log(`πŸ“Š Verification Summary: ${verificationCheckCount} checks performed`); } resolve(); } else { console.log(`\n❌ FFmpeg process exited with code ${code}`); reject(new Error(`FFmpeg process failed with exit code ${code}`)); } }); ffmpegProcess.on('error', (error) => { if (verificationInterval) { clearInterval(verificationInterval); } console.log(`\n❌ FFmpeg process error: ${error.message}`); reject(error); }); const sigintHandler = () => { console.log('\n⏹️ Stopping stream...'); if (verificationInterval) { clearInterval(verificationInterval); } ffmpegProcess.kill('SIGINT'); process.removeListener('SIGINT', sigintHandler); }; process.on('SIGINT', sigintHandler); }); }, 'stream.pushStream'); } validatePushStreamOptions(options) { if (!options.channelId || typeof options.channelId !== 'string' || options.channelId.trim() === '') { throw new errors_1.PolyVValidationError('Channel ID cannot be empty', 'channelId', options.channelId, 'required'); } if (!options.file || typeof options.file !== 'string' || options.file.trim() === '') { throw new errors_1.PolyVValidationError('File path cannot be empty', 'file', options.file, 'required'); } if (!fs.existsSync(options.file)) { throw new errors_1.PolyVValidationError(`File not found: ${options.file}`, 'file', options.file, 'not_found'); } } async verifyStream(options) { return this.executeWithErrorHandling(async () => { this.validateVerifyStreamOptions(options); const duration = options.duration || stream_verification_1.DEFAULT_VERIFICATION_SETTINGS.DURATION; const interval = options.interval || stream_verification_1.DEFAULT_VERIFICATION_SETTINGS.INTERVAL; const threshold = options.qualityThreshold || stream_verification_1.DEFAULT_VERIFICATION_SETTINGS.QUALITY_THRESHOLD; const expectedChecks = Math.floor(duration / interval); console.log(`πŸ” Starting stream verification for channel ${options.channelId}...`); console.log(` Duration: ${duration}s | Interval: ${interval}s | Expected checks: ${expectedChecks}`); console.log(''); const verificationId = (0, stream_verification_1.generateVerificationId)(); const startTime = new Date(); const verificationPoints = []; if (options.showViewerLinks) { console.log('πŸ”— Viewer Links:'); const viewerLinks = (0, stream_verification_1.generateViewerLinks)(options.channelId); viewerLinks.forEach(link => { console.log(` β€’ ${link.protocol}: ${link.url}`); }); console.log(''); } console.log('πŸ“Š Verification Progress:'); for (let i = 0; i < expectedChecks; i++) { const checkNumber = i + 1; const elapsedTime = checkNumber * interval; try { const statusInfo = await this.streamService.getStreamStatus({ channelId: options.channelId }); const verificationPoint = (0, stream_verification_1.createVerificationPoint)(checkNumber, statusInfo, threshold); verificationPoints.push(verificationPoint); const statusEmoji = (0, stream_verification_1.getStatusEmoji)(verificationPoint.status); const timeStr = this.formatDuration(elapsedTime); console.log(` ${checkNumber.toString().padStart(2)}. ${timeStr} | ${statusEmoji} ${verificationPoint.status.padEnd(7)} | FPS: ${(0, stream_verification_1.formatFPS)(verificationPoint.metrics.fps).padEnd(8)} | Bandwidth: ${(0, formatter_2.formatBandwidth)(verificationPoint.metrics.bandwidth).padEnd(10)} | ${statusEmoji}`); if (verificationPoint.issues.length > 0) { verificationPoint.issues.forEach(issue => { console.log(` Issue: ${issue.message}`); }); } } catch (error) { console.log(` ${checkNumber.toString().padStart(2)}. ${this.formatDuration(elapsedTime)} | ❌ Error | Failed to get status: ${error instanceof Error ? error.message : String(error)}`); } if (i < expectedChecks - 1) { await this.sleep(interval * 1000); } } const result = (0, stream_verification_1.createVerificationResult)(options.channelId, verificationId, startTime, verificationPoints, threshold); console.log('\nπŸ“ˆ Verification Summary:'); console.log(` β€’ Overall Status: ${result.summary.overallStatus} (${result.summary.reliability.toFixed(0)}% reliability)`); console.log(` β€’ Average FPS: ${(0, stream_verification_1.formatFPS)(result.averageMetrics.fps)} (Target: >${threshold} FPS)`); console.log(` β€’ Average Bandwidth: ${(0, formatter_2.formatBandwidth)(result.averageMetrics.bandwidth)}`); console.log(` β€’ Issues Found: ${result.summary.totalIssues}`); if (result.summary.recommendations.length > 0) { console.log(' β€’ Recommendations:'); result.summary.recommendations.forEach(rec => { console.log(` - ${rec}`); }); } if (options.saveReport) { const { saveVerificationReport, createVerificationReport } = await Promise.resolve().then(() => __importStar(require('../utils/stream-verification'))); const report = createVerificationReport(result, duration, interval); report.timeline = verificationPoints; await saveVerificationReport(report, options.saveReport); console.log(`\nπŸ’Ύ Verification report saved to: ${options.saveReport}`); } if (options.output === 'json') { console.log('\nπŸ“„ JSON Report:'); console.log(JSON.stringify(result, null, 2)); } }, 'stream.verifyStream'); } async monitorStream(options) { return this.executeWithErrorHandling(async () => { this.validateMonitorStreamOptions(options); const refresh = options.refresh || stream_verification_1.DEFAULT_VERIFICATION_SETTINGS.MONITOR_REFRESH; console.log(`πŸ“Š Stream Monitor - Channel: ${options.channelId} (Refreshing every ${refresh}s)`); console.log('Press Ctrl+C to stop monitoring\n'); let monitorInterval = null; let activityLog = []; const performCheck = async () => { try { const statusInfo = await this.streamService.getStreamStatus({ channelId: options.channelId }); process.stdout.write('\x1Bc'); console.log(`πŸ“Š Stream Monitor - Channel: ${options.channelId} (Refreshing every ${refresh}s)`); console.log('Press Ctrl+C to stop monitoring\n'); const statusEmoji = statusInfo.isLive ? 'βœ…' : '⏸️'; const status = statusInfo.isLive ? 'Live and Healthy' : statusInfo.statusText; console.log('β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”'); console.log(`β”‚ Current Status: ${statusEmoji} ${status.padEnd(40)} β”‚`); if (statusInfo.isLive && statusInfo.metrics) { const duration = statusInfo.durationText || 'Unknown'; const fps = (0, stream_verification_1.formatFPS)(statusInfo.metrics.fps); const bandwidth = (0, formatter_2.formatBandwidth)(statusInfo.metrics.bandwidth); const latency = statusInfo.metrics.lfr ? `${statusInfo.metrics.lfr.toFixed(1)}% LFR` : 'N/A'; console.log(`β”‚ Uptime: ${duration.padEnd(20)} | Viewers: Unknown${' '.padEnd(14)} β”‚`); console.log(`β”‚ FPS: ${fps.padEnd(8)} | Bandwidth: ${bandwidth.padEnd(10)} | Latency: ${latency.padEnd(8)} β”‚`); } else { console.log(`β”‚ Stream is not currently live${' '.padEnd(32)} β”‚`); } console.log('β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n'); if (options.alerts && statusInfo.isLive && statusInfo.metrics) { const qualityIssues = (0, stream_verification_1.analyzeQualityMetrics)({ fps: statusInfo.metrics.fps, bandwidth: statusInfo.metrics.bandwidth, lfr: statusInfo.metrics.lfr }); if (qualityIssues.length > 0) { console.log('⚠️ Quality Alerts:'); qualityIssues.forEach(issue => { const severityEmoji = (0, stream_verification_1.getSeverityEmoji)(issue.severity); console.log(` ${severityEmoji} ${issue.message}`); }); console.log(''); } } console.log('Recent Activity:'); const currentTime = new Date().toLocaleTimeString(); const newActivity = ` ${currentTime} - Stream status: ${status}`; activityLog.unshift(newActivity); if (activityLog.length > 5) { activityLog = activityLog.slice(0, 5); } activityLog.forEach(activity => console.log(activity)); console.log(`\n⏱️ Last updated: ${currentTime}`); console.log(`πŸ”„ Refreshing in ${refresh} seconds...`); } catch (error) { console.log(`❌ Error monitoring stream: ${error instanceof Error ? error.message : String(error)}`); } }; await performCheck(); monitorInterval = setInterval(performCheck, refresh * 1000); if (process.env['NODE_ENV'] !== 'test' && monitorInterval?.unref) { monitorInterval.unref(); } return new Promise((resolve) => { const sigintHandler = () => { if (monitorInterval) { clearInterval(monitorInterval); } console.log('\n\nπŸ‘‹ Monitoring stopped. Goodbye!'); process.removeListener('SIGINT', sigintHandler); resolve(); }; process.on('SIGINT', sigintHandler); }); }, 'stream.monitorStream'); } validateVerifyStreamOptions(options) { if (!options.channelId || typeof options.channelId !== 'string' || options.channelId.trim() === '') { throw new errors_1.PolyVValidationError('Channel ID cannot be empty', 'channelId', options.channelId, 'required'); } if (options.duration && (options.duration < 10 || options.duration > 3600)) { throw new errors_1.PolyVValidationError('Duration must be between 10 and 3600 seconds', 'duration', options.duration, 'out_of_range'); } if (options.interval && (options.interval < 5 || options.interval > 300)) { throw new errors_1.PolyVValidationError('Interval must be between 5 and 300 seconds', 'interval', options.interval, 'out_of_range'); } } validateMonitorStreamOptions(options) { if (!options.channelId || typeof options.channelId !== 'string' || options.channelId.trim() === '') { throw new errors_1.PolyVValidationError('Channel ID cannot be empty', 'channelId', options.channelId, 'required'); } if (options.refresh !== undefined && (options.refresh < 1 || options.refresh > 60)) { throw new errors_1.PolyVValidationError('Refresh interval must be between 1 and 60 seconds', 'refresh', options.refresh, 'out_of_range'); } } sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } formatDuration(seconds) { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } } exports.StreamHandler = StreamHandler; //# sourceMappingURL=stream.handler.js.map