webcake-landing-mcp 1.5.10 → 1.6.0
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/dist/changelog.json +14 -14
- package/dist/http.js +15 -9
- package/dist/persistence/screenshot-playwright.js +56 -0
- package/dist/smoke.js +17 -1
- package/package.json +1 -1
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.6.0",
|
|
4
|
+
"d": "14/10/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "The self-hosted screenshot route behind render_preview now caps concurrent Playwright captures at RENDER_SCREENSHOT_CONCURRENCY (default 2), queuing…",
|
|
7
|
+
"vi": "Route screenshot self-hosted phía sau render_preview nay giới hạn số lượt chụp Playwright chạy đồng thời theo RENDER_SCREENSHOT_CONCURRENCY (mặc…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.5.11",
|
|
11
|
+
"d": "13/10/2026",
|
|
12
|
+
"type": "Fixed",
|
|
13
|
+
"en": "The HTTP server now answers HEAD requests the same way it answers GET on every public read-only route (/privacy, /privacy-policy, /terms, /tos,…",
|
|
14
|
+
"vi": "HTTP server nay trả lời request HEAD giống như GET trên mọi route công khai chỉ-đọc (/privacy, /privacy-policy, /terms, /tos, /favicon.ico,…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.5.10",
|
|
4
18
|
"d": "13/10/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Changed",
|
|
27
41
|
"en": "The ingest_url and render_preview tool descriptions now link to the API docs (docs/tools.md for both, plus the Microlink API reference for…",
|
|
28
42
|
"vi": "Mô tả của tool ingest_url và render_preview nay có thêm link tới tài liệu API (docs/tools.md cho cả hai, cùng với link tham chiếu API của Microlink…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.5.6",
|
|
32
|
-
"d": "13/10/2026",
|
|
33
|
-
"type": "Fixed",
|
|
34
|
-
"en": "The HTTP server now serves a 192px PNG at /favicon.ico, /favicon.png, and /apple-touch-icon.png (with matching <link rel=\"icon\">/<link…",
|
|
35
|
-
"vi": "HTTP server nay phục vụ PNG 192px tại /favicon.ico, /favicon.png, và /apple-touch-icon.png (kèm thẻ <link rel=\"icon\">/<link rel=\"apple-touch-icon\">…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.5.5",
|
|
39
|
-
"d": "13/10/2026",
|
|
40
|
-
"type": "Fixed",
|
|
41
|
-
"en": "The page/source/sections/patches parameters on validate_page, create_page, update_page, add_section, and patch_page now declare a typed JSON Schema…",
|
|
42
|
-
"vi": "Các tham số page/source/sections/patches của validate_page, create_page, update_page, add_section, và patch_page nay khai báo JSON Schema có kiểu rõ…"
|
|
43
43
|
}
|
|
44
44
|
]
|
package/dist/http.js
CHANGED
|
@@ -81,6 +81,12 @@ function iconPng() {
|
|
|
81
81
|
return null;
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
// Public pages/assets answer HEAD like GET (Node drops the body on HEAD): link
|
|
85
|
+
// checkers — incl. the connector-directory form — probe with HEAD, and a 404
|
|
86
|
+
// there made /privacy and /terms look missing.
|
|
87
|
+
function isRead(req) {
|
|
88
|
+
return req.method === "GET" || req.method === "HEAD";
|
|
89
|
+
}
|
|
84
90
|
function sendJson(res, status, body) {
|
|
85
91
|
res.writeHead(status, { "content-type": "application/json" });
|
|
86
92
|
res.end(JSON.stringify(body));
|
|
@@ -186,12 +192,12 @@ function htmlError(res, status, message) {
|
|
|
186
192
|
async function handleOAuth(req, res, path) {
|
|
187
193
|
const issuer = publicBase(req);
|
|
188
194
|
// ---- Metadata (RFC 8414 / RFC 9728) ----
|
|
189
|
-
if (req
|
|
195
|
+
if (isRead(req) && path === WELL_KNOWN_PR) {
|
|
190
196
|
res.writeHead(200, { "content-type": "application/json", "access-control-allow-origin": "*" });
|
|
191
197
|
res.end(JSON.stringify(protectedResourceMetadata(`${issuer}${MCP_PATH}`, issuer)));
|
|
192
198
|
return true;
|
|
193
199
|
}
|
|
194
|
-
if (req
|
|
200
|
+
if (isRead(req) && path === WELL_KNOWN_AS) {
|
|
195
201
|
res.writeHead(200, { "content-type": "application/json", "access-control-allow-origin": "*" });
|
|
196
202
|
res.end(JSON.stringify(authServerMetadata(issuer)));
|
|
197
203
|
return true;
|
|
@@ -397,21 +403,21 @@ export async function startHttpServer(port) {
|
|
|
397
403
|
const path = (req.url ?? "").split("?")[0];
|
|
398
404
|
// Brand icon — clients (e.g. the claude.ai connector) fetch a favicon from the
|
|
399
405
|
// server origin; without one they show a generic globe. Served raw as SVG.
|
|
400
|
-
if (req
|
|
406
|
+
if (isRead(req) && (path === "/favicon.ico" || path === "/favicon.png" || path === "/icon.png" || path === "/apple-touch-icon.png")) {
|
|
401
407
|
const png = iconPng();
|
|
402
408
|
if (png) {
|
|
403
409
|
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=86400" });
|
|
404
410
|
return res.end(png);
|
|
405
411
|
}
|
|
406
412
|
}
|
|
407
|
-
if (req
|
|
413
|
+
if (isRead(req) && (path === "/favicon.ico" || path === "/favicon.svg" || path === "/icon.svg")) {
|
|
408
414
|
res.writeHead(200, { "content-type": ICON_MIME, "cache-control": "public, max-age=86400" });
|
|
409
415
|
return res.end(ICON_SVG);
|
|
410
416
|
}
|
|
411
417
|
// Social-card image referenced by the landing page's og:image / twitter:image.
|
|
412
418
|
// PNG is the canonical og:image (unfurls everywhere); the SVG stays for clients
|
|
413
419
|
// that prefer it (Slack/Telegram/Discord) and as a fallback if og.png is absent.
|
|
414
|
-
if (req
|
|
420
|
+
if (isRead(req) && path === "/og.png") {
|
|
415
421
|
const png = ogImagePng();
|
|
416
422
|
if (png) {
|
|
417
423
|
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=86400" });
|
|
@@ -421,21 +427,21 @@ export async function startHttpServer(port) {
|
|
|
421
427
|
res.writeHead(200, { "content-type": ICON_MIME, "cache-control": "public, max-age=86400" });
|
|
422
428
|
return res.end(ogImageSvg());
|
|
423
429
|
}
|
|
424
|
-
if (req
|
|
430
|
+
if (isRead(req) && path === "/og.svg") {
|
|
425
431
|
res.writeHead(200, { "content-type": ICON_MIME, "cache-control": "public, max-age=86400" });
|
|
426
432
|
return res.end(ogImageSvg());
|
|
427
433
|
}
|
|
428
434
|
// Public legal pages — required URLs for the Claude/ChatGPT directory submission.
|
|
429
|
-
if (req
|
|
435
|
+
if (isRead(req) && (path === "/privacy" || path === "/privacy-policy")) {
|
|
430
436
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" });
|
|
431
437
|
return res.end(privacyHtml());
|
|
432
438
|
}
|
|
433
|
-
if (req
|
|
439
|
+
if (isRead(req) && (path === "/terms" || path === "/tos")) {
|
|
434
440
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" });
|
|
435
441
|
return res.end(termsHtml());
|
|
436
442
|
}
|
|
437
443
|
// Lightweight health check for hosting platforms.
|
|
438
|
-
if (req
|
|
444
|
+
if (isRead(req) && (path === "/" || path === "/health")) {
|
|
439
445
|
// A browser/connector probing the root with `Accept: text/html` gets a tiny
|
|
440
446
|
// page that links the favicon (helps icon discovery); programmatic probes
|
|
441
447
|
// (the container healthcheck uses `Accept: */*`) still get the JSON health.
|
|
@@ -99,6 +99,62 @@ async function withContext(width, scale, fn) {
|
|
|
99
99
|
error: "playwright is not installed on this host (run: npm i playwright && npx playwright install chromium)",
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
|
+
await acquireSlot();
|
|
103
|
+
try {
|
|
104
|
+
return await withContextUnlimited(width, scale, fn);
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
releaseSlot();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// RAM guard for small VPSes: cap concurrent captures (each context is
|
|
112
|
+
// ~100–300 MB) and close the browser after an idle spell so it doesn't sit on
|
|
113
|
+
// memory / slowly leak. RENDER_SCREENSHOT_CONCURRENCY (default 2),
|
|
114
|
+
// RENDER_SCREENSHOT_IDLE_MS (default 5 min, 0 = keep the browser forever).
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
const MAX_CONCURRENT = Math.max(1, parseInt(process.env.RENDER_SCREENSHOT_CONCURRENCY ?? "", 10) || 2);
|
|
117
|
+
const idleEnv = parseInt(process.env.RENDER_SCREENSHOT_IDLE_MS ?? "", 10);
|
|
118
|
+
const IDLE_CLOSE_MS = Number.isFinite(idleEnv) && idleEnv >= 0 ? idleEnv : 300_000;
|
|
119
|
+
let active = 0;
|
|
120
|
+
const waiters = [];
|
|
121
|
+
let idleTimer = null;
|
|
122
|
+
/** Wait for a capture slot. ponytail: unbounded FIFO queue — the HTTP caller's own timeout bounds the wait. */
|
|
123
|
+
export async function acquireSlot() {
|
|
124
|
+
if (idleTimer) {
|
|
125
|
+
clearTimeout(idleTimer);
|
|
126
|
+
idleTimer = null;
|
|
127
|
+
}
|
|
128
|
+
if (active < MAX_CONCURRENT) {
|
|
129
|
+
active++;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
await new Promise((r) => waiters.push(r)); // releaseSlot hands its slot over (active unchanged)
|
|
133
|
+
}
|
|
134
|
+
/** Free a slot: hand it to the next waiter, else arm the idle close once nothing is running. */
|
|
135
|
+
export function releaseSlot() {
|
|
136
|
+
const next = waiters.shift();
|
|
137
|
+
if (next)
|
|
138
|
+
return next();
|
|
139
|
+
active--;
|
|
140
|
+
if (active === 0 && IDLE_CLOSE_MS > 0) {
|
|
141
|
+
idleTimer = setTimeout(closeIdleBrowser, IDLE_CLOSE_MS);
|
|
142
|
+
idleTimer.unref?.();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Snapshot of the limiter (smoke coverage). */
|
|
146
|
+
export function slotStats() {
|
|
147
|
+
return { active, waiting: waiters.length, max: MAX_CONCURRENT };
|
|
148
|
+
}
|
|
149
|
+
async function closeIdleBrowser() {
|
|
150
|
+
idleTimer = null;
|
|
151
|
+
const p = browserPromise;
|
|
152
|
+
browserPromise = null; // the next capture relaunches
|
|
153
|
+
const browser = p ? await p.catch(() => null) : null;
|
|
154
|
+
if (browser)
|
|
155
|
+
await browser.close().catch(() => { });
|
|
156
|
+
}
|
|
157
|
+
async function withContextUnlimited(width, scale, fn) {
|
|
102
158
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
103
159
|
let browser = await getBrowser();
|
|
104
160
|
if (!browser)
|
package/dist/smoke.js
CHANGED
|
@@ -21,7 +21,7 @@ import { buildConnectUrl, parseCallback } from "./auth/login.js";
|
|
|
21
21
|
import { redirectMatches } from "./auth/oauth-server.js";
|
|
22
22
|
import { isLocalPath, resolveLocalPath, sniffMime, localContentType } from "./tools/media.js";
|
|
23
23
|
import { buildMicrolinkUrl } from "./persistence/screenshot-client.js";
|
|
24
|
-
import { isPrivateHost, isAllowedScreenshotUrl } from "./persistence/screenshot-playwright.js";
|
|
24
|
+
import { isPrivateHost, isAllowedScreenshotUrl, acquireSlot, releaseSlot, slotStats } from "./persistence/screenshot-playwright.js";
|
|
25
25
|
import { iconifyCandidates } from "./persistence/icon-client.js";
|
|
26
26
|
import { collectExternalImageUrls, rewriteImageUrls, isRehostableImageUrl, isInlineBase64Image, parseInlineBase64Image } from "./persistence/rehost.js";
|
|
27
27
|
import { key as rehostCacheKey } from "./persistence/rehost-cache.js";
|
|
@@ -2265,6 +2265,22 @@ console.log("== upload_images: localContentType (ext + magic, pure offline) ==")
|
|
|
2265
2265
|
}
|
|
2266
2266
|
// == /api/render/screenshot: SSRF url guard (pure offline) ==
|
|
2267
2267
|
{
|
|
2268
|
+
console.log("\n== render screenshot: concurrency cap (pure offline) ==");
|
|
2269
|
+
{
|
|
2270
|
+
const max = slotStats().max;
|
|
2271
|
+
for (let i = 0; i < max; i++)
|
|
2272
|
+
await acquireSlot();
|
|
2273
|
+
let queuedGotSlot = false;
|
|
2274
|
+
const queued = acquireSlot().then(() => (queuedGotSlot = true));
|
|
2275
|
+
await Promise.resolve();
|
|
2276
|
+
check("slots: capped at max, extra caller waits", slotStats().active === max && slotStats().waiting === 1 && !queuedGotSlot);
|
|
2277
|
+
releaseSlot(); // hands the slot to the waiter
|
|
2278
|
+
await queued;
|
|
2279
|
+
check("slots: release hands slot to waiter", queuedGotSlot && slotStats().active === max && slotStats().waiting === 0);
|
|
2280
|
+
for (let i = 0; i < max; i++)
|
|
2281
|
+
releaseSlot();
|
|
2282
|
+
check("slots: all released", slotStats().active === 0);
|
|
2283
|
+
}
|
|
2268
2284
|
console.log("\n== render screenshot route: SSRF url guard (pure offline) ==");
|
|
2269
2285
|
// private / loopback / link-local hosts are blocked
|
|
2270
2286
|
check("isPrivateHost: localhost", isPrivateHost("localhost"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-landing-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "MCP server exposing Webcake landing-page element schemas + AI usage hints, and persisting LLM-generated page sources to a Webcake backend.",
|
|
5
5
|
"mcpName": "io.github.vuluu2k/webcake-landing-mcp",
|
|
6
6
|
"type": "module",
|