@revclamp/google-ads-transparency-mcp 0.0.0-stage → 1.0.8
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/.actor/actor.json +78 -0
- package/.actor/input_schema.json +79 -0
- package/.well-known/glama.json +6 -0
- package/LICENSE +21 -0
- package/README.md +133 -2
- package/actor_config.json +29 -0
- package/assets/icon-180.png +0 -0
- package/assets/icon-32.png +0 -0
- package/assets/icon.svg +26 -0
- package/assets/playground.css +468 -0
- package/assets/playground.js +817 -0
- package/glama.json +6 -0
- package/index.mjs +314 -0
- package/package.json +55 -4
- package/server.json +45 -0
- package/src/apify_actor.mjs +2208 -0
- package/src/egress_guard.mjs +102 -0
- package/src/mcp_server.mjs +1171 -0
- package/src/rpc_client.mjs +3691 -0
- package/src/web_preview_server.mjs +420 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hardened Localhost Web Playground Server (src/web_preview_server.mjs)
|
|
3
|
+
*
|
|
4
|
+
* Binds strictly to `127.0.0.1` (default port `4410`), enforces `Host` and exact `Origin`
|
|
5
|
+
* DNS-rebinding/CSRF guards, `Content-Type: application/json`, streaming `64KB`
|
|
6
|
+
* request body ceiling, `25s` request timeout aligned with engine deadline,
|
|
7
|
+
* strict Content-Security-Policy, and `X-Content-Type-Options: nosniff`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import http from 'node:http';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import {
|
|
15
|
+
MCP_SERVER_INFO,
|
|
16
|
+
MCP_TOOLS_MANIFEST,
|
|
17
|
+
handleMcpJsonRpcRequest,
|
|
18
|
+
} from './mcp_server.mjs';
|
|
19
|
+
|
|
20
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
21
|
+
const __dirname = path.dirname(__filename);
|
|
22
|
+
const ASSETS_DIR = path.resolve(__dirname, '..', 'assets');
|
|
23
|
+
|
|
24
|
+
export const PLAYGROUND_CSP_HEADER =
|
|
25
|
+
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
|
|
26
|
+
|
|
27
|
+
export const MAX_PLAYGROUND_BODY_BYTES = 65536;
|
|
28
|
+
export const PLAYGROUND_REQUEST_TIMEOUT_MS = 25000;
|
|
29
|
+
|
|
30
|
+
const ALLOWED_HOST_HEADER_RE = /^(127\.0\.0\.1|localhost|\[::1\])(:\d{1,5})?$/i;
|
|
31
|
+
|
|
32
|
+
const STATIC_ASSET_MAP = Object.freeze({
|
|
33
|
+
'/assets/icon.svg': {
|
|
34
|
+
file: 'icon.svg',
|
|
35
|
+
contentType: 'image/svg+xml; charset=utf-8',
|
|
36
|
+
},
|
|
37
|
+
'/assets/icon-32.png': {
|
|
38
|
+
file: 'icon-32.png',
|
|
39
|
+
contentType: 'image/png',
|
|
40
|
+
},
|
|
41
|
+
'/assets/icon-180.png': {
|
|
42
|
+
file: 'icon-180.png',
|
|
43
|
+
contentType: 'image/png',
|
|
44
|
+
},
|
|
45
|
+
'/assets/playground.css': {
|
|
46
|
+
file: 'playground.css',
|
|
47
|
+
contentType: 'text/css; charset=utf-8',
|
|
48
|
+
},
|
|
49
|
+
'/assets/playground.js': {
|
|
50
|
+
file: 'playground.js',
|
|
51
|
+
contentType: 'application/javascript; charset=utf-8',
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function buildPlaygroundHtml() {
|
|
56
|
+
return `<!DOCTYPE html>
|
|
57
|
+
<html lang="en">
|
|
58
|
+
<head>
|
|
59
|
+
<meta charset="utf-8">
|
|
60
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
61
|
+
<title>RevClamp AdPulse - Google Ads Transparency Scraper & MCP</title>
|
|
62
|
+
<link rel="icon" type="image/png" sizes="32x32" href="/assets/icon-32.png">
|
|
63
|
+
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icon-180.png">
|
|
64
|
+
<link rel="stylesheet" href="/assets/playground.css">
|
|
65
|
+
<script src="/assets/playground.js" defer></script>
|
|
66
|
+
</head>
|
|
67
|
+
<body>
|
|
68
|
+
<header class="site-header">
|
|
69
|
+
<div class="header-inner">
|
|
70
|
+
<div class="brand-group">
|
|
71
|
+
<img class="brand-logo" src="/assets/icon.svg" width="36" height="36" alt="RevClamp AdPulse ad library lens icon">
|
|
72
|
+
<div class="brand-titles">
|
|
73
|
+
<h1 class="brand-title">RevClamp AdPulse - Google Ads Transparency Scraper & MCP</h1>
|
|
74
|
+
<p class="brand-subtitle">Find Verified Advertiser Entities & Extract Active or Historical Google Ads Creatives</p>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="header-badges">
|
|
78
|
+
<span class="badge badge-emerald">MCP 2025-06-18 Ready</span>
|
|
79
|
+
<span class="badge badge-cyan">Responses up to 30 KB</span>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</header>
|
|
83
|
+
|
|
84
|
+
<main class="workbench-main">
|
|
85
|
+
<section class="panel" aria-labelledby="controls-heading">
|
|
86
|
+
<h2 id="controls-heading" class="panel-heading">Search & Tool Controls</h2>
|
|
87
|
+
<div class="form-stack">
|
|
88
|
+
<div class="field-group">
|
|
89
|
+
<label class="field-label" for="tool-select">Tool Action</label>
|
|
90
|
+
<select id="tool-select" class="select-control">
|
|
91
|
+
<option value="search_google_ads_transparency">Search Google Ads Transparency Creatives</option>
|
|
92
|
+
<option value="resolve_google_ads_advertiser">Resolve Google Ads Advertiser Entities</option>
|
|
93
|
+
<option value="get_google_ads_creative_detail">Get Google Ads Creative Detail</option>
|
|
94
|
+
</select>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div id="dynamic-schema-fields" class="form-stack"></div>
|
|
98
|
+
|
|
99
|
+
<label class="checkbox-row" for="toggle-load-previews">
|
|
100
|
+
<input id="toggle-load-previews" class="checkbox-input" type="checkbox">
|
|
101
|
+
<span>Show verified ad preview & archive links</span>
|
|
102
|
+
</label>
|
|
103
|
+
|
|
104
|
+
<div class="button-row">
|
|
105
|
+
<button id="btn-run-tool" class="btn btn-primary" type="button">Run Tool</button>
|
|
106
|
+
<button id="btn-load-next-page" class="btn btn-secondary" type="button" hidden>Load next page</button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</section>
|
|
110
|
+
|
|
111
|
+
<section class="panel" aria-labelledby="results-heading">
|
|
112
|
+
<div class="panel-heading">
|
|
113
|
+
<h2 id="results-heading" class="panel-heading-title">Results</h2>
|
|
114
|
+
<div class="export-actions-row">
|
|
115
|
+
<button id="btn-copy-json" class="btn btn-secondary" type="button" disabled>Copy JSON</button>
|
|
116
|
+
<button id="btn-download-json" class="btn btn-secondary" type="button" disabled>Download JSON</button>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
<div id="status-banner" class="status-banner" role="status" aria-live="polite" data-state="idle">
|
|
120
|
+
Ready. Choose a tool action and click Run Tool to inspect public Google Ads Transparency records.
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div class="telemetry-grid">
|
|
124
|
+
<div class="telemetry-card">
|
|
125
|
+
<div class="telemetry-label">Billing Mode</div>
|
|
126
|
+
<div id="telemetry-billing-mode" class="telemetry-value">Local mode - not billed</div>
|
|
127
|
+
</div>
|
|
128
|
+
<div class="telemetry-card">
|
|
129
|
+
<div class="telemetry-label">Extracted Items</div>
|
|
130
|
+
<div id="telemetry-extracted-count" class="telemetry-value">0</div>
|
|
131
|
+
</div>
|
|
132
|
+
<div class="telemetry-card">
|
|
133
|
+
<div class="telemetry-label">Pagination Status</div>
|
|
134
|
+
<div id="telemetry-pagination-status" class="telemetry-value">IDLE</div>
|
|
135
|
+
</div>
|
|
136
|
+
<div class="telemetry-card">
|
|
137
|
+
<div class="telemetry-label">Response Size</div>
|
|
138
|
+
<div id="telemetry-envelope-bytes" class="telemetry-value">0 B</div>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div id="results-cards-container" class="cards-stack"></div>
|
|
143
|
+
|
|
144
|
+
<pre id="raw-json-output" class="raw-json-box" aria-label="Raw JSON-RPC Envelope">{}</pre>
|
|
145
|
+
</section>
|
|
146
|
+
</main>
|
|
147
|
+
|
|
148
|
+
<footer class="site-footer">
|
|
149
|
+
Independent intelligence tool by RevClamp; not affiliated with or endorsed by Google LLC.
|
|
150
|
+
</footer>
|
|
151
|
+
</body>
|
|
152
|
+
</html>`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function applySecurityHeaders(res, isApiRoute = false) {
|
|
156
|
+
res.setHeader('Content-Security-Policy', PLAYGROUND_CSP_HEADER);
|
|
157
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
158
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
159
|
+
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
|
|
160
|
+
if (isApiRoute) {
|
|
161
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function sendJson(res, statusCode, payload) {
|
|
166
|
+
if (res.headersSent || res.writableEnded) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
applySecurityHeaders(res, true);
|
|
170
|
+
const body = JSON.stringify(payload);
|
|
171
|
+
res.writeHead(statusCode, {
|
|
172
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
173
|
+
'Content-Length': Buffer.byteLength(body, 'utf8'),
|
|
174
|
+
});
|
|
175
|
+
res.end(body);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Validates that an HTTP `Origin` header matches the exact `http://${hostHeader}` host+port pair.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} originHeader
|
|
182
|
+
* @param {string} hostHeader
|
|
183
|
+
* @returns {boolean}
|
|
184
|
+
*/
|
|
185
|
+
function isAllowedSamePortOrigin(originHeader, hostHeader) {
|
|
186
|
+
const rawOrigin = String(originHeader ?? '').trim();
|
|
187
|
+
const rawHost = String(hostHeader ?? '').trim().toLowerCase();
|
|
188
|
+
if (!rawOrigin || !rawHost) return false;
|
|
189
|
+
try {
|
|
190
|
+
const parsedOrigin = new URL(rawOrigin);
|
|
191
|
+
if (parsedOrigin.protocol !== 'http:') return false;
|
|
192
|
+
const originHostPort = parsedOrigin.host.toLowerCase();
|
|
193
|
+
return originHostPort === rawHost;
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Creates and starts the hardened localhost Web Playground HTTP server.
|
|
201
|
+
*
|
|
202
|
+
* @param {object} [options]
|
|
203
|
+
* @param {number} [options.port=4410]
|
|
204
|
+
* @param {string} [options.host='127.0.0.1']
|
|
205
|
+
* @param {Function} [options.fetchImpl]
|
|
206
|
+
* @param {number} [options.maxBodyBytes=65536]
|
|
207
|
+
* @returns {Promise<{server: http.Server, host: string, port: number, url: string, close: () => Promise<void>}>}
|
|
208
|
+
*/
|
|
209
|
+
export async function createAdPulsePreviewServer(options = {}) {
|
|
210
|
+
const requestedHost = String(options.host ?? '127.0.0.1').trim();
|
|
211
|
+
if (requestedHost !== '127.0.0.1' && requestedHost !== 'localhost') {
|
|
212
|
+
throw new Error(`Playground server must bind strictly to 127.0.0.1 (got '${requestedHost}')`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const requestedPort = options.port !== undefined ? Number(options.port) : 4410;
|
|
216
|
+
const maxBodyBytes =
|
|
217
|
+
Number.isInteger(options.maxBodyBytes) && options.maxBodyBytes > 0
|
|
218
|
+
? options.maxBodyBytes
|
|
219
|
+
: MAX_PLAYGROUND_BODY_BYTES;
|
|
220
|
+
|
|
221
|
+
const server = http.createServer((req, res) => {
|
|
222
|
+
req.setTimeout(PLAYGROUND_REQUEST_TIMEOUT_MS, () => {
|
|
223
|
+
if (!res.headersSent && !res.writableEnded) {
|
|
224
|
+
sendJson(res, 408, { ok: false, error: 'Request timeout' });
|
|
225
|
+
}
|
|
226
|
+
req.destroy();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const hostHeader = String(req.headers.host ?? '').trim();
|
|
230
|
+
if (!hostHeader || !ALLOWED_HOST_HEADER_RE.test(hostHeader)) {
|
|
231
|
+
sendJson(res, 403, {
|
|
232
|
+
ok: false,
|
|
233
|
+
error: 'Forbidden Host header (DNS rebinding guard)',
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const rawUrl = String(req.url || '/');
|
|
239
|
+
if (rawUrl.startsWith('//')) {
|
|
240
|
+
sendJson(res, 400, { ok: false, error: 'Malformed request URL' });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let parsedUrl;
|
|
245
|
+
try {
|
|
246
|
+
parsedUrl = new URL(rawUrl, `http://${hostHeader}`);
|
|
247
|
+
} catch {
|
|
248
|
+
sendJson(res, 400, { ok: false, error: 'Malformed request URL' });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const pathname = parsedUrl.pathname;
|
|
252
|
+
|
|
253
|
+
if (req.method === 'GET' && pathname === '/') {
|
|
254
|
+
applySecurityHeaders(res, false);
|
|
255
|
+
const html = buildPlaygroundHtml();
|
|
256
|
+
res.writeHead(200, {
|
|
257
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
258
|
+
'Content-Length': Buffer.byteLength(html, 'utf8'),
|
|
259
|
+
});
|
|
260
|
+
res.end(html);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (req.method === 'GET' && Object.prototype.hasOwnProperty.call(STATIC_ASSET_MAP, pathname)) {
|
|
265
|
+
const assetMeta = STATIC_ASSET_MAP[pathname];
|
|
266
|
+
const filePath = path.join(ASSETS_DIR, assetMeta.file);
|
|
267
|
+
try {
|
|
268
|
+
const buf = fs.readFileSync(filePath);
|
|
269
|
+
applySecurityHeaders(res, false);
|
|
270
|
+
res.writeHead(200, {
|
|
271
|
+
'Content-Type': assetMeta.contentType,
|
|
272
|
+
'Content-Length': buf.length,
|
|
273
|
+
});
|
|
274
|
+
res.end(buf);
|
|
275
|
+
} catch {
|
|
276
|
+
sendJson(res, 404, { ok: false, error: 'Asset not found' });
|
|
277
|
+
}
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (req.method === 'GET' && pathname === '/api/schema') {
|
|
282
|
+
sendJson(res, 200, {
|
|
283
|
+
ok: true,
|
|
284
|
+
serverInfo: MCP_SERVER_INFO,
|
|
285
|
+
tools: MCP_TOOLS_MANIFEST,
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (req.method === 'POST' && pathname === '/api/mcp') {
|
|
291
|
+
const originHeader = req.headers.origin;
|
|
292
|
+
if (originHeader !== undefined && !isAllowedSamePortOrigin(originHeader, hostHeader)) {
|
|
293
|
+
sendJson(res, 403, {
|
|
294
|
+
ok: false,
|
|
295
|
+
error: 'Forbidden Origin header (cross-origin request blocked)',
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const contentType = String(req.headers['content-type'] ?? '').trim().toLowerCase();
|
|
301
|
+
if (!contentType.startsWith('application/json')) {
|
|
302
|
+
sendJson(res, 415, {
|
|
303
|
+
ok: false,
|
|
304
|
+
error: 'Unsupported Media Type: Content-Type must be application/json',
|
|
305
|
+
});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const chunks = [];
|
|
310
|
+
let totalBytes = 0;
|
|
311
|
+
let abortedForSize = false;
|
|
312
|
+
|
|
313
|
+
req.on('data', (chunk) => {
|
|
314
|
+
if (abortedForSize) return;
|
|
315
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
316
|
+
totalBytes += buf.length;
|
|
317
|
+
if (totalBytes > maxBodyBytes) {
|
|
318
|
+
abortedForSize = true;
|
|
319
|
+
sendJson(res, 413, {
|
|
320
|
+
ok: false,
|
|
321
|
+
error: `Payload Too Large: request body exceeds ${maxBodyBytes} byte limit`,
|
|
322
|
+
});
|
|
323
|
+
req.destroy();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
chunks.push(buf);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
req.on('end', async () => {
|
|
330
|
+
if (abortedForSize || res.headersSent || res.writableEnded) return;
|
|
331
|
+
const rawBody = Buffer.concat(chunks, totalBytes).toString('utf8');
|
|
332
|
+
let parsedBody;
|
|
333
|
+
try {
|
|
334
|
+
parsedBody = JSON.parse(rawBody);
|
|
335
|
+
} catch {
|
|
336
|
+
sendJson(res, 200, {
|
|
337
|
+
jsonrpc: '2.0',
|
|
338
|
+
id: null,
|
|
339
|
+
error: {
|
|
340
|
+
code: -32700,
|
|
341
|
+
message: 'Parse error: Invalid JSON was received by the server.',
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
const rpcResponse = await handleMcpJsonRpcRequest(parsedBody, {
|
|
349
|
+
fetchImpl: options.fetchImpl,
|
|
350
|
+
});
|
|
351
|
+
if (res.headersSent || res.writableEnded) return;
|
|
352
|
+
if (rpcResponse === null) {
|
|
353
|
+
applySecurityHeaders(res, true);
|
|
354
|
+
res.writeHead(204);
|
|
355
|
+
res.end();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
sendJson(res, 200, rpcResponse);
|
|
359
|
+
} catch (err) {
|
|
360
|
+
sendJson(res, 500, {
|
|
361
|
+
jsonrpc: '2.0',
|
|
362
|
+
id: null,
|
|
363
|
+
error: {
|
|
364
|
+
code: -32603,
|
|
365
|
+
message: err?.message || 'Internal server error',
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
sendJson(res, 404, { ok: false, error: 'Route not found' });
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
await new Promise((resolve, reject) => {
|
|
377
|
+
server.once('error', reject);
|
|
378
|
+
server.listen(requestedPort, '127.0.0.1', () => {
|
|
379
|
+
server.removeListener('error', reject);
|
|
380
|
+
resolve();
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const addr = server.address();
|
|
385
|
+
const actualPort = typeof addr === 'object' && addr ? addr.port : requestedPort;
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
server,
|
|
389
|
+
host: '127.0.0.1',
|
|
390
|
+
port: actualPort,
|
|
391
|
+
url: `http://127.0.0.1:${actualPort}`,
|
|
392
|
+
close: () =>
|
|
393
|
+
new Promise((resolve) => {
|
|
394
|
+
server.close(() => resolve());
|
|
395
|
+
}),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function isExecutedAsMainModule(metaUrl) {
|
|
400
|
+
if (!process.argv[1]) return false;
|
|
401
|
+
try {
|
|
402
|
+
const entryPath = fs.realpathSync(process.argv[1]);
|
|
403
|
+
const selfPath = fs.realpathSync(fileURLToPath(metaUrl));
|
|
404
|
+
return entryPath === selfPath;
|
|
405
|
+
} catch {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (isExecutedAsMainModule(import.meta.url)) {
|
|
411
|
+
const portArg = Number(process.env.PORT || 4410);
|
|
412
|
+
createAdPulsePreviewServer({ port: portArg })
|
|
413
|
+
.then((srv) => {
|
|
414
|
+
process.stdout.write(`RevClamp AdPulse Web Playground listening on ${srv.url}\n`);
|
|
415
|
+
})
|
|
416
|
+
.catch((err) => {
|
|
417
|
+
process.stderr.write(`Failed to start Web Playground server: ${err?.message || String(err)}\n`);
|
|
418
|
+
process.exit(1);
|
|
419
|
+
});
|
|
420
|
+
}
|