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 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/preview links |
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/preview links in the result. Defaults to the env preset, else derived from the API host (`api.x`→`builder.x`). |
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, dry_run: false }) # backend appends server-side (no whole-source get+put)
421
- add_section({ page_id, sections: [formSection, footerSection], dry_run: false })
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
 
@@ -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): when create_page / update_page / add_section returns validation errors, DO NOT regenerate and re-send the whole source. Send only the fix via patch_page:
18
- · create_page failed → its error returns a draft_id (the source is cached server-side). Call patch_page({ draft_id, patches:[…], dry_run:false }) with ops fixing ONLY the listed elements; it re-validates the merged tree and CREATES the page. A wrong element type is the most common error → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements if unsure). The draft keeps your fixes across rounds and expires in ~30 min.
19
- · update_page / add_section failed the page already has a page_id patch_page({ page_id, patches:[…] }) the offending ids.
20
- Either way you ship a tiny diff, not the large payload that drops the connection.
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
- 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.`;
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/preview URLs in the result
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/preview URLs (overrides the preset)
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 page sources that FAILED create_page validation, so the
3
- * model can fix ONLY the broken elements (patch_page with a draft_id) instead of
4
- * re-emitting the whole source. The create-before-save gap: a failed create has no
5
- * page_id to patch against, so we hold the source here keyed by a random draft_id.
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 via create_page — never a failure.
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 { ok: false, status: 0, error: `Network error calling ${url}: ${e?.message ?? e}` };
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 { ok: false, status: 0, error: `Network error calling ${req.url}: ${e?.message ?? e}` };
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: toEditorUrl(config, previewPath),
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
- return { ok: false, status: 0, json: null, text: "", error: `Network error calling ${url}: ${e?.message ?? e}` };
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 { ok: false, status: 0, error: `Network error calling ${url}: ${e?.message ?? e}` };
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: toEditorUrl(config, data?.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 { ok: false, status: 0, error: `Network error calling ${url}: ${e?.message ?? e}` };
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: toEditorUrl(config, data?.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
+ }