@fanboynz/network-scanner 2.0.3 → 2.0.5
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/lib/browserhealth.js +8 -8
- package/lib/cloudflare.js +97 -61
- package/nwss.js +11 -4
- package/package.json +1 -1
package/lib/browserhealth.js
CHANGED
|
@@ -9,7 +9,7 @@ const { formatLogMessage, messageColors } = require('./colorize');
|
|
|
9
9
|
// Window cleanup delay constant
|
|
10
10
|
const WINDOW_CLEANUP_DELAY_MS = 15000;
|
|
11
11
|
// window_clean REALTIME
|
|
12
|
-
const REALTIME_CLEANUP_BUFFER_MS =
|
|
12
|
+
const REALTIME_CLEANUP_BUFFER_MS = 35000; // Additional buffer time after site delay (increased for Cloudflare)
|
|
13
13
|
const REALTIME_CLEANUP_THRESHOLD = 8; // Default number of pages to keep
|
|
14
14
|
const REALTIME_CLEANUP_MIN_PAGES = 3; // Minimum pages before cleanup kicks in
|
|
15
15
|
|
|
@@ -270,14 +270,14 @@ function updatePageUsage(page, isProcessing = false) {
|
|
|
270
270
|
|
|
271
271
|
/**
|
|
272
272
|
* Performs realtime window cleanup - removes oldest pages when threshold is exceeded
|
|
273
|
-
* Waits for site delay +
|
|
273
|
+
* Waits for site delay + buffer before cleanup, with extended buffer for Cloudflare sites
|
|
274
274
|
* @param {import('puppeteer').Browser} browserInstance - Browser instance
|
|
275
275
|
* @param {number} threshold - Maximum number of pages to keep (default: 8)
|
|
276
276
|
* @param {boolean} forceDebug - Debug logging flag
|
|
277
|
-
* @param {number}
|
|
277
|
+
* @param {number} totalDelay - Total delay including site delay and appropriate buffer (default: 4000 + 15000)
|
|
278
278
|
* @returns {Promise<Object>} Cleanup results
|
|
279
279
|
*/
|
|
280
|
-
async function performRealtimeWindowCleanup(browserInstance, threshold = REALTIME_CLEANUP_THRESHOLD, forceDebug,
|
|
280
|
+
async function performRealtimeWindowCleanup(browserInstance, threshold = REALTIME_CLEANUP_THRESHOLD, forceDebug, totalDelay = 19000) {
|
|
281
281
|
try {
|
|
282
282
|
const allPages = await browserInstance.pages();
|
|
283
283
|
|
|
@@ -289,11 +289,11 @@ async function performRealtimeWindowCleanup(browserInstance, threshold = REALTIM
|
|
|
289
289
|
return { success: true, closedCount: 0, totalPages: allPages.length, reason: 'below_threshold' };
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
-
//
|
|
293
|
-
const cleanupDelay =
|
|
292
|
+
// Use the provided total delay (already includes appropriate buffer)
|
|
293
|
+
const cleanupDelay = totalDelay;
|
|
294
294
|
|
|
295
295
|
if (forceDebug) {
|
|
296
|
-
console.log(formatLogMessage('debug', `[realtime_cleanup] Waiting ${cleanupDelay}ms
|
|
296
|
+
console.log(formatLogMessage('debug', `[realtime_cleanup] Waiting ${cleanupDelay}ms before cleanup (threshold: ${threshold})`));
|
|
297
297
|
}
|
|
298
298
|
await new Promise(resolve => setTimeout(resolve, cleanupDelay));
|
|
299
299
|
|
|
@@ -999,4 +999,4 @@ if (originalPageClose) {
|
|
|
999
999
|
}
|
|
1000
1000
|
return originalPageClose.apply(this, args);
|
|
1001
1001
|
};
|
|
1002
|
-
}
|
|
1002
|
+
}
|
package/lib/cloudflare.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cloudflare bypass and challenge handling module - Optimized with smart detection and adaptive timeouts
|
|
3
|
+
* Version: 2.6.2 - Further detached Frame fixes
|
|
4
|
+
* Version: 2.6.1 - timeoutId is not defined & race condition fix
|
|
3
5
|
* Version: 2.6.0 - Memory leak fixes and timeout cleanup
|
|
4
6
|
* Version: 2.5.0 - Fix Frame Lifecycle issue, Timing and Race condition
|
|
5
7
|
* Version: 2.4.1 - Bump timeout values
|
|
@@ -17,7 +19,7 @@ const { formatLogMessage } = require('./colorize');
|
|
|
17
19
|
/**
|
|
18
20
|
* Module version information
|
|
19
21
|
*/
|
|
20
|
-
const CLOUDFLARE_MODULE_VERSION = '2.6.
|
|
22
|
+
const CLOUDFLARE_MODULE_VERSION = '2.6.1';
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Timeout constants for various operations (in milliseconds)
|
|
@@ -25,11 +27,11 @@ const CLOUDFLARE_MODULE_VERSION = '2.6.0';
|
|
|
25
27
|
* All values tuned for maximum scanning speed while maintaining functionality
|
|
26
28
|
*/
|
|
27
29
|
const TIMEOUTS = {
|
|
28
|
-
PAGE_EVALUATION:
|
|
29
|
-
PAGE_EVALUATION_SAFE:
|
|
30
|
+
PAGE_EVALUATION: 12000, // Standard page evaluation timeout
|
|
31
|
+
PAGE_EVALUATION_SAFE: 12000, // Safe page evaluation with extra buffer
|
|
30
32
|
PHISHING_CLICK: 3000, // Timeout for clicking phishing continue button
|
|
31
33
|
PHISHING_NAVIGATION: 8000, // Wait for navigation after phishing bypass
|
|
32
|
-
JS_CHALLENGE_BUFFER:
|
|
34
|
+
JS_CHALLENGE_BUFFER: 26000, // JS challenge with safety buffer
|
|
33
35
|
TURNSTILE_COMPLETION: 20000, // Turnstile completion check
|
|
34
36
|
TURNSTILE_COMPLETION_BUFFER: 25000, // Turnstile completion with buffer
|
|
35
37
|
CLICK_TIMEOUT: 5000, // Standard click operation timeout
|
|
@@ -49,13 +51,13 @@ const TIMEOUTS = {
|
|
|
49
51
|
|
|
50
52
|
// Fast timeout constants - optimized for speed
|
|
51
53
|
const FAST_TIMEOUTS = {
|
|
52
|
-
QUICK_DETECTION:
|
|
54
|
+
QUICK_DETECTION: 4000, // Fast Cloudflare detection
|
|
53
55
|
PHISHING_WAIT: 1000, // Fast phishing check
|
|
54
56
|
CHALLENGE_WAIT: 500, // Fast challenge detection
|
|
55
57
|
ELEMENT_INTERACTION_DELAY: 250, // Fast element interactions
|
|
56
|
-
SELECTOR_WAIT:
|
|
58
|
+
SELECTOR_WAIT: 3000, // Fast selector waits
|
|
57
59
|
TURNSTILE_OPERATION: 6000, // Fast Turnstile operations
|
|
58
|
-
JS_CHALLENGE:
|
|
60
|
+
JS_CHALLENGE: 19000, // Fast JS challenge completion
|
|
59
61
|
CHALLENGE_SOLVING: 30000, // Fast overall challenge solving
|
|
60
62
|
CHALLENGE_COMPLETION: 8000 // Fast completion check
|
|
61
63
|
};
|
|
@@ -319,21 +321,68 @@ async function safePageEvaluate(page, func, timeout = TIMEOUTS.PAGE_EVALUATION_S
|
|
|
319
321
|
let lastError = null;
|
|
320
322
|
|
|
321
323
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
324
|
+
let timeoutId = null;
|
|
325
|
+
|
|
322
326
|
try {
|
|
323
|
-
//
|
|
327
|
+
// Multi-layered page state validation
|
|
324
328
|
if (page.isClosed()) {
|
|
325
|
-
throw new Error('Page is closed');
|
|
329
|
+
throw new Error('Page is closed or invalid');
|
|
326
330
|
}
|
|
327
331
|
|
|
328
|
-
// Check if
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
+
// Check if page is still navigating or has valid context
|
|
333
|
+
let currentUrl;
|
|
334
|
+
try {
|
|
335
|
+
currentUrl = await page.url();
|
|
336
|
+
if (!currentUrl || currentUrl === 'about:blank') {
|
|
337
|
+
throw new Error('Page URL is invalid or blank');
|
|
338
|
+
}
|
|
339
|
+
} catch (urlError) {
|
|
340
|
+
throw new Error('Page URL access failed - likely detached');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Quick execution context validation with timeout
|
|
344
|
+
const contextValid = await Promise.race([
|
|
345
|
+
page.evaluate(() => {
|
|
346
|
+
try {
|
|
347
|
+
// Quick context validation
|
|
348
|
+
if (typeof window === 'undefined' || !document) {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
// Check if document is ready for interaction
|
|
352
|
+
if (document.readyState === 'uninitialized') {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
return true;
|
|
356
|
+
} catch (e) {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
}),
|
|
360
|
+
new Promise((_, reject) => {
|
|
361
|
+
setTimeout(() => reject(new Error('Context validation timeout')), 3500);
|
|
362
|
+
})
|
|
363
|
+
]).catch(() => false);
|
|
364
|
+
|
|
365
|
+
if (!contextValid) {
|
|
366
|
+
throw new Error('Page execution context is invalid');
|
|
332
367
|
}
|
|
333
368
|
|
|
334
|
-
let timeoutId = null;
|
|
335
369
|
const result = await Promise.race([
|
|
336
|
-
page.evaluate(
|
|
370
|
+
page.evaluate(() => {
|
|
371
|
+
// Additional runtime validation inside evaluation
|
|
372
|
+
try {
|
|
373
|
+
if (typeof window === 'undefined' || !document) {
|
|
374
|
+
throw new Error('Execution context invalid during evaluation');
|
|
375
|
+
}
|
|
376
|
+
return func();
|
|
377
|
+
} catch (evalError) {
|
|
378
|
+
// Return error info instead of throwing to avoid unhandled promise rejections
|
|
379
|
+
return {
|
|
380
|
+
__evaluation_error: true,
|
|
381
|
+
message: evalError.message,
|
|
382
|
+
type: 'evaluation_error'
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
}),
|
|
337
386
|
new Promise((_, reject) => {
|
|
338
387
|
timeoutId = setTimeout(() => reject(new Error('Page evaluation timeout')), timeout);
|
|
339
388
|
})
|
|
@@ -342,9 +391,13 @@ async function safePageEvaluate(page, func, timeout = TIMEOUTS.PAGE_EVALUATION_S
|
|
|
342
391
|
// Clear timeout if evaluation completed first
|
|
343
392
|
if (timeoutId) {
|
|
344
393
|
clearTimeout(timeoutId);
|
|
345
|
-
timeoutId = null;
|
|
346
394
|
}
|
|
347
|
-
|
|
395
|
+
|
|
396
|
+
// Check if evaluation returned an error
|
|
397
|
+
if (result && result.__evaluation_error) {
|
|
398
|
+
throw new Error(`Evaluation failed: ${result.message}`);
|
|
399
|
+
}
|
|
400
|
+
|
|
348
401
|
if (forceDebug && attempt > 1) {
|
|
349
402
|
console.log(formatLogMessage('cloudflare', `Page evaluation succeeded on attempt ${attempt}`));
|
|
350
403
|
}
|
|
@@ -354,7 +407,6 @@ async function safePageEvaluate(page, func, timeout = TIMEOUTS.PAGE_EVALUATION_S
|
|
|
354
407
|
// Ensure timeout is cleared on any error
|
|
355
408
|
if (timeoutId) {
|
|
356
409
|
clearTimeout(timeoutId);
|
|
357
|
-
timeoutId = null;
|
|
358
410
|
}
|
|
359
411
|
|
|
360
412
|
lastError = error;
|
|
@@ -369,23 +421,19 @@ async function safePageEvaluate(page, func, timeout = TIMEOUTS.PAGE_EVALUATION_S
|
|
|
369
421
|
if (forceDebug) {
|
|
370
422
|
console.warn(formatLogMessage('cloudflare', `Detached frame detected on attempt ${attempt}/${maxRetries} - using longer delay`));
|
|
371
423
|
}
|
|
372
|
-
// For detached frames, wait longer
|
|
373
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
424
|
+
// For detached frames, wait longer
|
|
425
|
+
await new Promise(resolve => setTimeout(resolve, 3000)); // Longer delay
|
|
426
|
+
|
|
427
|
+
// For detached frames, only retry once more
|
|
428
|
+
if (attempt >= 2) {
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
374
431
|
continue;
|
|
375
432
|
}
|
|
376
433
|
|
|
377
434
|
// Don't retry if error type is not retryable or if it's the last attempt
|
|
378
435
|
if (!RETRY_CONFIG.retryableErrors.includes(errorType) || attempt === maxRetries) {
|
|
379
|
-
|
|
380
|
-
isChallengePresent: false,
|
|
381
|
-
isPhishingWarning: false,
|
|
382
|
-
isTurnstile: false,
|
|
383
|
-
isJSChallenge: false,
|
|
384
|
-
isChallengeCompleted: false,
|
|
385
|
-
error: error.message,
|
|
386
|
-
errorType: errorType,
|
|
387
|
-
attempts: attempt
|
|
388
|
-
};
|
|
436
|
+
break;
|
|
389
437
|
}
|
|
390
438
|
|
|
391
439
|
// Wait before retrying with exponential backoff
|
|
@@ -393,19 +441,7 @@ async function safePageEvaluate(page, func, timeout = TIMEOUTS.PAGE_EVALUATION_S
|
|
|
393
441
|
}
|
|
394
442
|
}
|
|
395
443
|
|
|
396
|
-
//
|
|
397
|
-
if (lastError?.message.includes('detached Frame') || lastError?.message.includes('Attempted to use detached')) {
|
|
398
|
-
return {
|
|
399
|
-
isChallengePresent: false,
|
|
400
|
-
isPhishingWarning: false,
|
|
401
|
-
isTurnstile: false,
|
|
402
|
-
isJSChallenge: false,
|
|
403
|
-
isChallengeCompleted: false,
|
|
404
|
-
error: 'Frame detached - skipping evaluation',
|
|
405
|
-
errorType: ERROR_TYPES.DETACHED_FRAME,
|
|
406
|
-
attempts: maxRetries
|
|
407
|
-
};
|
|
408
|
-
}
|
|
444
|
+
// Return safe defaults if all retries failed
|
|
409
445
|
|
|
410
446
|
return {
|
|
411
447
|
isChallengePresent: false,
|
|
@@ -427,8 +463,7 @@ async function safeClick(page, selector, timeout = TIMEOUTS.CLICK_TIMEOUT) {
|
|
|
427
463
|
return await Promise.race([
|
|
428
464
|
page.click(selector, { timeout: timeout }),
|
|
429
465
|
new Promise((_, reject) => {
|
|
430
|
-
|
|
431
|
-
// Timer will be cleared when promise resolves/rejects
|
|
466
|
+
setTimeout(() => reject(new Error('Click timeout')), timeout + TIMEOUTS.CLICK_TIMEOUT_BUFFER);
|
|
432
467
|
})
|
|
433
468
|
]);
|
|
434
469
|
} catch (error) {
|
|
@@ -444,8 +479,7 @@ async function safeWaitForNavigation(page, timeout = TIMEOUTS.NAVIGATION_TIMEOUT
|
|
|
444
479
|
return await Promise.race([
|
|
445
480
|
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: timeout }),
|
|
446
481
|
new Promise((_, reject) => {
|
|
447
|
-
|
|
448
|
-
// Timer will be cleared when promise resolves/rejects
|
|
482
|
+
setTimeout(() => reject(new Error('Navigation timeout')), timeout + TIMEOUTS.NAVIGATION_TIMEOUT_BUFFER);
|
|
449
483
|
})
|
|
450
484
|
]);
|
|
451
485
|
} catch (error) {
|
|
@@ -963,13 +997,14 @@ async function attemptChallengeSolveWithTimeout(page, currentUrl, challengeInfo,
|
|
|
963
997
|
const result = {
|
|
964
998
|
success: false,
|
|
965
999
|
error: null,
|
|
966
|
-
method: null
|
|
967
|
-
_timeoutId: null
|
|
1000
|
+
method: null
|
|
968
1001
|
};
|
|
1002
|
+
|
|
1003
|
+
let timeoutId = null;
|
|
969
1004
|
|
|
970
1005
|
try {
|
|
971
1006
|
const timeoutPromise = new Promise((_, reject) => {
|
|
972
|
-
|
|
1007
|
+
timeoutId = setTimeout(() => reject(new Error('Challenge solving timeout')), FAST_TIMEOUTS.CHALLENGE_SOLVING);
|
|
973
1008
|
});
|
|
974
1009
|
// Reduced timeout for challenge solving
|
|
975
1010
|
const finalResult = await Promise.race([
|
|
@@ -977,15 +1012,15 @@ async function attemptChallengeSolveWithTimeout(page, currentUrl, challengeInfo,
|
|
|
977
1012
|
timeoutPromise
|
|
978
1013
|
]);
|
|
979
1014
|
// Clear timeout if operation completed first
|
|
980
|
-
if (
|
|
981
|
-
clearTimeout(
|
|
1015
|
+
if (timeoutId) {
|
|
1016
|
+
clearTimeout(timeoutId);
|
|
982
1017
|
}
|
|
983
1018
|
return finalResult;
|
|
984
1019
|
|
|
985
1020
|
} catch (error) {
|
|
986
1021
|
// Clear timeout on error
|
|
987
|
-
if (
|
|
988
|
-
clearTimeout(
|
|
1022
|
+
if (timeoutId) {
|
|
1023
|
+
clearTimeout(timeoutId);
|
|
989
1024
|
}
|
|
990
1025
|
result.error = `Challenge solving timed out: ${error.message}`;
|
|
991
1026
|
if (forceDebug) console.log(formatLogMessage('cloudflare', `Challenge solving timeout for ${currentUrl}`));
|
|
@@ -1205,15 +1240,16 @@ async function handleEmbeddedIframeChallenge(page, forceDebug = false) {
|
|
|
1205
1240
|
async function waitForJSChallengeCompletion(page, forceDebug = false) {
|
|
1206
1241
|
const result = {
|
|
1207
1242
|
success: false,
|
|
1208
|
-
error: null
|
|
1209
|
-
_timeoutId: null
|
|
1243
|
+
error: null
|
|
1210
1244
|
};
|
|
1211
1245
|
|
|
1246
|
+
let timeoutId = null;
|
|
1247
|
+
|
|
1212
1248
|
try {
|
|
1213
1249
|
if (forceDebug) console.log(formatLogMessage('cloudflare', `Waiting for JS challenge completion`));
|
|
1214
1250
|
|
|
1215
1251
|
const timeoutPromise = new Promise((_, reject) => {
|
|
1216
|
-
|
|
1252
|
+
timeoutId = setTimeout(() => reject(new Error('JS challenge timeout')), TIMEOUTS.JS_CHALLENGE_BUFFER);
|
|
1217
1253
|
});
|
|
1218
1254
|
|
|
1219
1255
|
// Reduced timeout for JS challenge completion
|
|
@@ -1231,16 +1267,16 @@ async function waitForJSChallengeCompletion(page, forceDebug = false) {
|
|
|
1231
1267
|
]);
|
|
1232
1268
|
|
|
1233
1269
|
// Clear timeout if completion detected first
|
|
1234
|
-
if (
|
|
1235
|
-
clearTimeout(
|
|
1270
|
+
if (timeoutId) {
|
|
1271
|
+
clearTimeout(timeoutId);
|
|
1236
1272
|
}
|
|
1237
1273
|
|
|
1238
1274
|
result.success = true;
|
|
1239
1275
|
if (forceDebug) console.log(formatLogMessage('cloudflare', `JS challenge completed automatically`));
|
|
1240
1276
|
} catch (error) {
|
|
1241
1277
|
// Clear timeout on error
|
|
1242
|
-
if (
|
|
1243
|
-
clearTimeout(
|
|
1278
|
+
if (timeoutId) {
|
|
1279
|
+
clearTimeout(timeoutId);
|
|
1244
1280
|
}
|
|
1245
1281
|
|
|
1246
1282
|
result.error = `JS challenge timeout: ${error.message}`;
|
package/nwss.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// === Network scanner script (nwss.js) v2.0.
|
|
1
|
+
// === Network scanner script (nwss.js) v2.0.5 ===
|
|
2
2
|
|
|
3
3
|
// puppeteer for browser automation, fs for file system operations, psl for domain parsing.
|
|
4
4
|
// const pLimit = require('p-limit'); // Will be dynamically imported
|
|
@@ -127,7 +127,7 @@ const { navigateWithRedirectHandling, handleRedirectTimeout } = require('./lib/r
|
|
|
127
127
|
const { monitorBrowserHealth, isBrowserHealthy, isQuicklyResponsive, performGroupWindowCleanup, performRealtimeWindowCleanup, trackPageForRealtime, updatePageUsage } = require('./lib/browserhealth');
|
|
128
128
|
|
|
129
129
|
// --- Script Configuration & Constants ---
|
|
130
|
-
const VERSION = '2.0.
|
|
130
|
+
const VERSION = '2.0.5'; // Script version
|
|
131
131
|
|
|
132
132
|
// get startTime
|
|
133
133
|
const startTime = Date.now();
|
|
@@ -1541,10 +1541,17 @@ function setupFrameHandling(page, forceDebug) {
|
|
|
1541
1541
|
? siteConfig.window_cleanup_threshold
|
|
1542
1542
|
: REALTIME_CLEANUP_THRESHOLD;
|
|
1543
1543
|
|
|
1544
|
-
//
|
|
1544
|
+
// Calculate appropriate delay based on site configuration
|
|
1545
1545
|
const siteDelay = siteConfig.delay || 4000;
|
|
1546
|
+
const hasCloudflareConfig = siteConfig.cloudflare_bypass || siteConfig.cloudflare_phish;
|
|
1547
|
+
const bufferTime = hasCloudflareConfig ? 23000 : REALTIME_CLEANUP_BUFFER_MS; // 23s for Cloudflare, 15s for normal
|
|
1548
|
+
const totalDelay = siteDelay + bufferTime;
|
|
1546
1549
|
|
|
1547
|
-
|
|
1550
|
+
if (forceDebug && hasCloudflareConfig) {
|
|
1551
|
+
console.log(formatLogMessage('debug', `[realtime_cleanup] Using extended delay for Cloudflare site: ${totalDelay}ms (${siteDelay}ms + ${bufferTime}ms CF buffer)`));
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
const realtimeResult = await performRealtimeWindowCleanup(browserInstance, threshold, forceDebug, totalDelay);
|
|
1548
1555
|
if (realtimeResult.success && realtimeResult.closedCount > 0 && forceDebug) {
|
|
1549
1556
|
console.log(formatLogMessage('debug', `[realtime_cleanup] Cleaned ${realtimeResult.closedCount} old pages, ${realtimeResult.remainingPages} remaining`));
|
|
1550
1557
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fanboynz/network-scanner",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.5",
|
|
4
4
|
"description": "A Puppeteer-based network scanner for analyzing web traffic, generating adblock filter rules, and identifying third-party requests. Features include fingerprint spoofing, Cloudflare bypass, content analysis with curl/grep, and multiple output formats.",
|
|
5
5
|
"main": "nwss.js",
|
|
6
6
|
"scripts": {
|