navflow-browser-server
Version:
Standalone Playwright browser server for NavFlow - enables browser automation with API key authentication, workspace device management, session sync, and requires Node.js v22+
861 lines • 32.4 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.browserManager = exports.app = void 0;
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const body_parser_1 = __importDefault(require("body-parser"));
const http_1 = require("http");
const ws_1 = require("ws");
const promises_1 = __importDefault(require("fs/promises"));
const path_1 = __importDefault(require("path"));
const BrowserManager_1 = require("./BrowserManager");
const FlowExecutor_1 = require("./FlowExecutor");
const ScreenShareService_1 = require("./ScreenShareService");
const DeviceRegistry_1 = require("./DeviceRegistry");
const PlaywrightInstaller_1 = require("./PlaywrightInstaller");
const HeartbeatService_1 = require("./HeartbeatService");
const UpdateService_1 = require("./UpdateService");
const HumanLoopBannerService_1 = require("./HumanLoopBannerService");
const DataDirectory_1 = require("./DataDirectory");
const app = (0, express_1.default)();
exports.app = app;
const PORT = Number(process.env.BROWSER_SERVER_PORT) || 3002;
const deviceRegistry = new DeviceRegistry_1.DeviceRegistry();
const browserManager = new BrowserManager_1.BrowserManager(deviceRegistry);
exports.browserManager = browserManager;
const screenShareService = new ScreenShareService_1.ScreenShareService();
const humanLoopBannerService = new HumanLoopBannerService_1.HumanLoopBannerService();
// Initialize FlowExecutor with device info after device registration
let flowExecutor;
let heartbeatService = null;
let updateService = null;
// Middleware
app.use((0, cors_1.default)({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Device-API-Key'],
credentials: false
}));
app.use(body_parser_1.default.json({ limit: '50mb' }));
app.use(body_parser_1.default.urlencoded({ extended: true }));
// Health check
app.get('/health', (req, res) => {
const deviceInfo = deviceRegistry.getDeviceInfo();
res.json({
status: 'healthy',
activeSessions: browserManager.getSessionCount(),
timestamp: new Date().toISOString(),
device: deviceInfo ? {
name: deviceInfo.deviceName,
macAddress: deviceInfo.macAddress,
port: deviceInfo.port
} : null
});
});
// Device information endpoint
app.get('/device', (req, res) => {
const deviceInfo = deviceRegistry.getDeviceInfo();
if (!deviceInfo) {
return res.status(500).json({ error: 'Device not initialized' });
}
res.json({
name: deviceInfo.deviceName,
macAddress: deviceInfo.macAddress,
port: deviceInfo.port,
createdAt: deviceInfo.createdAt
});
});
// API Key Authentication Middleware for protected routes
const requireApiKey = deviceRegistry.getAuthMiddleware();
// Authentication session management - use global directory for persistence across versions
const SESSIONS_DIR = DataDirectory_1.DataDirectory.getSessionsDir();
// Ensure sessions directory exists and migrate legacy data
const initializeDataDirectories = async () => {
await DataDirectory_1.DataDirectory.ensureDirectories();
// Check for and migrate legacy session data
const migration = await DataDirectory_1.DataDirectory.migrateLegacyData();
if (migration.errors.length > 0) {
migration.errors.forEach(error => console.error('Migration error:', error));
}
};
// Initialize data directories and migration on startup
initializeDataDirectories();
// Get all authentication sessions
app.get('/api/sessions', requireApiKey, async (req, res) => {
try {
await initializeDataDirectories();
const sessionFiles = await promises_1.default.readdir(SESSIONS_DIR);
const sessions = [];
for (const file of sessionFiles) {
if (file.endsWith('.json')) {
try {
const sessionPath = path_1.default.join(SESSIONS_DIR, file);
const sessionData = await promises_1.default.readFile(sessionPath, 'utf-8');
const session = JSON.parse(sessionData);
sessions.push({
name: session.name,
description: session.description,
createdAt: session.createdAt,
lastUsed: session.lastUsed
});
}
catch (error) {
console.error(`Failed to read session file ${file}:`, error);
}
}
}
res.json(sessions);
}
catch (error) {
console.error('Failed to list authentication sessions:', error);
res.status(500).json({
error: 'Failed to list sessions',
message: error.message
});
}
});
// Create new authentication session
app.post('/api/sessions', requireApiKey, async (req, res) => {
try {
const { name, description } = req.body;
if (!name || typeof name !== 'string' || !name.trim()) {
return res.status(400).json({ error: 'Session name is required' });
}
const sessionName = name.trim();
const sessionPath = path_1.default.join(SESSIONS_DIR, `${sessionName}.json`);
// Check if session already exists
try {
await promises_1.default.access(sessionPath);
return res.status(409).json({ error: 'Session already exists' });
}
catch {
// Session doesn't exist, which is what we want
}
const sessionData = {
name: sessionName,
description: description || '',
createdAt: new Date().toISOString(),
lastUsed: null,
cookies: [],
localStorage: {},
sessionStorage: {}
};
await initializeDataDirectories();
await promises_1.default.writeFile(sessionPath, JSON.stringify(sessionData, null, 2));
res.json({
success: true,
name: sessionName,
message: 'Authentication session created successfully'
});
}
catch (error) {
console.error('Failed to create authentication session:', error);
res.status(500).json({
error: 'Failed to create session',
message: error.message
});
}
});
// Delete authentication session
app.delete('/api/sessions/:sessionName', requireApiKey, async (req, res) => {
try {
const { sessionName } = req.params;
if (!sessionName) {
return res.status(400).json({ error: 'Session name is required' });
}
const sessionPath = path_1.default.join(SESSIONS_DIR, `${sessionName}.json`);
try {
await promises_1.default.unlink(sessionPath);
res.json({
success: true,
message: 'Authentication session deleted successfully'
});
}
catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'Session not found' });
}
throw error;
}
}
catch (error) {
console.error('Failed to delete authentication session:', error);
res.status(500).json({
error: 'Failed to delete session',
message: error.message
});
}
});
// Create a new browser session
app.post('/sessions', requireApiKey, async (req, res) => {
try {
const { sessionId, config, userContext } = req.body;
if (!sessionId) {
return res.status(400).json({ error: 'sessionId is required' });
}
const session = await browserManager.createSession(sessionId, config, userContext);
res.json({
success: true,
sessionId: session.id,
message: 'Browser session created successfully',
isolatedSession: !!userContext?.userId
});
}
catch (error) {
console.error('Failed to create session:', error);
res.status(500).json({
error: 'Failed to create browser session',
message: error.message
});
}
});
// Execute an action in a browser session
app.post('/sessions/:sessionId/actions', requireApiKey, async (req, res) => {
try {
const { sessionId } = req.params;
const { action } = req.body;
if (!action) {
return res.status(400).json({ error: 'action is required' });
}
const result = await browserManager.executeAction(sessionId, action);
if (result.success) {
res.json(result);
}
else {
res.status(500).json(result);
}
}
catch (error) {
console.error('Failed to execute action:', error);
res.status(500).json({
error: 'Failed to execute action',
message: error.message
});
}
});
// Get session info
app.get('/sessions/:sessionId', async (req, res) => {
try {
const { sessionId } = req.params;
const session = await browserManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
res.json({
sessionId: session.id,
createdAt: session.createdAt,
lastUsed: session.lastUsed,
status: 'active'
});
}
catch (error) {
console.error('Failed to get session info:', error);
res.status(500).json({
error: 'Failed to get session info',
message: error.message
});
}
});
// Close a browser session
app.delete('/sessions/:sessionId', async (req, res) => {
try {
const { sessionId } = req.params;
await browserManager.closeSession(sessionId);
res.json({
success: true,
message: 'Browser session closed successfully'
});
}
catch (error) {
console.error('Failed to close session:', error);
res.status(500).json({
error: 'Failed to close browser session',
message: error.message
});
}
});
// List all active sessions
app.get('/sessions', (req, res) => {
try {
const activeSessions = browserManager.getActiveSessions();
res.json({
sessions: activeSessions,
count: activeSessions.length
});
}
catch (error) {
console.error('Failed to list sessions:', error);
res.status(500).json({
error: 'Failed to list sessions',
message: error.message
});
}
});
// Save session state (cookies, localStorage, etc.)
app.post('/sessions/:sessionId/save', async (req, res) => {
try {
const { sessionId } = req.params;
await browserManager.saveSession(sessionId);
res.json({
success: true,
message: 'Session state saved successfully'
});
}
catch (error) {
console.error('Failed to save session:', error);
res.status(500).json({
error: 'Failed to save session state',
message: error.message
});
}
});
// Sync session with proxy-server for workspace sharing
app.post('/sessions/:sessionId/sync', requireApiKey, async (req, res) => {
try {
const { sessionId } = req.params;
const { workspaceId, userId, sessionName, sessionType = 'private' } = req.body;
if (!workspaceId || !userId) {
return res.status(400).json({
error: 'Missing required fields',
message: 'workspaceId and userId are required'
});
}
// Get browser session data
const session = await browserManager.getSession(sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
// Extract session data for syncing
const sessionData = {
name: sessionName || `Session ${new Date().toLocaleString()}`,
sessionType: sessionType,
cookies: [], // Will be populated by BrowserManager
localStorage: {},
sessionStorage: {},
browserContext: {
userAgent: session.context ? await session.context.userAgent() : undefined,
viewport: session.context ? session.context.viewportSize() : undefined
}
};
// Try to extract cookies if context exists
if (session.context) {
try {
sessionData.cookies = await session.context.cookies();
}
catch (error) {
console.warn('Could not extract cookies:', error);
}
}
// Sync with proxy-server
const success = await deviceRegistry.syncSessionWithProxy(sessionId, sessionData, workspaceId, userId);
if (success) {
res.json({
success: true,
message: 'Session synced with workspace successfully'
});
}
else {
res.status(500).json({
error: 'Failed to sync session',
message: 'Could not sync session with proxy-server'
});
}
}
catch (error) {
console.error('Failed to sync session:', error);
res.status(500).json({
error: 'Failed to sync session',
message: error.message
});
}
});
// Get synced sessions from proxy-server
app.get('/synced-sessions', requireApiKey, async (req, res) => {
try {
const { userId } = req.query;
const sessions = await deviceRegistry.getSyncedSessions(userId);
res.json({
success: true,
sessions: sessions,
count: sessions.length
});
}
catch (error) {
console.error('Failed to get synced sessions:', error);
res.status(500).json({
error: 'Failed to get synced sessions',
message: error.message
});
}
});
// Load synced session from proxy-server
app.post('/sessions/:sessionId/load-synced', requireApiKey, async (req, res) => {
try {
const { sessionId } = req.params;
const { syncedSessionId, userId } = req.body;
if (!syncedSessionId) {
return res.status(400).json({
error: 'Missing synced session ID',
message: 'syncedSessionId is required'
});
}
// Get synced sessions and find the one we want
const syncedSessions = await deviceRegistry.getSyncedSessions(userId);
const targetSession = syncedSessions.find(s => s.id === syncedSessionId);
if (!targetSession) {
return res.status(404).json({
error: 'Synced session not found',
message: 'The requested synced session was not found or is not accessible'
});
}
// Create or get browser session with user context for isolation
let session;
try {
session = await browserManager.getSession(sessionId);
}
catch {
// Session doesn't exist, create it with user isolation
const userContext = { userId: userId, sessionName: targetSession.name };
session = await browserManager.createSession(sessionId, {
viewport: targetSession.browserContext?.viewport || { width: 1920, height: 1080 }
}, userContext);
}
// Apply synced session data
if (session && session.context && targetSession.cookies) {
try {
await session.context.addCookies(targetSession.cookies);
console.log(`✅ Loaded ${targetSession.cookies.length} cookies from synced session`);
}
catch (error) {
console.warn('Could not load cookies from synced session:', error);
}
}
res.json({
success: true,
message: `Synced session "${targetSession.name}" loaded successfully`,
sessionData: {
name: targetSession.name,
type: targetSession.sessionType,
cookieCount: targetSession.cookies?.length || 0,
lastUsed: targetSession.lastUsed
}
});
}
catch (error) {
console.error('Failed to load synced session:', error);
res.status(500).json({
error: 'Failed to load synced session',
message: error.message
});
}
});
// Execute a flow
app.post('/execute-flow', requireApiKey, async (req, res) => {
try {
const { flow, browserConfig, userContext, userInputVariables, stepScreenshots, sessionId } = req.body;
if (!flow) {
return res.status(400).json({ error: 'Flow data is required' });
}
if (!flowExecutor) {
return res.status(503).json({ error: 'FlowExecutor not initialized' });
}
// If sessionId is provided directly in request body, use it
// This maintains backward compatibility while supporting both patterns
const flowWithSessionId = {
...flow,
sessionId: sessionId || flow.sessionId // Prioritize direct sessionId over flow.sessionId
};
const result = await flowExecutor.executeFlow(flowWithSessionId, browserConfig, userContext, userInputVariables, stepScreenshots);
res.json(result);
}
catch (error) {
console.error('Failed to execute flow:', error);
res.status(500).json({
error: 'Failed to execute flow',
message: error.message
});
}
});
// Get available browsers
app.get('/browsers', async (req, res) => {
try {
// Return available browser types
const browsers = [
{ type: 'chromium', name: 'Chromium' },
{ type: 'firefox', name: 'Firefox' },
{ type: 'webkit', name: 'WebKit (Safari)' }
];
res.json({
success: true,
browsers
});
}
catch (error) {
console.error('Failed to get available browsers:', error);
res.status(500).json({
error: 'Failed to get available browsers',
message: error.message
});
}
});
// Screen sharing endpoints
app.post('/sessions/:sessionId/screen-share/start', async (req, res) => {
try {
const { sessionId } = req.params;
const browserSession = await browserManager.getSession(sessionId);
if (!browserSession) {
return res.status(404).json({ error: 'Browser session not found' });
}
await screenShareService.startScreenShare(sessionId, browserSession);
res.json({
success: true,
message: 'Screen sharing started successfully'
});
}
catch (error) {
console.error('Failed to start screen sharing:', error);
res.status(500).json({
error: 'Failed to start screen sharing',
message: error.message
});
}
});
app.post('/sessions/:sessionId/screen-share/stop', async (req, res) => {
try {
const { sessionId } = req.params;
await screenShareService.stopScreenShare(sessionId);
res.json({
success: true,
message: 'Screen sharing stopped successfully'
});
}
catch (error) {
console.error('Failed to stop screen sharing:', error);
res.status(500).json({
error: 'Failed to stop screen sharing',
message: error.message
});
}
});
app.get('/sessions/:sessionId/screen-share/status', async (req, res) => {
try {
const { sessionId } = req.params;
const status = screenShareService.getScreenShareStatus(sessionId);
res.json({
success: true,
...status
});
}
catch (error) {
console.error('Failed to get screen share status:', error);
res.status(500).json({
error: 'Failed to get screen share status',
message: error.message
});
}
});
app.post('/sessions/:sessionId/screen-share/offer', async (req, res) => {
try {
const { sessionId } = req.params;
const offer = await screenShareService.createOffer(sessionId);
res.json({
success: true,
offer
});
}
catch (error) {
console.error('Failed to create WebRTC offer:', error);
res.status(500).json({
error: 'Failed to create WebRTC offer',
message: error.message
});
}
});
app.post('/sessions/:sessionId/screen-share/answer', async (req, res) => {
try {
const { sessionId } = req.params;
const { answer } = req.body;
if (!answer) {
return res.status(400).json({ error: 'Answer is required' });
}
await screenShareService.handleAnswer(sessionId, answer);
res.json({
success: true,
message: 'Answer processed successfully'
});
}
catch (error) {
console.error('Failed to handle WebRTC answer:', error);
res.status(500).json({
error: 'Failed to handle WebRTC answer',
message: error.message
});
}
});
app.post('/sessions/:sessionId/screen-share/ice-candidate', async (req, res) => {
try {
const { sessionId } = req.params;
const { candidate } = req.body;
if (!candidate) {
return res.status(400).json({ error: 'ICE candidate is required' });
}
await screenShareService.addIceCandidate(sessionId, candidate);
res.json({
success: true,
message: 'ICE candidate added successfully'
});
}
catch (error) {
console.error('Failed to add ICE candidate:', error);
res.status(500).json({
error: 'Failed to add ICE candidate',
message: error.message
});
}
});
// Human-in-the-loop banner API endpoints
app.post('/api/human-loop/complete', async (req, res) => {
try {
console.log('📞 Human-loop complete request received:', req.body);
const { sessionKey, userResponse } = req.body;
if (!sessionKey) {
return res.status(400).json({ error: 'sessionKey is required' });
}
await humanLoopBannerService.handleUserResponse(sessionKey, 'complete', userResponse || 'User completed the task');
res.json({
success: true,
message: 'Task marked as complete'
});
}
catch (error) {
console.error('Failed to handle human-loop completion:', error);
res.status(500).json({
error: 'Failed to complete task',
message: error.message
});
}
});
app.post('/api/human-loop/extend', async (req, res) => {
try {
console.log('📞 Human-loop extend request received:', req.body);
const { sessionKey, extensionTime } = req.body;
if (!sessionKey) {
return res.status(400).json({ error: 'sessionKey is required' });
}
const extension = extensionTime || 300; // Default 5 minutes
await humanLoopBannerService.handleUserResponse(sessionKey, 'extend', undefined, extension);
res.json({
success: true,
message: `Time extended by ${Math.floor(extension / 60)} minutes`,
extensionTime: extension
});
}
catch (error) {
console.error('Failed to extend human-loop time:', error);
res.status(500).json({
error: 'Failed to extend time',
message: error.message
});
}
});
app.get('/api/human-loop/status', async (req, res) => {
try {
const activeSessions = humanLoopBannerService.getActiveSessions();
res.json({
success: true,
activeSessions,
count: activeSessions.length
});
}
catch (error) {
console.error('Failed to get human-loop status:', error);
res.status(500).json({
error: 'Failed to get status',
message: error.message
});
}
});
// Start recording browser actions
app.post('/record', async (req, res) => {
try {
const sessionId = req.body.sessionId || `recording_${Date.now()}`;
// Create browser session for recording
const session = await browserManager.createSession(sessionId, {
headless: false, // Recording should be visible
viewport: { width: 1920, height: 1080 }
});
res.json({
success: true,
sessionId: session.id,
message: 'Recording started. Browser window opened for interaction.'
});
}
catch (error) {
console.error('Failed to start recording:', error);
res.status(500).json({
error: 'Failed to start recording',
message: error.message
});
}
});
// Create HTTP server
const server = (0, http_1.createServer)(app);
// Set up WebSocket server for real-time communication (optional)
const wss = new ws_1.WebSocketServer({ server });
wss.on('connection', (ws) => {
console.log('WebSocket client connected');
// Register WebSocket client with FlowExecutor for response overlay messages
if (flowExecutor) {
console.log('Registering WebSocket client with FlowExecutor');
flowExecutor.addWebSocketClient(ws);
}
else {
console.log('FlowExecutor not ready, will register client later');
// Store the websocket to register later when FlowExecutor is ready
const registerLater = () => {
if (flowExecutor) {
console.log('Late registering WebSocket client with FlowExecutor');
flowExecutor.addWebSocketClient(ws);
}
else {
setTimeout(registerLater, 100);
}
};
registerLater();
}
ws.on('message', async (message) => {
try {
const data = JSON.parse(message.toString());
if (data.type === 'execute_action') {
const result = await browserManager.executeAction(data.sessionId, data.action);
ws.send(JSON.stringify({ type: 'action_result', ...result }));
}
}
catch (error) {
ws.send(JSON.stringify({
type: 'error',
error: error.message
}));
}
});
ws.on('close', () => {
console.log('WebSocket client disconnected');
// Remove WebSocket client from FlowExecutor
if (flowExecutor) {
flowExecutor.removeWebSocketClient(ws);
}
});
});
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('Received SIGTERM, shutting down gracefully...');
// Stop heartbeat service
if (heartbeatService) {
heartbeatService.stop();
}
// Close WebSocket connection
deviceRegistry.closeWebSocket();
// Clean up human-in-the-loop banners
await humanLoopBannerService.cleanup();
// Close all browser sessions
const sessions = browserManager.getActiveSessions();
await Promise.all(sessions.map(sessionId => browserManager.closeSession(sessionId)));
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});
process.on('SIGINT', async () => {
console.log('Received SIGINT, shutting down gracefully...');
// Stop heartbeat service
if (heartbeatService) {
heartbeatService.stop();
}
// Close WebSocket connection
deviceRegistry.closeWebSocket();
// Clean up human-in-the-loop banners
await humanLoopBannerService.cleanup();
// Close all browser sessions
const sessions = browserManager.getActiveSessions();
await Promise.all(sessions.map(sessionId => browserManager.closeSession(sessionId)));
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});
// Start server
server.listen(PORT, async () => {
console.log(`🌊 NavFlow Browser Server starting on port ${PORT}...`);
console.log(`📍 Health check: http://localhost:${PORT}/health`);
console.log(`🔌 WebSocket: ws://localhost:${PORT}`);
console.log('');
try {
// Check and install Playwright browsers
await PlaywrightInstaller_1.PlaywrightInstaller.ensurePlaywrightBrowsers();
// Display system status
await PlaywrightInstaller_1.PlaywrightInstaller.displaySystemStatus();
// Initialize device registry
console.log('🔧 Initializing device registry...');
await deviceRegistry.initialize(PORT);
deviceRegistry.displayDeviceInfo();
// Initialize FlowExecutor with device info for human-loop banner support
const deviceInfo = deviceRegistry.getDeviceInfo();
const proxyServerUrl = process.env.PROXY_SERVER_URL || 'https://navflow-proxy-858493283701.us-central1.run.app';
flowExecutor = new FlowExecutor_1.FlowExecutor(browserManager, screenShareService, humanLoopBannerService, deviceInfo?.apiKey, proxyServerUrl);
console.log('🔧 FlowExecutor initialized with device API key for banner communication');
console.log('🔌 FlowExecutor ready for WebSocket connections');
const packageJson = require('../package.json');
console.log(`🚀 NavFlow Browser Server v${packageJson.version} is ready for API key connections!`);
// Ensure device is registered with proxy-server and establish WebSocket connection
setTimeout(async () => {
await deviceRegistry.ensureProxyRegistration();
// Establish WebSocket connection to proxy-server for device communication
// This allows the proxy-server to forward requests to this device
// without needing direct HTTP access to localhost
await deviceRegistry.connectToProxyServer();
// Initialize heartbeat service after device registration
const deviceInfo = deviceRegistry.getDeviceInfo();
if (deviceInfo) {
const packageJson = require('../package.json');
heartbeatService = new HeartbeatService_1.HeartbeatService(browserManager, deviceInfo.apiKey, packageJson.version);
heartbeatService.start();
console.log('💓 Heartbeat service started');
// Initialize update service for npm version monitoring
updateService = new UpdateService_1.UpdateService('navflow-browser-server', packageJson.version, async () => {
console.log('💤 Preparing for auto-update shutdown...');
if (heartbeatService) {
heartbeatService.stop();
}
deviceRegistry.closeWebSocket();
await new Promise(resolve => setTimeout(resolve, 1000));
});
// Schedule periodic npm version checks (every 2 hours)
const updateCheckInterval = updateService.scheduleUpdateCheck(120);
console.log('🔄 Update monitoring enabled (checks every 2 hours)');
// Cleanup on shutdown
process.on('SIGTERM', () => {
if (updateCheckInterval)
clearInterval(updateCheckInterval);
});
process.on('SIGINT', () => {
if (updateCheckInterval)
clearInterval(updateCheckInterval);
});
}
}, 2000); // Wait 2 seconds for server to be fully ready
}
catch (error) {
console.error('❌ Failed to initialize browser server:', error);
console.log('');
console.log('💡 Troubleshooting tips:');
console.log(' • Ensure Node.js version >= 22.0.0');
console.log(' • Check internet connection for Playwright download');
console.log(' • Try running: npx playwright install');
console.log('');
process.exit(1);
}
});
//# sourceMappingURL=index.js.map