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.
@@ -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.method === "GET" && path === WELL_KNOWN_PR) {
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.method === "GET" && path === WELL_KNOWN_AS) {
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.method === "GET" && (path === "/favicon.ico" || path === "/favicon.png" || path === "/icon.png" || path === "/apple-touch-icon.png")) {
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.method === "GET" && (path === "/favicon.ico" || path === "/favicon.svg" || path === "/icon.svg")) {
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.method === "GET" && path === "/og.png") {
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.method === "GET" && path === "/og.svg") {
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.method === "GET" && (path === "/privacy" || path === "/privacy-policy")) {
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.method === "GET" && (path === "/terms" || path === "/tos")) {
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.method === "GET" && (path === "/" || path === "/health")) {
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.5.10",
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",