webcake-landing-mcp 1.0.49 → 1.0.51
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/README.md +16 -7
- package/dist/changelog.json +14 -14
- package/dist/domains/landing/instructions.js +12 -5
- package/dist/persistence/config.js +21 -5
- package/dist/persistence/draft-cache.js +24 -5
- package/dist/persistence/pexels-client.js +13 -2
- package/dist/persistence/webcake-client.js +175 -11
- package/dist/smoke.js +112 -3
- package/dist/tools/persistence.js +478 -70
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -89,7 +89,7 @@ persists it (source-only — the page opens in the editor where re-saving render
|
|
|
89
89
|
| **npx (local)** — runs on your machine | Personal daily use, full control | browser `login`, a JWT, or none (reference tools) |
|
|
90
90
|
| **Hosted URL** — use our live server, nothing to install | No Node.js, teams, the claude.ai dialog | your personal `?jwt=` link / `x-webcake-jwt` header |
|
|
91
91
|
|
|
92
|
-
The **reference + generation tools** (`get_generation_guide`, `list_elements`, `validate_page`, …) and the **ingest tools** (`ingest_html`, `ingest_url` — turn an existing HTML or URL into a layout anchor so the AI can recreate or adapt it) work with **zero config**; only the **persistence tools** (`create_page`, `update_page`, `add_section`, `patch_page`, `list_pages`, `find_pages`, `get_page`, `list_organizations`) need a token. Credentials resolve in order: **per-request header → env var → saved `auth.json`** (`login`).
|
|
92
|
+
The **reference + generation tools** (`get_generation_guide`, `list_elements`, `validate_page`, …) and the **ingest tools** (`ingest_html`, `ingest_url` — turn an existing HTML or URL into a layout anchor so the AI can recreate or adapt it) work with **zero config**; only the **persistence tools** (`create_page`, `update_page`, `add_section`, `patch_page`, `publish_page`, `list_pages`, `find_pages`, `get_page`, `list_organizations`) need a token. Credentials resolve in order: **per-request header → env var → saved `auth.json`** (`login`).
|
|
93
93
|
|
|
94
94
|
> 🛠️ Prefer a shell-script installer (`install.sh`/`install.ps1`), a cloned local build, or hand-written per-IDE config? See **[docs/manual-install.md](docs/manual-install.md)**.
|
|
95
95
|
|
|
@@ -222,7 +222,8 @@ lands in logs). Any header that's missing falls back to the matching env var:
|
|
|
222
222
|
| `x-webcake-org-id` | `WEBCAKE_ORG_ID` | default org |
|
|
223
223
|
| `x-webcake-api-base` | `WEBCAKE_API_BASE` | overrides the env preset's API base |
|
|
224
224
|
| `x-webcake-app-base` | `WEBCAKE_APP_BASE` | overrides the env preset's SPA base (login connect page) |
|
|
225
|
-
| `x-webcake-builder-base` | `WEBCAKE_BUILDER_BASE` | overrides the builder host used for editor
|
|
225
|
+
| `x-webcake-builder-base` | `WEBCAKE_BUILDER_BASE` | overrides the builder host used for editor links |
|
|
226
|
+
| `x-webcake-preview-base` | `WEBCAKE_PREVIEW_BASE` | overrides the public preview host used for `/preview/<id>` links |
|
|
226
227
|
|
|
227
228
|
> The reference + generation tools (`get_generation_guide`, `list_elements`, `validate_page`, …) need **no
|
|
228
229
|
> token** — only the persistence tools (`create_page`, `update_page`, …) use it. Without a JWT, those return
|
|
@@ -292,7 +293,8 @@ flow can also be done entirely in the SPA, no backend route needed.)
|
|
|
292
293
|
| `WEBCAKE_JWT` | No* | Account JWT (dashboard auth). Required to persist — expires, refresh when needed. |
|
|
293
294
|
| `WEBCAKE_ORG_ID` | No | Default organization id for `create_page` (overridden by its `organization_id` arg). Omit → personal page. |
|
|
294
295
|
| `WEBCAKE_APP_BASE` | No | Optional SPA base — used for the browser `login` connect page. |
|
|
295
|
-
| `WEBCAKE_BUILDER_BASE` | No | Optional builder host for the editor
|
|
296
|
+
| `WEBCAKE_BUILDER_BASE` | No | Optional builder host for the editor links in the result. Defaults to the env preset, else derived from the API host (`api.x`→`builder.x`). |
|
|
297
|
+
| `WEBCAKE_PREVIEW_BASE` | No | Optional public preview host for the `/preview/<id>` links — NOT the builder subdomain. Defaults to the env preset (`preview.localhost:5800` local / `staging.webcake.me` staging / `www.webcake.me` prod). |
|
|
296
298
|
| `WEBCAKE_CONFIG_DIR` | No | Dir for the saved `auth.json` written by `login` (default `~/.webcake-landing-mcp`). |
|
|
297
299
|
|
|
298
300
|
> \* `WEBCAKE_API_BASE` and `WEBCAKE_JWT` are only needed for the persistence tools. The reference and
|
|
@@ -417,8 +419,12 @@ update_page({ page_id, source, dry_run: false }) # overwrite (dry_run=tr
|
|
|
417
419
|
# Build a LARGE page incrementally (avoids the giant single create_page payload
|
|
418
420
|
# that can drop the connection): small skeleton first, then one section at a time.
|
|
419
421
|
create_page({ source: smallSkeleton, dry_run: false }) # → page_id
|
|
420
|
-
add_section({ page_id, sections: heroSection
|
|
421
|
-
add_section({ page_id,
|
|
422
|
+
add_section({ page_id, sections: heroSection }) # dry_run=true → validates + returns draft_id
|
|
423
|
+
add_section({ page_id, draft_id, dry_run: false }) # re-run with draft_id — no re-send of sections
|
|
424
|
+
add_section({ page_id, sections: [formSection, footerSection], dry_run: false }) # or skip dry-run entirely
|
|
425
|
+
|
|
426
|
+
# Go LIVE (the preview link works without this — publish to attach a domain / set live status)
|
|
427
|
+
publish_page({ page_id, custom_domain: "shop.example.com", custom_path: "sale", dry_run: false })
|
|
422
428
|
```
|
|
423
429
|
|
|
424
430
|
`create_page` calls **`POST {WEBCAKE_API_BASE}/api/v1/ai/create_page_from_source`** on the backend.
|
|
@@ -462,11 +468,14 @@ Both `create_page` and `update_page` **default to `dry_run=true`** (validate and
|
|
|
462
468
|
| Tool | Description |
|
|
463
469
|
|------|-------------|
|
|
464
470
|
| `list_organizations` | List the account's organizations (id, name, is_default). Default = the `is_default` org. |
|
|
465
|
-
| `create_page` | Persist a generated source as a new page (source-only). **Defaults to `dry_run=true`.** |
|
|
471
|
+
| `create_page` | Persist a generated source as a new page (source-only). Validates, caches the source as `draft_id`, then creates. On validation failure, timeout, or network error the draft is kept — retry via `create_page({ draft_id, dry_run:false })` or fix via `patch_page({ draft_id, patches })`. **Defaults to `dry_run=true`.** |
|
|
466
472
|
| `list_pages` | List the account's pages (id, name, organization_id, updated_at) to pick one to edit. |
|
|
467
473
|
| `find_pages` | Search the account's pages by name, domain, and/or page id (AND-combined) to locate one to edit; returns id, name, org, custom/default domain, updated_at. |
|
|
468
474
|
| `get_page` | Fetch an existing page's decoded source tree, COMPACTED to the sparse authoring shape (factory-default boilerplate stripped — far fewer tokens; `compact:false` for the raw tree). Edit and send back as-is. |
|
|
469
|
-
| `update_page` | Overwrite an existing page's source with an edited tree. **Defaults to `dry_run=true`.** |
|
|
475
|
+
| `update_page` | Overwrite an existing page's source with an edited tree. Validates, caches the source as `draft_id`, then saves. On timeout or failure the draft is kept — retry via `update_page({ draft_id, dry_run:false })` or `patch_page({ draft_id, dry_run:false })` (no patches). **Defaults to `dry_run=true`.** |
|
|
476
|
+
| `add_section` | Append section(s) to an existing page without re-sending the whole source (incremental-build path). Always caches the batch as `draft_id`; re-run with `{ page_id, draft_id, dry_run:false }` — no need to re-send sections. Validation failure, timeout, or network error also keeps the draft — fix via `patch_page({ draft_id, patches })` or retry `patch_page({ draft_id, dry_run:false })` with no patches. **Defaults to `dry_run=true`.** |
|
|
477
|
+
| `patch_page` | Edit a page by element id without re-sending the whole source. Targets a live page (`page_id`) OR a cached draft (`draft_id`). Draft kinds: `create_page` (creates page once valid), `add_section` (appends once valid), `update_page`/live-patch (retries updatePageSource). **Empty/omitted patches + `draft_id` = commit-as-is (the universal timeout-retry path).** Live-page path pre-caches the patched source before the network call and returns `draft_id` for recovery. **Defaults to `dry_run=true`.** |
|
|
478
|
+
| `publish_page` | Publish a page (live status, optional custom domain/path). The preview link works WITHOUT publishing — publish only to go live. **Defaults to `dry_run=true`.** |
|
|
470
479
|
|
|
471
480
|
---
|
|
472
481
|
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.0.51",
|
|
4
|
+
"d": "10/06/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "create_page, update_page, and add_section dry-run responses now include a draft_id, and all three tools now accept draft_id as an input parameter:…",
|
|
7
|
+
"vi": "Các response dry-run của create_page, update_page và add_section nay đều trả về draft_id, đồng thời cả ba công cụ đều nhận draft_id làm tham số đầu…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.0.50",
|
|
11
|
+
"d": "10/06/2026",
|
|
12
|
+
"type": "Added",
|
|
13
|
+
"en": "New publish_page tool makes a page live: reads the page's current stored source, saves it as a new version, and creates or updates the…",
|
|
14
|
+
"vi": "Công cụ publish_page mới giúp đưa trang lên live: đọc source đang lưu của trang, lưu thành phiên bản mới và tạo hoặc cập nhật bản ghi…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.0.49",
|
|
4
18
|
"d": "10/06/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Changed",
|
|
27
41
|
"en": "validate_page now emits an advisory warning when no section, button, or text on the page carries a non-neutral color (white, black, or grey),…",
|
|
28
42
|
"vi": "validate_page nay phát cảnh báo tư vấn khi không có section, button hay text nào trên trang mang màu thực sự (không phải trắng, đen hoặc xám), giúp…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.0.45",
|
|
32
|
-
"d": "09/06/2026",
|
|
33
|
-
"type": "Changed",
|
|
34
|
-
"en": "get_generation_guide workflow condensed to four steps: element-type reads and image fetches are now batched into single calls…",
|
|
35
|
-
"vi": "Workflow trong get_generation_guide được rút gọn xuống còn bốn bước: việc đọc loại phần tử và tìm ảnh nay được gộp thành các lần gọi batch duy nhất…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.0.44",
|
|
39
|
-
"d": "09/06/2026",
|
|
40
|
-
"type": "Added",
|
|
41
|
-
"en": "New find_pages tool searches the account's pages by name, domain (matches custom_domain or default_domain), and/or page id (filters are…",
|
|
42
|
-
"vi": "Công cụ find_pages mới tìm kiếm các trang trong tài khoản theo tên, domain (khớp với custom_domain hoặc default_domain), và/hoặc page id (các bộ lọc…"
|
|
43
43
|
}
|
|
44
44
|
]
|
|
@@ -14,10 +14,15 @@ RULES (follow for every request):
|
|
|
14
14
|
- create_page and update_page DEFAULT to dry_run=true (a safety net for ambiguous requests). When the user's intent is clear AND validate_page already passed (no errors), SKIP the dry-run and call with dry_run=false directly — saves one round-trip. Use dry_run=true only when (a) the request is ambiguous about target/content, (b) the user explicitly asks to "preview" or "xem trước", (c) this is an update_page that overwrites significant existing content, or (d) you genuinely need to inspect the redacted payload. Never loop dry-runs to "check" the source — validate_page is the validator. Do not run dry-run then dry-run again before the real write.
|
|
15
15
|
- LARGE PAGES (4+ sections) — build INCREMENTALLY to avoid the giant single create_page payload that can drop the connection: create_page with a SMALL skeleton (empty/near-empty page) to get a page_id, then call add_section once per section (each call ships ONLY that section; the backend appends it server-side and rejects duplicate ids — no whole-source get+put). Small pages can still go in one create_page pass.
|
|
16
16
|
- EDIT existing pages surgically: find_pages (locate the page by name/domain/id when you don't already have a page_id) → get_page → change ONLY what was asked → keep every other element, its id, and coordinates. For a SMALL edit, PREFER patch_page over update_page: send only the changed elements by id (ops: update/replace/remove/add) instead of re-shipping the whole tree — the MCP fetches the live source, merges, validates and saves server-side. Use update_page only when you're rewriting most of the page. Never regenerate the whole tree for a small change.
|
|
17
|
-
- FIX-AFTER-ERROR (don't rebuild — this is the #1 time-waster):
|
|
18
|
-
· create_page failed → its error returns a draft_id (
|
|
19
|
-
·
|
|
20
|
-
|
|
17
|
+
- FIX-AFTER-ERROR / RETRY-AFTER-TIMEOUT (don't rebuild — this is the #1 time-waster): every mutating tool (create_page, update_page, add_section, patch_page) writes the payload to a draft cache BEFORE the network call and returns a draft_id. On validation failure, timeout, or any network error, the draft is kept — retry or fix without re-sending the full JSON:
|
|
18
|
+
· create_page failed validation OR timed out → its error returns a draft_id (full source cached). Call patch_page({ draft_id, patches:[…], dry_run:false }) to fix ONLY the listed elements, or retry create_page({ draft_id, dry_run:false }) as-is after a timeout.
|
|
19
|
+
· add_section failed validation, timed out, or returned dry_run=true → response returns a draft_id (section batch cached). Call patch_page({ draft_id, patches:[…], dry_run:false }) to fix elements and append, or retry patch_page({ draft_id, dry_run:false }) (no patches) to commit as-is.
|
|
20
|
+
· update_page timed out or failed → response returns a draft_id (full source cached). Retry update_page({ draft_id, dry_run:false }) or patch_page({ draft_id, dry_run:false }) with no patches.
|
|
21
|
+
· patch_page (live-page) timed out → response returns a draft_id (patched source cached). Retry patch_page({ draft_id, dry_run:false }) with no patches — the COMMIT-AS-IS path.
|
|
22
|
+
· update_page validation failed → the page already has a page_id → patch_page({ page_id, patches:[…] }) the offending ids.
|
|
23
|
+
COMMIT-AS-IS (retry path after timeout): patch_page({ draft_id, dry_run:false }) with NO patches — skips the apply step, re-validates, then saves. This is the universal retry for any timed-out write.
|
|
24
|
+
A wrong element type is the most common error → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements if unsure). Drafts expire in ~30 min.
|
|
25
|
+
- DRY-RUN CACHE: create_page, update_page, and add_section dry_run=true all cache the validated payload and return a draft_id. Re-run the same tool with { draft_id, dry_run:false } — no need to re-send the source.
|
|
21
26
|
- Organizations: call list_organizations and ask which to use; default to the is_default org. Endpoints are owner-scoped (only the account's own pages).
|
|
22
27
|
- REFERENCE INPUT — if the user provides a layout reference, USE it as the layout anchor (don't ignore it, don't re-invent from scratch). Three input modes: (1) IMAGE/screenshot attached in chat → analyze it natively (no tool call): identify section flow (hero/features/form/cta/footer), heading hierarchy, dominant colors, font feel, then map sections to Webcake elements. (2) HTML string → call ingest_html(html) to get a compact AST. (3) URL → call ingest_url(url) for the same AST. The AST classifies sections by role and lists headings/subheadings/ctas/images/form_fields plus brand hints (colors/fonts) — use it for LAYOUT + HIERARCHY, then generate FRESH content tailored to the user's brand (don't 1:1 copy text). intent='clone' only when the user explicitly asks to mirror the original; default intent='adapt'. The reference workflow PRESERVES craft rules above (centering, page margin, premium spacing, real images) — apply them on top of the reference layout, don't bypass them.
|
|
23
28
|
|
|
@@ -32,4 +37,6 @@ MODEL (essentials):
|
|
|
32
37
|
- Visible content lives in specials (text, src, field_name…), never in styles. Colors as rgba(). Animation in config.animation={name,delay,duration,repeat}. Form inputs need a unique specials.field_name (use canonical keys: full_name, phone_number, email, address, quantity).
|
|
33
38
|
- IMAGES: include them (hero/product, feature icons, about photo). PREFER REAL PHOTOS — call search_images with a short English subject (e.g. 'fresh coffee cup') and put a returned URL (src.large for a hero/banner, src.medium for a card/thumb) into the image-block specials.src; it works out of the box (a shared proxy supplies images). Only if search_images returns ok:false, FALL BACK to a PLACEHOLDER sized to the box: https://placehold.co/<width>x<height>. (gallery.media = array of OBJECTS {type:'image',link:'<url>',linkVideo:'',typeVideo:'youtube',imageCompression:true} — NOT plain strings, the gallery reads item.link; video.specials.img = poster). NEVER leave src empty (renders blank). Ensure text contrasts with its section background.
|
|
34
39
|
|
|
35
|
-
|
|
40
|
+
- PREVIEW vs PUBLISH: the preview_url returned by create_page/update_page/add_section lives on the PREVIEW host (preview.localhost:5800 local / staging.webcake.me staging / www.webcake.me prod — NOT the builder subdomain) and renders the stored source immediately — share it as-is for review. When the user wants the page LIVE (public/published, optionally on their custom domain), call publish_page({ page_id, custom_domain?, custom_path?, dry_run:false }).
|
|
41
|
+
|
|
42
|
+
Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, search_images, ingest_html, ingest_url, list_organizations, create_page, list_pages, find_pages, get_page, update_page, add_section, patch_page, publish_page.`;
|
|
@@ -19,8 +19,11 @@
|
|
|
19
19
|
* WEBCAKE_JWT the account JWT (required to call the backend)
|
|
20
20
|
* WEBCAKE_ORG_ID optional default organization id for create_page
|
|
21
21
|
* WEBCAKE_APP_BASE optional SPA base (used for the login connect page)
|
|
22
|
-
* WEBCAKE_BUILDER_BASE optional builder host for editor
|
|
22
|
+
* WEBCAKE_BUILDER_BASE optional builder host for the editor URLs in the result
|
|
23
23
|
* (defaults to the env preset, else derived from the API host)
|
|
24
|
+
* WEBCAKE_PREVIEW_BASE optional public preview host for the /preview/<id> links —
|
|
25
|
+
* NOT the builder subdomain (defaults to the env preset:
|
|
26
|
+
* preview.localhost:5800 / staging.webcake.me / www.webcake.me)
|
|
24
27
|
* WEBCAKE_CONFIG_DIR optional dir for the saved auth.json (default ~/.webcake-landing-mcp)
|
|
25
28
|
*/
|
|
26
29
|
import { homedir } from "node:os";
|
|
@@ -36,9 +39,9 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
|
36
39
|
* after create/update (a distinct host — NOT the API and NOT the SPA).
|
|
37
40
|
*/
|
|
38
41
|
export const ENVIRONMENTS = {
|
|
39
|
-
local: { apiBase: "http://localhost:5800", appBase: "http://localhost:5173", builderBase: "http://builder.localhost:5800" },
|
|
40
|
-
staging: { apiBase: "https://api.staging.webcake.io", appBase: "https://staging.webcake.io", builderBase: "https://builder.staging.webcake.io" },
|
|
41
|
-
prod: { apiBase: "https://api.webcake.io", appBase: "https://webcake.io", builderBase: "https://builder.webcake.io" },
|
|
42
|
+
local: { apiBase: "http://localhost:5800", appBase: "http://localhost:5173", builderBase: "http://builder.localhost:5800", previewBase: "http://preview.localhost:5800" },
|
|
43
|
+
staging: { apiBase: "https://api.staging.webcake.io", appBase: "https://staging.webcake.io", builderBase: "https://builder.staging.webcake.io", previewBase: "https://staging.webcake.me" },
|
|
44
|
+
prod: { apiBase: "https://api.webcake.io", appBase: "https://webcake.io", builderBase: "https://builder.webcake.io", previewBase: "https://www.webcake.me" },
|
|
42
45
|
};
|
|
43
46
|
export const ENV_NAMES = Object.keys(ENVIRONMENTS);
|
|
44
47
|
/** True when `v` names a known environment (local|staging|prod). */
|
|
@@ -88,6 +91,16 @@ export function readConfig(overrides = {}) {
|
|
|
88
91
|
preset?.builderBase ??
|
|
89
92
|
saved.builderBase ??
|
|
90
93
|
deriveBuilderBase(cleanBase))?.replace(/\/+$/, "");
|
|
94
|
+
// The public preview link (/preview/<id>) is served on its OWN root host — NOT
|
|
95
|
+
// the builder subdomain (preview.localhost:5800 / staging.webcake.me /
|
|
96
|
+
// www.webcake.me). When nothing matches, default to the backend's own preview
|
|
97
|
+
// domain (its @preview_domain) so the link still lands on a host that serves
|
|
98
|
+
// the /preview/:id route.
|
|
99
|
+
const previewBase = (overrides.previewBase ??
|
|
100
|
+
process.env.WEBCAKE_PREVIEW_BASE ??
|
|
101
|
+
preset?.previewBase ??
|
|
102
|
+
saved.previewBase ??
|
|
103
|
+
"https://www.webcake.me").replace(/\/+$/, "");
|
|
91
104
|
return {
|
|
92
105
|
config: {
|
|
93
106
|
base: cleanBase,
|
|
@@ -95,6 +108,7 @@ export function readConfig(overrides = {}) {
|
|
|
95
108
|
orgId: overrides.orgId ?? process.env.WEBCAKE_ORG_ID ?? saved.orgId,
|
|
96
109
|
appBase: (overrides.appBase ?? process.env.WEBCAKE_APP_BASE ?? preset?.appBase ?? saved.appBase)?.replace(/\/+$/, ""),
|
|
97
110
|
builderBase,
|
|
111
|
+
previewBase,
|
|
98
112
|
},
|
|
99
113
|
missing: [],
|
|
100
114
|
};
|
|
@@ -111,7 +125,8 @@ function header(headers, name) {
|
|
|
111
125
|
* x-webcake-env named environment (local|staging|prod) for the base URLs
|
|
112
126
|
* x-webcake-api-base backend base URL (overrides the env preset)
|
|
113
127
|
* x-webcake-app-base SPA base used for the login connect page (overrides the preset)
|
|
114
|
-
* x-webcake-builder-base builder host for editor
|
|
128
|
+
* x-webcake-builder-base builder host for editor URLs (overrides the preset)
|
|
129
|
+
* x-webcake-preview-base public preview host for /preview/<id> links (overrides the preset)
|
|
115
130
|
* Any header that is absent falls back to the corresponding env var in readConfig.
|
|
116
131
|
*/
|
|
117
132
|
export function configFromHeaders(headers) {
|
|
@@ -123,6 +138,7 @@ export function configFromHeaders(headers) {
|
|
|
123
138
|
orgId: header(headers, "x-webcake-org-id"),
|
|
124
139
|
appBase: header(headers, "x-webcake-app-base"),
|
|
125
140
|
builderBase: header(headers, "x-webcake-builder-base"),
|
|
141
|
+
previewBase: header(headers, "x-webcake-preview-base"),
|
|
126
142
|
env: header(headers, "x-webcake-env"),
|
|
127
143
|
};
|
|
128
144
|
}
|
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tiny in-memory store for
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Tiny in-memory store for sources that need a cache — three kinds:
|
|
3
|
+
*
|
|
4
|
+
* - 'page' (default/absent): a full page source whose create_page FAILED validation
|
|
5
|
+
* OR whose create_page network call failed/timed-out after validation passed.
|
|
6
|
+
* The create-before-save gap: a failed create has no page_id to patch
|
|
7
|
+
* against, so we hold the source here keyed by a random draft_id.
|
|
8
|
+
* Commit path: create_page({draft_id, dry_run:false}) or
|
|
9
|
+
* patch_page({draft_id, patches?, dry_run:false}).
|
|
10
|
+
*
|
|
11
|
+
* - 'sections': the expanded throwaway shell built by add_section when dry_run=true
|
|
12
|
+
* or when section validation fails / the append network call fails, so the
|
|
13
|
+
* model never has to re-send the section payload between dry-run → real call
|
|
14
|
+
* or after a fix/timeout round.
|
|
15
|
+
* `source` holds the shell { page:[<new sections>], … }; `page_id` is
|
|
16
|
+
* the live page the sections will be appended to.
|
|
17
|
+
* Commit path: add_section({page_id, draft_id, dry_run:false}) or
|
|
18
|
+
* patch_page({draft_id, patches?, dry_run:false}).
|
|
19
|
+
*
|
|
20
|
+
* - 'update' : a full page source for updatePageSource on an EXISTING page whose
|
|
21
|
+
* update_page/patch_page network call failed or timed-out after validation
|
|
22
|
+
* passed. `page_id` is the live page to overwrite.
|
|
23
|
+
* Commit path: update_page({draft_id, dry_run:false}) or
|
|
24
|
+
* patch_page({draft_id, patches?, dry_run:false}).
|
|
6
25
|
*
|
|
7
26
|
* Bounded + TTL'd; a lost draft (process restart, eviction, expiry) just means the
|
|
8
|
-
* model falls back to re-sending the full source
|
|
27
|
+
* model falls back to re-sending the full source — never a failure.
|
|
9
28
|
* Process-global, but draft_ids are random/unguessable AND persisting still uses the
|
|
10
29
|
* CALLER's own creds, so a draft only ever yields a page in the caller's account.
|
|
11
30
|
*/
|
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
* route is served by this same server in `serve` mode (see src/http.ts).
|
|
19
19
|
*/
|
|
20
20
|
const PEXELS_SEARCH_ENDPOINT = "https://api.pexels.com/v1/search";
|
|
21
|
+
/** Fetch timeout for Pexels/proxy calls — matches the Webcake client default. */
|
|
22
|
+
const PEXELS_TIMEOUT_MS = (() => {
|
|
23
|
+
const v = parseInt(process.env.WEBCAKE_HTTP_TIMEOUT_MS ?? "", 10);
|
|
24
|
+
return Number.isFinite(v) && v > 0 ? v : 60_000;
|
|
25
|
+
})();
|
|
21
26
|
/** Default hosted proxy that holds a shared Pexels key (override with PEXELS_PROXY_BASE). */
|
|
22
27
|
export const PEXELS_PROXY_DEFAULT = "https://mcp.toolvn.io.vn";
|
|
23
28
|
/** Path of the proxy's image-search route (served by this server in `serve` mode). */
|
|
@@ -72,9 +77,12 @@ export async function searchPexels(key, params) {
|
|
|
72
77
|
const url = `${PEXELS_SEARCH_ENDPOINT}?${buildSearchQuery(params).toString()}`;
|
|
73
78
|
let res;
|
|
74
79
|
try {
|
|
75
|
-
res = await fetch(url, { method: "GET", headers: { Authorization: key } });
|
|
80
|
+
res = await fetch(url, { method: "GET", headers: { Authorization: key }, signal: AbortSignal.timeout(PEXELS_TIMEOUT_MS) });
|
|
76
81
|
}
|
|
77
82
|
catch (e) {
|
|
83
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
84
|
+
return { ok: false, status: 0, error: `request timed out after ${PEXELS_TIMEOUT_MS}ms calling Pexels` };
|
|
85
|
+
}
|
|
78
86
|
return { ok: false, status: 0, error: `Network error calling Pexels: ${e?.message ?? e}` };
|
|
79
87
|
}
|
|
80
88
|
const body = await res.text();
|
|
@@ -102,9 +110,12 @@ export async function searchImagesViaProxy(base, params) {
|
|
|
102
110
|
const url = `${base.replace(/\/+$/, "")}${PEXELS_PROXY_PATH}?${buildSearchQuery(params).toString()}`;
|
|
103
111
|
let res;
|
|
104
112
|
try {
|
|
105
|
-
res = await fetch(url, { method: "GET", headers: { Accept: "application/json" } });
|
|
113
|
+
res = await fetch(url, { method: "GET", headers: { Accept: "application/json" }, signal: AbortSignal.timeout(PEXELS_TIMEOUT_MS) });
|
|
106
114
|
}
|
|
107
115
|
catch (e) {
|
|
116
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
117
|
+
return { ok: false, status: 0, error: `request timed out after ${PEXELS_TIMEOUT_MS}ms calling image proxy ${base}` };
|
|
118
|
+
}
|
|
108
119
|
return { ok: false, status: 0, error: `Network error calling image proxy ${base}: ${e?.message ?? e}` };
|
|
109
120
|
}
|
|
110
121
|
const body = await res.text();
|
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
/** Default fetch timeout in ms. Override via WEBCAKE_HTTP_TIMEOUT_MS env. */
|
|
2
|
+
const HTTP_TIMEOUT_MS = (() => {
|
|
3
|
+
const v = parseInt(process.env.WEBCAKE_HTTP_TIMEOUT_MS ?? "", 10);
|
|
4
|
+
return Number.isFinite(v) && v > 0 ? v : 60_000;
|
|
5
|
+
})();
|
|
6
|
+
/** Build an AbortSignal that fires after `ms` milliseconds. */
|
|
7
|
+
function timeoutSignal(ms) {
|
|
8
|
+
return AbortSignal.timeout(ms);
|
|
9
|
+
}
|
|
10
|
+
/** Wrap a fetch error or AbortError in the standard {ok:false} shape. */
|
|
11
|
+
function timeoutOrNetworkError(url, e) {
|
|
12
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
13
|
+
return {
|
|
14
|
+
ok: false,
|
|
15
|
+
status: 0,
|
|
16
|
+
error: `request timed out after ${HTTP_TIMEOUT_MS}ms — the backend may still complete it; check before re-creating to avoid duplicates`,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return { ok: false, status: 0, error: `Network error calling ${url}: ${e?.message ?? e}` };
|
|
20
|
+
}
|
|
1
21
|
const CREATE_ENDPOINT = "/api/v1/ai/create_page_from_source";
|
|
2
22
|
const ORGS_ENDPOINT = "/api/v1/org/organizations";
|
|
3
23
|
const PAGES_ENDPOINT = "/api/v1/ai/pages";
|
|
@@ -5,6 +25,13 @@ const SEARCH_PAGES_ENDPOINT = "/api/v1/ai/search_pages";
|
|
|
5
25
|
const PAGE_SOURCE_ENDPOINT = "/api/v1/ai/page_source";
|
|
6
26
|
const UPDATE_ENDPOINT = "/api/v1/ai/update_page_source";
|
|
7
27
|
const APPEND_ENDPOINT = "/api/v1/ai/append_section";
|
|
28
|
+
// The editor's own publish route (NOT under /api/v1/ai): saves the source as a
|
|
29
|
+
// new version and creates/updates the page_published record (+ optional custom
|
|
30
|
+
// domain/path) so the page goes live. NOTE: this scope is host-constrained to
|
|
31
|
+
// the BUILDER host (router scope `host: "builder."`), so the request goes to
|
|
32
|
+
// config.builderBase, not the API base.
|
|
33
|
+
const publishEndpoint = (pageId) => `/api/pages/${encodeURIComponent(pageId)}/edit/publish`;
|
|
34
|
+
const publishUrl = (config, pageId) => `${(config.builderBase ?? config.base).replace(/\/+$/, "")}${publishEndpoint(pageId)}`;
|
|
8
35
|
function authHeaders(config, orgId) {
|
|
9
36
|
const headers = {
|
|
10
37
|
"Content-Type": "application/json",
|
|
@@ -44,6 +71,33 @@ export function toEditorUrl(config, raw) {
|
|
|
44
71
|
pathQuery = `/${pathQuery}`;
|
|
45
72
|
return `${builder.replace(/\/+$/, "")}${pathQuery}`;
|
|
46
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the public preview link (`/preview/<page_id>`) onto the PREVIEW host
|
|
76
|
+
* (config.previewBase) — NOT the builder subdomain. The /preview/:id route only
|
|
77
|
+
* exists on the root preview hosts (preview.localhost:5800 local /
|
|
78
|
+
* staging.webcake.me staging / www.webcake.me prod); the v4 renderer there reads
|
|
79
|
+
* the stored page_source directly, so the link works without publishing.
|
|
80
|
+
*/
|
|
81
|
+
export function toPreviewUrl(config, raw) {
|
|
82
|
+
if (!raw)
|
|
83
|
+
return raw;
|
|
84
|
+
const preview = config.previewBase;
|
|
85
|
+
if (!preview)
|
|
86
|
+
return toEditorUrl(config, raw); // legacy fallback
|
|
87
|
+
let pathQuery = raw;
|
|
88
|
+
if (/^https?:\/\//i.test(raw)) {
|
|
89
|
+
try {
|
|
90
|
+
const u = new URL(raw);
|
|
91
|
+
pathQuery = u.pathname + u.search + u.hash;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
/* not a parseable URL — use as-is */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!pathQuery.startsWith("/"))
|
|
98
|
+
pathQuery = `/${pathQuery}`;
|
|
99
|
+
return `${preview.replace(/\/+$/, "")}${pathQuery}`;
|
|
100
|
+
}
|
|
47
101
|
/** Build (but do not send) the create request — used for dry-run previews. */
|
|
48
102
|
export function buildRequest(config, name, source, orgId) {
|
|
49
103
|
return {
|
|
@@ -69,10 +123,10 @@ export async function listOrganizations(config) {
|
|
|
69
123
|
const url = `${config.base}${ORGS_ENDPOINT}`;
|
|
70
124
|
let res;
|
|
71
125
|
try {
|
|
72
|
-
res = await fetch(url, { method: "GET", headers: authHeaders(config) });
|
|
126
|
+
res = await fetch(url, { method: "GET", headers: authHeaders(config), signal: timeoutSignal(HTTP_TIMEOUT_MS) });
|
|
73
127
|
}
|
|
74
128
|
catch (e) {
|
|
75
|
-
return
|
|
129
|
+
return timeoutOrNetworkError(url, e);
|
|
76
130
|
}
|
|
77
131
|
const text = await res.text();
|
|
78
132
|
let json = null;
|
|
@@ -100,10 +154,10 @@ export async function createPage(config, name, source, orgId) {
|
|
|
100
154
|
const req = buildRequest(config, name, source, orgId);
|
|
101
155
|
let res;
|
|
102
156
|
try {
|
|
103
|
-
res = await fetch(req.url, { method: req.method, headers: req.headers, body: req.body });
|
|
157
|
+
res = await fetch(req.url, { method: req.method, headers: req.headers, body: req.body, signal: timeoutSignal(HTTP_TIMEOUT_MS) });
|
|
104
158
|
}
|
|
105
159
|
catch (e) {
|
|
106
|
-
return
|
|
160
|
+
return timeoutOrNetworkError(req.url, e);
|
|
107
161
|
}
|
|
108
162
|
const text = await res.text();
|
|
109
163
|
let json = null;
|
|
@@ -134,7 +188,7 @@ export async function createPage(config, name, source, orgId) {
|
|
|
134
188
|
status: res.status,
|
|
135
189
|
page_id: pageId,
|
|
136
190
|
editor_url: toEditorUrl(config, editorPath),
|
|
137
|
-
preview_url:
|
|
191
|
+
preview_url: toPreviewUrl(config, previewPath),
|
|
138
192
|
organization_id: (orgId ?? config.orgId) ?? null,
|
|
139
193
|
raw: data,
|
|
140
194
|
};
|
|
@@ -145,10 +199,11 @@ export async function createPage(config, name, source, orgId) {
|
|
|
145
199
|
async function getJson(url, config) {
|
|
146
200
|
let res;
|
|
147
201
|
try {
|
|
148
|
-
res = await fetch(url, { method: "GET", headers: authHeaders(config) });
|
|
202
|
+
res = await fetch(url, { method: "GET", headers: authHeaders(config), signal: timeoutSignal(HTTP_TIMEOUT_MS) });
|
|
149
203
|
}
|
|
150
204
|
catch (e) {
|
|
151
|
-
|
|
205
|
+
const e2 = timeoutOrNetworkError(url, e);
|
|
206
|
+
return { ok: false, status: 0, json: null, text: "", error: e2.error };
|
|
152
207
|
}
|
|
153
208
|
const text = await res.text();
|
|
154
209
|
let json = null;
|
|
@@ -245,10 +300,11 @@ export async function appendSection(config, pageId, sections) {
|
|
|
245
300
|
method: "POST",
|
|
246
301
|
headers: authHeaders(config),
|
|
247
302
|
body: JSON.stringify({ page_id: pageId, sections }),
|
|
303
|
+
signal: timeoutSignal(HTTP_TIMEOUT_MS),
|
|
248
304
|
});
|
|
249
305
|
}
|
|
250
306
|
catch (e) {
|
|
251
|
-
return
|
|
307
|
+
return timeoutOrNetworkError(url, e);
|
|
252
308
|
}
|
|
253
309
|
// No such route on an older backend → Phoenix 404. Signal a fallback.
|
|
254
310
|
if (res.status === 404) {
|
|
@@ -278,7 +334,7 @@ export async function appendSection(config, pageId, sections) {
|
|
|
278
334
|
status: res.status,
|
|
279
335
|
page_id: pageIdOut,
|
|
280
336
|
editor_url: toEditorUrl(config, data?.editor_url),
|
|
281
|
-
preview_url:
|
|
337
|
+
preview_url: toPreviewUrl(config, data?.preview_url),
|
|
282
338
|
organization_id: data?.organization_id ?? null,
|
|
283
339
|
section_count: data?.section_count,
|
|
284
340
|
sections_added: data?.sections_added,
|
|
@@ -294,10 +350,11 @@ export async function updatePageSource(config, pageId, source) {
|
|
|
294
350
|
method: "POST",
|
|
295
351
|
headers: authHeaders(config),
|
|
296
352
|
body: JSON.stringify({ page_id: pageId, source }),
|
|
353
|
+
signal: timeoutSignal(HTTP_TIMEOUT_MS),
|
|
297
354
|
});
|
|
298
355
|
}
|
|
299
356
|
catch (e) {
|
|
300
|
-
return
|
|
357
|
+
return timeoutOrNetworkError(url, e);
|
|
301
358
|
}
|
|
302
359
|
const text = await res.text();
|
|
303
360
|
let json = null;
|
|
@@ -323,8 +380,115 @@ export async function updatePageSource(config, pageId, source) {
|
|
|
323
380
|
status: res.status,
|
|
324
381
|
page_id: pageIdOut,
|
|
325
382
|
editor_url: toEditorUrl(config, data?.editor_url),
|
|
326
|
-
preview_url:
|
|
383
|
+
preview_url: toPreviewUrl(config, data?.preview_url),
|
|
327
384
|
organization_id: data?.organization_id ?? null,
|
|
328
385
|
raw: data,
|
|
329
386
|
};
|
|
330
387
|
}
|
|
388
|
+
function publishBody(sourceString, opts = {}) {
|
|
389
|
+
// The publish action expects `source` as a JSON STRING (it Jason.decode!s it),
|
|
390
|
+
// plus optional custom_domain/custom_path. is_publish marks the save as a
|
|
391
|
+
// publish in save_page_with_source.
|
|
392
|
+
return JSON.stringify({
|
|
393
|
+
source: sourceString,
|
|
394
|
+
custom_domain: opts.customDomain ?? "",
|
|
395
|
+
custom_path: opts.customPath ?? "",
|
|
396
|
+
is_publish: true,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
/** Build (but do not send) the publish request with the token masked — for dry-run previews. */
|
|
400
|
+
export function buildPublishRequestRedacted(config, pageId, sourceString, opts = {}) {
|
|
401
|
+
const body = publishBody(sourceString, opts);
|
|
402
|
+
return {
|
|
403
|
+
method: "POST",
|
|
404
|
+
url: publishUrl(config, pageId),
|
|
405
|
+
headers: { ...authHeaders(config), Authorization: "Bearer ***JWT***", Cookie: "jwt=***JWT***" },
|
|
406
|
+
body: body.replace(config.jwt, "***JWT***").slice(0, 400) + (body.length > 400 ? `… (${body.length} bytes)` : ""),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* POST to a host-scoped route. Node's fetch cannot reach `*.localhost` hosts
|
|
411
|
+
* (browsers special-case .localhost; Node's DNS does not, and undici forbids a
|
|
412
|
+
* manual Host header) — so for those we connect to loopback via node:http and
|
|
413
|
+
* carry the real host in the Host header. Everything else uses plain fetch.
|
|
414
|
+
*/
|
|
415
|
+
async function postToHost(url, headers, body) {
|
|
416
|
+
const u = new URL(url);
|
|
417
|
+
if (!u.hostname.endsWith(".localhost")) {
|
|
418
|
+
const res = await fetch(url, { method: "POST", headers, body, signal: timeoutSignal(HTTP_TIMEOUT_MS) });
|
|
419
|
+
return { status: res.status, text: await res.text() };
|
|
420
|
+
}
|
|
421
|
+
const { request } = await import("node:http");
|
|
422
|
+
return new Promise((resolve, reject) => {
|
|
423
|
+
const req = request({
|
|
424
|
+
host: "127.0.0.1",
|
|
425
|
+
port: u.port || 80,
|
|
426
|
+
path: u.pathname + u.search,
|
|
427
|
+
method: "POST",
|
|
428
|
+
headers: { ...headers, Host: u.host },
|
|
429
|
+
}, (res) => {
|
|
430
|
+
let data = "";
|
|
431
|
+
res.on("data", (c) => (data += c));
|
|
432
|
+
res.on("end", () => resolve({ status: res.statusCode ?? 0, text: data }));
|
|
433
|
+
});
|
|
434
|
+
req.setTimeout(HTTP_TIMEOUT_MS, () => {
|
|
435
|
+
req.destroy(new Error(`request timed out after ${HTTP_TIMEOUT_MS}ms`));
|
|
436
|
+
});
|
|
437
|
+
req.on("error", reject);
|
|
438
|
+
req.end(body);
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Publish a page: saves the source as a new version and creates/updates the
|
|
443
|
+
* page_published record (live status + optional custom domain/path). Returns the
|
|
444
|
+
* resulting public URL — `https://<domain>/<path>` when a custom domain is
|
|
445
|
+
* attached, else the preview-host link (`<previewBase>/preview/<page_id>`).
|
|
446
|
+
*/
|
|
447
|
+
export async function publishPage(config, pageId, sourceString, opts = {}) {
|
|
448
|
+
const url = publishUrl(config, pageId);
|
|
449
|
+
let status;
|
|
450
|
+
let text;
|
|
451
|
+
try {
|
|
452
|
+
// The builder-host pipeline runs an `accepts ["html"]` plug (it serves the
|
|
453
|
+
// editor SPA); a literal application/json Accept gets a 406, so send */*
|
|
454
|
+
// like the browser does — the action still returns JSON.
|
|
455
|
+
({ status, text } = await postToHost(url, { ...authHeaders(config), Accept: "*/*" }, publishBody(sourceString, opts)));
|
|
456
|
+
}
|
|
457
|
+
catch (e) {
|
|
458
|
+
const e2 = timeoutOrNetworkError(url, e);
|
|
459
|
+
return { ok: false, status: e2.status, error: e2.error };
|
|
460
|
+
}
|
|
461
|
+
let json = null;
|
|
462
|
+
try {
|
|
463
|
+
json = JSON.parse(text);
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
/* non-JSON */
|
|
467
|
+
}
|
|
468
|
+
const resOk = status >= 200 && status < 300;
|
|
469
|
+
const success = json?.success !== false && resOk;
|
|
470
|
+
if (!success) {
|
|
471
|
+
const backendMsg = json?.message ?? json?.reason ?? (json ? undefined : text.slice(0, 200));
|
|
472
|
+
return {
|
|
473
|
+
ok: false,
|
|
474
|
+
status,
|
|
475
|
+
raw: json ?? text.slice(0, 600),
|
|
476
|
+
error: `Backend returned ${status}${backendMsg ? `: ${backendMsg}` : ""}`,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
const data = json?.data ?? json;
|
|
480
|
+
const domain = data?.domain ?? null;
|
|
481
|
+
const path = data?.path ?? null;
|
|
482
|
+
const previewUrl = toPreviewUrl(config, `/preview/${pageId}`);
|
|
483
|
+
const publishedUrl = domain ? `https://${domain}${path ? `/${String(path).replace(/^\/+/, "")}` : ""}` : previewUrl;
|
|
484
|
+
return {
|
|
485
|
+
ok: true,
|
|
486
|
+
status,
|
|
487
|
+
page_id: pageId,
|
|
488
|
+
published_url: publishedUrl,
|
|
489
|
+
preview_url: previewUrl,
|
|
490
|
+
domain,
|
|
491
|
+
path,
|
|
492
|
+
raw: data,
|
|
493
|
+
};
|
|
494
|
+
}
|