inspectra-engine 0.0.0-stage → 1.0.2
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/CHANGELOG.md +58 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +132 -2
- package/bin/inspectra.mjs +68 -0
- package/client/index.d.mts +85 -0
- package/client/index.mjs +2 -0
- package/compare.js +439 -0
- package/dom-compare.js +263 -0
- package/examples/README.md +105 -0
- package/examples/figma-scan.json +12 -0
- package/examples/png-metadata.json +5 -0
- package/examples/runner-scan.json +7 -0
- package/node/analysis-worker.mjs +33 -0
- package/node/dev-config.mjs +18 -0
- package/node/event-stream.mjs +10 -0
- package/node/index.d.mts +114 -0
- package/node/index.mjs +100 -0
- package/node/job-manager.mjs +172 -0
- package/node/png-highlights.mjs +30 -0
- package/node/runner.mjs +135 -0
- package/package.json +136 -4
- package/pages.json +8 -0
- package/scan.js +27 -0
- package/scanner/README.md +70 -0
- package/scanner/accessibility.mjs +10 -0
- package/scanner/browser-snapshot.js +128 -0
- package/scanner/figma-snapshot.mjs +221 -0
- package/scanner/legacy-image-tools.cjs +14 -0
- package/scanner/ocr/local.mjs +52 -0
- package/scanner/ocr/vision.swift +29 -0
- package/scanner/png-regions.mjs +53 -0
- package/scanner/png-snapshot.mjs +192 -0
- package/scripts/check-build.mjs +12 -0
- package/server.js +361 -0
- package/ui/compare-2.html +946 -0
- package/ui/compare-3.html +1222 -0
- package/ui/compare.html +1408 -0
- package/ui/index.html +136 -0
- package/ui-check.js +1493 -0
package/server.js
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// Local UI: paste a URL, get a WCAG accessibility report. Run: npm run ui
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { chromium } = require('playwright');
|
|
6
|
+
|
|
7
|
+
const PORT = process.env.PORT || 3000;
|
|
8
|
+
|
|
9
|
+
const REVIEWS_FILE = process.env.UI_REVIEWS_FILE || path.join(__dirname, 'visual-reports', 'reviews.json');
|
|
10
|
+
const REVIEW_STATUSES = ['issue', 'accepted', 'ignored'];
|
|
11
|
+
const reviewKey = (ref, tgt) => `${String(ref || '').trim()} -> ${String(tgt || '').trim()}`;
|
|
12
|
+
function readReviews() {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(fs.readFileSync(REVIEWS_FILE, 'utf8'));
|
|
15
|
+
} catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function writeReviews(all) {
|
|
20
|
+
fs.mkdirSync(path.dirname(REVIEWS_FILE), { recursive: true });
|
|
21
|
+
const tmp = `${REVIEWS_FILE}.tmp`;
|
|
22
|
+
fs.writeFileSync(tmp, JSON.stringify(all, null, 2));
|
|
23
|
+
fs.renameSync(tmp, REVIEWS_FILE);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Lighthouse-style scoring: weighted share of applicable rules that pass.
|
|
27
|
+
const WEIGHT = { critical: 10, serious: 7, moderate: 3, minor: 1 };
|
|
28
|
+
const LEVEL_A = ['wcag2a', 'wcag21a'];
|
|
29
|
+
const LEVEL_AA = ['wcag2aa', 'wcag21aa', 'wcag22aa'];
|
|
30
|
+
|
|
31
|
+
function ruleWeight(rule, failed) {
|
|
32
|
+
const impact = failed
|
|
33
|
+
? rule.impact
|
|
34
|
+
: rule.nodes.flatMap(n => [...n.any, ...n.all]).map(c => c.impact).find(Boolean);
|
|
35
|
+
return WEIGHT[impact] || WEIGHT.moderate;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function score(violations, passes, tags) {
|
|
39
|
+
const hit = r => !tags || r.tags.some(t => tags.includes(t));
|
|
40
|
+
let pass = 0, total = 0, failed = 0, passed = 0;
|
|
41
|
+
for (const r of passes.filter(hit)) { const w = ruleWeight(r, false); pass += w; total += w; passed++; }
|
|
42
|
+
for (const r of violations.filter(hit)) { total += ruleWeight(r, true); failed++; }
|
|
43
|
+
return { score: total ? Math.round((pass / total) * 100) : null, passed, failed };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Flatten a Lighthouse table cell into display text.
|
|
47
|
+
function cell(v) {
|
|
48
|
+
if (v == null) return '';
|
|
49
|
+
if (typeof v !== 'object') return String(v);
|
|
50
|
+
if (v.type === 'node') return v.snippet || v.selector || '';
|
|
51
|
+
if (v.type === 'source-location') return `${v.url}:${(v.line || 0) + 1}`;
|
|
52
|
+
if (v.type === 'link') return v.url || v.text || '';
|
|
53
|
+
if (v.type === 'code') return String(v.value);
|
|
54
|
+
if (v.type === 'numeric') return String(Math.round(v.value * 100) / 100);
|
|
55
|
+
if (v.url) return v.url;
|
|
56
|
+
if (v.value != null) return String(v.value);
|
|
57
|
+
if (v.text != null) return String(v.text);
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function auditDetails(d) {
|
|
62
|
+
if (!d || !['table', 'opportunity', 'list'].includes(d.type)) return null;
|
|
63
|
+
if (d.type === 'list') return null;
|
|
64
|
+
const heads = (d.headings || []).filter(h => h.key).map(h => ({ key: h.key, label: cell(h.label) }));
|
|
65
|
+
const rows = (d.items || []).slice(0, 15).map(it => heads.map(h => cell(it[h.key])));
|
|
66
|
+
return rows.length ? { headings: heads.map(h => h.label), rows, total: (d.items || []).length } : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function lighthouseScores(url, port) {
|
|
70
|
+
const { default: lighthouse } = await import('lighthouse');
|
|
71
|
+
const { default: agenticConfig } = await import('lighthouse/core/config/agentic-browsing-config.js');
|
|
72
|
+
const { lhr, report } = await lighthouse(
|
|
73
|
+
url,
|
|
74
|
+
{
|
|
75
|
+
port,
|
|
76
|
+
output: ['json', 'html'],
|
|
77
|
+
logLevel: 'error',
|
|
78
|
+
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo', 'agentic-browsing']
|
|
79
|
+
},
|
|
80
|
+
agenticConfig
|
|
81
|
+
);
|
|
82
|
+
const scores = {};
|
|
83
|
+
const categories = [];
|
|
84
|
+
for (const [k, c] of Object.entries(lhr.categories)) {
|
|
85
|
+
const audits = c.auditRefs
|
|
86
|
+
.filter(r => r.weight > 0 || r.group)
|
|
87
|
+
.map(r => {
|
|
88
|
+
const a = lhr.audits[r.id];
|
|
89
|
+
if (!a) return null;
|
|
90
|
+
const mode = a.scoreDisplayMode;
|
|
91
|
+
const status =
|
|
92
|
+
mode === 'notApplicable' ? 'na'
|
|
93
|
+
: mode === 'manual' ? 'manual'
|
|
94
|
+
: mode === 'informative' ? 'info'
|
|
95
|
+
: mode === 'error' ? 'error'
|
|
96
|
+
: a.score === 1 || a.score >= 0.9 ? 'pass'
|
|
97
|
+
: 'fail';
|
|
98
|
+
return {
|
|
99
|
+
id: a.id,
|
|
100
|
+
title: a.title,
|
|
101
|
+
description: a.description,
|
|
102
|
+
displayValue: a.displayValue || '',
|
|
103
|
+
score: a.score,
|
|
104
|
+
status,
|
|
105
|
+
group: r.group ? (lhr.categoryGroups?.[r.group]?.title || r.group) : '',
|
|
106
|
+
details: status === 'pass' ? null : auditDetails(a.details)
|
|
107
|
+
};
|
|
108
|
+
})
|
|
109
|
+
.filter(Boolean);
|
|
110
|
+
if (k === 'agentic-browsing') {
|
|
111
|
+
// Fraction category: passed / applicable audits.
|
|
112
|
+
const applicable = audits.filter(a => ['pass', 'fail'].includes(a.status));
|
|
113
|
+
scores[k] = { passed: applicable.filter(a => a.status === 'pass').length, total: applicable.length };
|
|
114
|
+
} else {
|
|
115
|
+
scores[k] = c.score == null ? null : Math.round(c.score * 100);
|
|
116
|
+
}
|
|
117
|
+
categories.push({ id: k, title: c.title, audits });
|
|
118
|
+
}
|
|
119
|
+
return { scores, categories, report: report[1] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const reports = new Map(); // id -> full Lighthouse HTML report
|
|
123
|
+
let reportSeq = 0;
|
|
124
|
+
|
|
125
|
+
async function scan(url, bestPractice) {
|
|
126
|
+
const port = 9300 + Math.floor(Math.random() * 500);
|
|
127
|
+
const browser = await chromium.launch({ args: [`--remote-debugging-port=${port}`] });
|
|
128
|
+
try {
|
|
129
|
+
let lighthouse = null, lighthouseError = null, lighthouseCategories = null, reportId = null;
|
|
130
|
+
try {
|
|
131
|
+
const lh = await lighthouseScores(url, port);
|
|
132
|
+
lighthouse = lh.scores;
|
|
133
|
+
lighthouseCategories = lh.categories;
|
|
134
|
+
reportId = String(++reportSeq);
|
|
135
|
+
reports.set(reportId, lh.report);
|
|
136
|
+
if (reports.size > 10) reports.delete(reports.keys().next().value);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
lighthouseError = String(e.message).split('\n')[0];
|
|
139
|
+
console.error('Lighthouse failed:', e.message);
|
|
140
|
+
}
|
|
141
|
+
const page = await (await browser.newContext()).newPage();
|
|
142
|
+
await page.goto(url, { waitUntil: 'load', timeout: 45000 });
|
|
143
|
+
const { scanAccessibility } = await import('./scanner/accessibility.mjs');
|
|
144
|
+
const r = await scanAccessibility(page, { bestPractice });
|
|
145
|
+
return {
|
|
146
|
+
url: r.url,
|
|
147
|
+
lighthouse,
|
|
148
|
+
lighthouseError,
|
|
149
|
+
lighthouseCategories,
|
|
150
|
+
reportId,
|
|
151
|
+
scores: {
|
|
152
|
+
overall: score(r.violations, r.passes),
|
|
153
|
+
a: score(r.violations, r.passes, LEVEL_A),
|
|
154
|
+
aa: score(r.violations, r.passes, LEVEL_AA)
|
|
155
|
+
},
|
|
156
|
+
counts: {
|
|
157
|
+
violations: r.violations.length,
|
|
158
|
+
incomplete: r.incomplete.length,
|
|
159
|
+
passes: r.passes.length
|
|
160
|
+
},
|
|
161
|
+
violations: r.violations.map(v => ({
|
|
162
|
+
id: v.id,
|
|
163
|
+
impact: v.impact,
|
|
164
|
+
help: v.help,
|
|
165
|
+
helpUrl: v.helpUrl,
|
|
166
|
+
tags: v.tags.filter(t => t.startsWith('wcag')),
|
|
167
|
+
nodes: v.nodes.slice(0, 10).map(n => ({
|
|
168
|
+
target: n.target.join(' '),
|
|
169
|
+
html: n.html,
|
|
170
|
+
fix: n.failureSummary
|
|
171
|
+
})),
|
|
172
|
+
totalNodes: v.nodes.length
|
|
173
|
+
}))
|
|
174
|
+
};
|
|
175
|
+
} finally {
|
|
176
|
+
await browser.close();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
http
|
|
181
|
+
.createServer(async (req, res) => {
|
|
182
|
+
const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
183
|
+
const pathname = parsedUrl.pathname;
|
|
184
|
+
|
|
185
|
+
if (req.method === 'GET' && pathname === '/') {
|
|
186
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
187
|
+
return res.end(fs.readFileSync(path.join(__dirname, 'ui', 'index.html')));
|
|
188
|
+
}
|
|
189
|
+
if (req.method === 'GET' && (pathname === '/compare' || pathname === '/compare.html')) {
|
|
190
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
191
|
+
return res.end(fs.readFileSync(path.join(__dirname, 'ui', 'compare.html')));
|
|
192
|
+
}
|
|
193
|
+
if (req.method === 'GET' && (pathname === '/compare-2' || pathname === '/compare-2.html')) {
|
|
194
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
195
|
+
return res.end(fs.readFileSync(path.join(__dirname, 'ui', 'compare-2.html')));
|
|
196
|
+
}
|
|
197
|
+
if (req.method === 'GET' && (pathname === '/compare-3' || pathname === '/compare-3.html')) {
|
|
198
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
199
|
+
return res.end(fs.readFileSync(path.join(__dirname, 'ui', 'compare-3.html')));
|
|
200
|
+
}
|
|
201
|
+
if (req.method === 'GET' && pathname === '/api/last-compare-result') {
|
|
202
|
+
const resultPath = path.join(__dirname, 'visual-reports', 'result.json');
|
|
203
|
+
res.setHeader('Content-Type', 'application/json');
|
|
204
|
+
if (fs.existsSync(resultPath)) {
|
|
205
|
+
return res.end(fs.readFileSync(resultPath));
|
|
206
|
+
}
|
|
207
|
+
return res.end(JSON.stringify(null));
|
|
208
|
+
}
|
|
209
|
+
const reportFileMatch = req.method === 'GET' && pathname.match(/^\/visual-reports\/((?:runs\/[a-zA-Z0-9_-]+\/)?[a-zA-Z0-9_-][a-zA-Z0-9_\-\.]*)$/);
|
|
210
|
+
if (reportFileMatch) {
|
|
211
|
+
const filename = reportFileMatch[1];
|
|
212
|
+
const filePath = path.join(__dirname, 'visual-reports', filename);
|
|
213
|
+
if (fs.existsSync(filePath)) {
|
|
214
|
+
const ext = path.extname(filename).toLowerCase();
|
|
215
|
+
const mimeMap = {
|
|
216
|
+
'.png': 'image/png',
|
|
217
|
+
'.json': 'application/json',
|
|
218
|
+
'.jpg': 'image/jpeg',
|
|
219
|
+
'.jpeg': 'image/jpeg',
|
|
220
|
+
'.webp': 'image/webp',
|
|
221
|
+
};
|
|
222
|
+
res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream');
|
|
223
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
224
|
+
return res.end(fs.readFileSync(filePath));
|
|
225
|
+
}
|
|
226
|
+
res.statusCode = 404;
|
|
227
|
+
return res.end('File not found');
|
|
228
|
+
}
|
|
229
|
+
const m = req.method === 'GET' && pathname.match(/^\/report\/(\d+)$/);
|
|
230
|
+
if (m) {
|
|
231
|
+
const html = reports.get(m[1]);
|
|
232
|
+
res.statusCode = html ? 200 : 404;
|
|
233
|
+
res.setHeader('Content-Type', 'text/html');
|
|
234
|
+
return res.end(html || 'Report expired. Run the scan again.');
|
|
235
|
+
}
|
|
236
|
+
if (req.method === 'POST' && pathname === '/scan') {
|
|
237
|
+
let body = '';
|
|
238
|
+
for await (const c of req) body += c;
|
|
239
|
+
res.setHeader('Content-Type', 'application/json');
|
|
240
|
+
try {
|
|
241
|
+
const { url, bestPractice } = JSON.parse(body);
|
|
242
|
+
if (!/^https?:\/\//i.test(url)) throw new Error('URL must start with http:// or https://');
|
|
243
|
+
res.end(JSON.stringify(await scan(url, bestPractice)));
|
|
244
|
+
} catch (e) {
|
|
245
|
+
res.statusCode = 400;
|
|
246
|
+
res.end(JSON.stringify({ error: e.message.split('\n')[0] }));
|
|
247
|
+
}
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (req.method === 'POST' && pathname === '/api/compare') {
|
|
251
|
+
let body = '';
|
|
252
|
+
for await (const c of req) body += c;
|
|
253
|
+
res.setHeader('Content-Type', 'application/json');
|
|
254
|
+
try {
|
|
255
|
+
const payload = JSON.parse(body);
|
|
256
|
+
const { compareVisuals } = await import('./compare.js');
|
|
257
|
+
const result = await compareVisuals(payload);
|
|
258
|
+
res.end(JSON.stringify(result));
|
|
259
|
+
} catch (e) {
|
|
260
|
+
res.statusCode = 400;
|
|
261
|
+
res.end(JSON.stringify({ error: e.message.split('\n')[0] }));
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// Review decisions for UI Check findings, remembered per reference/target URL pair.
|
|
266
|
+
if (pathname === '/api/reviews') {
|
|
267
|
+
res.setHeader('Content-Type', 'application/json');
|
|
268
|
+
try {
|
|
269
|
+
if (req.method === 'GET') {
|
|
270
|
+
const key = reviewKey(parsedUrl.searchParams.get('ref'), parsedUrl.searchParams.get('tgt'));
|
|
271
|
+
return res.end(JSON.stringify(readReviews()[key] || {}));
|
|
272
|
+
}
|
|
273
|
+
if (req.method === 'POST') {
|
|
274
|
+
let body = '';
|
|
275
|
+
for await (const c of req) body += c;
|
|
276
|
+
const p = JSON.parse(body);
|
|
277
|
+
const fp = String(p.fingerprint || '');
|
|
278
|
+
if (!/^[a-z0-9]{1,32}$/.test(fp)) throw new Error('Invalid fingerprint');
|
|
279
|
+
const status = p.status == null ? null : String(p.status);
|
|
280
|
+
if (status !== null && !REVIEW_STATUSES.includes(status)) throw new Error('Invalid status');
|
|
281
|
+
const all = readReviews();
|
|
282
|
+
const key = reviewKey(p.referenceUrl, p.targetUrl);
|
|
283
|
+
const pair = (all[key] = all[key] || {});
|
|
284
|
+
if (status === null && p.note == null) {
|
|
285
|
+
delete pair[fp];
|
|
286
|
+
} else {
|
|
287
|
+
const prev = pair[fp] || {};
|
|
288
|
+
pair[fp] = {
|
|
289
|
+
status: status ?? prev.status ?? null,
|
|
290
|
+
note: p.note != null ? String(p.note).slice(0, 2000) : prev.note || '',
|
|
291
|
+
type: String(p.type || prev.type || '').slice(0, 200),
|
|
292
|
+
at: new Date().toISOString(),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
writeReviews(all);
|
|
296
|
+
return res.end(JSON.stringify(pair));
|
|
297
|
+
}
|
|
298
|
+
res.statusCode = 405;
|
|
299
|
+
return res.end(JSON.stringify({ error: 'Method not allowed' }));
|
|
300
|
+
} catch (e) {
|
|
301
|
+
res.statusCode = 400;
|
|
302
|
+
return res.end(JSON.stringify({ error: e.message.split('\n')[0] }));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (req.method === 'POST' && pathname === '/api/ui-check') {
|
|
306
|
+
let body = '';
|
|
307
|
+
for await (const c of req) body += c;
|
|
308
|
+
res.setHeader('Content-Type', 'application/json');
|
|
309
|
+
try {
|
|
310
|
+
const p = JSON.parse(body);
|
|
311
|
+
const list = (v, sep) => String(v || '').split(sep).map((x) => x.trim()).filter(Boolean);
|
|
312
|
+
const { runUiCheck } = await import('./ui-check.js');
|
|
313
|
+
const result = await runUiCheck({
|
|
314
|
+
referenceUrl: String(p.referenceUrl || '').trim(),
|
|
315
|
+
targetUrl: String(p.targetUrl || '').trim(),
|
|
316
|
+
viewports: Array.isArray(p.viewports) && p.viewports.length ? p.viewports : ['desktop'],
|
|
317
|
+
checklist: list(p.checklist, '\n'),
|
|
318
|
+
customWords: list(p.customWords, /[\n,]/),
|
|
319
|
+
ignoreSelectors: list(p.ignoreSelectors, ','),
|
|
320
|
+
spelling: p.spelling === true,
|
|
321
|
+
styling: p.styling !== false,
|
|
322
|
+
pixelDiff: p.pixelDiff === true,
|
|
323
|
+
});
|
|
324
|
+
res.end(JSON.stringify(result));
|
|
325
|
+
} catch (e) {
|
|
326
|
+
res.statusCode = 400;
|
|
327
|
+
res.end(JSON.stringify({ error: e.message.split('\n')[0] }));
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (req.method === 'POST' && pathname === '/api/compare-dom') {
|
|
332
|
+
let body = '';
|
|
333
|
+
for await (const c of req) body += c;
|
|
334
|
+
res.setHeader('Content-Type', 'application/json');
|
|
335
|
+
try {
|
|
336
|
+
const p = JSON.parse(body);
|
|
337
|
+
const list = (v, sep) => String(v || '').split(sep).map((x) => x.trim()).filter(Boolean);
|
|
338
|
+
const referenceUrl = String(p.referenceUrl || '').trim();
|
|
339
|
+
const targetUrl = String(p.targetUrl || '').trim();
|
|
340
|
+
if (!referenceUrl || !targetUrl) throw new Error('Reference URL and Target URL are required');
|
|
341
|
+
const ignoreSelectors = list(p.ignoreSelectors, ',');
|
|
342
|
+
const viewport = {
|
|
343
|
+
width: Number(p.viewport?.width) || 1440,
|
|
344
|
+
height: Number(p.viewport?.height) || 900,
|
|
345
|
+
};
|
|
346
|
+
const { compareDom } = await import('./dom-compare.js');
|
|
347
|
+
const { spellCheckUrl } = await import('./ui-check.js');
|
|
348
|
+
const dom = await compareDom({ referenceUrl, actualUrl: targetUrl, viewport, tolerance: p.tolerance, ignoreSelectors });
|
|
349
|
+
const spelling = p.spelling === false ? null
|
|
350
|
+
: await spellCheckUrl({ url: targetUrl, viewport, customWords: list(p.customWords, /[\n,]/), ignoreSelectors });
|
|
351
|
+
res.end(JSON.stringify({ dom, spelling, referenceUrl, targetUrl, viewport }));
|
|
352
|
+
} catch (e) {
|
|
353
|
+
res.statusCode = 400;
|
|
354
|
+
res.end(JSON.stringify({ error: e.message.split('\n')[0] }));
|
|
355
|
+
}
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
res.statusCode = 404;
|
|
359
|
+
res.end('Not found');
|
|
360
|
+
})
|
|
361
|
+
.listen(PORT, '127.0.0.1', () => console.log(`Open http://127.0.0.1:${PORT}`));
|