webcake-storefront-mcp 1.0.1

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.
@@ -0,0 +1,64 @@
1
+ // Generation guide injected into builder tools so an AI agent understands the BuilderX
2
+ // page model well enough to author pages that actually render.
3
+ export const BUILD_GUIDE = `# BuilderX page authoring guide
4
+
5
+ ## Page shape
6
+ A page's content is a single JSON object: \`{ "sections": [ <section>, ... ] }\`.
7
+ - Save it via build_page (new page) or update_page_source (existing page).
8
+ - A page is a vertical STACK of sections (top → bottom). Sections are the only valid
9
+ top-level children of \`sections\`.
10
+
11
+ ## Node shape (every element)
12
+ \`\`\`
13
+ {
14
+ "id": "TEXT-ab12cd34", // unique per page; prefix = TYPE-, see factories
15
+ "type": "text", // one of the catalog types (list_elements)
16
+ "name": "",
17
+ "specials": { ... }, // CONTENT + behaviour (text, src, field_name, ...)
18
+ "runtime": { "style": {}, "config": {} }, // STYLE (css) + LAYOUT (grid/position)
19
+ "children": [ ... ], // only for container types
20
+ "events": [ ... ], // click/hover actions
21
+ "bindings": [ ... ] // dataset bindings (product/category/blog fields)
22
+ }
23
+ \`\`\`
24
+ Never hand-write a node from scratch — call new_element / new_section so the factory
25
+ fills the correct defaults, then edit specials/style.
26
+
27
+ ## Layout = CSS grid (NOT absolute top/left)
28
+ This is the key difference from landing-page builders. A section/container positions its
29
+ children with a grid:
30
+ - container \`runtime.config\`: \`grid: "1xN"\`, \`columns: [{unit:'fr',value:1}]\`,
31
+ \`rows: [{unit:'min/max', min:{unit:'px',absValue:H}, max:{unit:'max-c'}}, ...]\`.
32
+ - each child \`runtime.config\`: \`columnStart/columnEnd\`, \`rowStart/rowEnd\` (1-based grid
33
+ lines), \`constraintX\` (['left'|'right'|'centerLeft']), \`constraintY\` (['top'|'bottom'|'centerTop']).
34
+ new_section does this for you: pass children and they are stacked one row each. To build
35
+ multi-column layouts, nest a container child and give it its own grid.
36
+
37
+ ## Styling
38
+ - \`runtime.style\` holds CSS-ish props: width/height (numbers = px), color, background,
39
+ fontSize ("16px"), fontWeight, textAlign, border*, boxShadow, etc.
40
+ - \`runtime.config.heightUnit\`: "auto" lets content set height (default for text/image).
41
+ - Colours as hex or rgba(). Use the site theme colours where possible.
42
+
43
+ ## Responsive breakpoints
44
+ Override style/config per breakpoint by adding a key on the node: \`bp1\`, \`tablet\`,
45
+ \`laptop\` → \`{ style: {...}, config: {...} }\`. Desktop values live in \`runtime\`.
46
+ Breakpoint widths: large_desktop 1920, desktop 1280, laptop 992, tablet 640.
47
+
48
+ ## Content & data
49
+ - Text: \`specials.text\` (HTML allowed), \`specials.tag\` ("h1".."p").
50
+ - Image: \`runtime.config.src\` (URL). Use search_images / upload before referencing.
51
+ - Form: wrap inputs in a \`form\`; set \`form.specials.type\`
52
+ (form_order | form_login | form_signup | form_discount | order_tracking). Each input
53
+ needs \`specials.field_name\`.
54
+ - Dataset elements (text-dataset, image-dataset, grid-product...) use \`bindings\` to pull
55
+ product/category/blog data — leave bindings to dataset-driven pages.
56
+
57
+ ## Workflow (do this every time)
58
+ 1. Intake: confirm goal, brand, colours, sections wanted (ask 3-5 questions if unclear).
59
+ 2. list_elements / get_element to pick the right component types.
60
+ 3. Build sections with new_section (or new_element for one node), fill content.
61
+ 4. validate_page — fix every error and review warnings.
62
+ 5. build_page with dry_run:true first → review → dry_run:false to persist.
63
+ 6. For existing pages, prefer surgical edits (update_page_element) over full rewrites.
64
+ Always keep Vietnamese text with full diacritics; reply in the user's language.`;
@@ -0,0 +1,149 @@
1
+ // Page-level helpers: skeleton, grid composition, id hygiene, and validation.
2
+ //
3
+ // A BuilderX page source is `{ sections: [ <section node>, ... ] }`. Sections lay out
4
+ // their children in a CSS grid driven by runtime.config (grid / columns / rows) and each
5
+ // child's runtime.config (columnStart/End, rowStart/End, constraintX/Y). The helpers here
6
+ // produce that structure the same way the builder does, so generated pages render.
7
+ import { buildElement, isKnownType, ELEMENT_TYPES } from "./catalog.js";
8
+ import { randomString } from "./factory.js";
9
+ /** Walk every node in a source tree (depth-first). Return false from fn to stop. */
10
+ export function walk(source, fn) {
11
+ const sections = source && Array.isArray(source.sections) ? source.sections : [];
12
+ const visit = (node) => {
13
+ if (!node)
14
+ return true;
15
+ if (fn(node) === false)
16
+ return false;
17
+ for (const child of node.children || []) {
18
+ if (visit(child) === false)
19
+ return false;
20
+ }
21
+ return true;
22
+ };
23
+ for (const s of sections) {
24
+ if (visit(s) === false)
25
+ return;
26
+ }
27
+ }
28
+ /** Empty but valid page source. */
29
+ export function newPageSkeleton() {
30
+ return { sections: [] };
31
+ }
32
+ const typePrefix = (type) => ({ rectangle: "RECT", "grid-category": "GRID-CATE", "slider-category": "SLIDER-CATE" }[type] ||
33
+ type.toUpperCase());
34
+ /** Re-id every node in a subtree so a cloned/template node can't collide with existing ids. */
35
+ export function reassignIds(node) {
36
+ if (!node || typeof node !== "object")
37
+ return node;
38
+ if (node.type)
39
+ node.id = `${typePrefix(node.type)}-${randomString(8)}`;
40
+ for (const child of node.children || [])
41
+ reassignIds(child);
42
+ return node;
43
+ }
44
+ /**
45
+ * Lay children out vertically inside a section/container using a single-column grid —
46
+ * the same shape the builder emits. `children` are placed top-to-bottom, one grid row each.
47
+ */
48
+ export function stackChildren(container, children) {
49
+ const rows = children.map((child) => {
50
+ const h = (child.runtime && child.runtime.style && child.runtime.style.height) || 50;
51
+ return { unit: "min/max", min: { unit: "px", absValue: h }, max: { unit: "max-c" } };
52
+ });
53
+ container.runtime = container.runtime || {};
54
+ container.runtime.config = {
55
+ ...(container.runtime.config || {}),
56
+ grid: `1x${children.length || 1}`,
57
+ columns: [{ unit: "fr", value: 1 }],
58
+ rows: rows.length ? rows : [{ unit: "min/max", min: { unit: "px", absValue: 50 }, max: { unit: "max-c" } }],
59
+ heightUnit: "auto",
60
+ };
61
+ children.forEach((child, i) => {
62
+ child.runtime = child.runtime || {};
63
+ child.runtime.config = {
64
+ ...(child.runtime.config || {}),
65
+ columnStart: 1,
66
+ columnEnd: 2,
67
+ rowStart: i + 1,
68
+ rowEnd: i + 2,
69
+ constraintX: (child.runtime.config && child.runtime.config.constraintX) || ["centerLeft"],
70
+ constraintY: (child.runtime.config && child.runtime.config.constraintY) || ["top"],
71
+ loaded: true,
72
+ };
73
+ });
74
+ container.children = children;
75
+ return container;
76
+ }
77
+ /**
78
+ * Build a ready-to-place section from a list of child specs.
79
+ * Each spec: { type, opts?, children? } where children is a nested array of specs.
80
+ */
81
+ export function buildSection(childSpecs = [], sectionOpts = {}) {
82
+ const section = buildElement("section", sectionOpts);
83
+ const children = childSpecs.map((spec) => buildFromSpec(spec));
84
+ stackChildren(section, children);
85
+ return section;
86
+ }
87
+ function buildFromSpec(spec) {
88
+ if (!spec || !spec.type) {
89
+ throw new Error("Each element spec must have a 'type'.");
90
+ }
91
+ const node = buildElement(spec.type, spec.opts || {});
92
+ if (Array.isArray(spec.children) && spec.children.length) {
93
+ const kids = spec.children.map((c) => buildFromSpec(c));
94
+ stackChildren(node, kids);
95
+ }
96
+ return node;
97
+ }
98
+ /** Validate a page source. Errors block a save; warnings are fixable design issues. */
99
+ export function validatePage(source) {
100
+ const errors = [];
101
+ const warnings = [];
102
+ if (!source || typeof source !== "object" || !Array.isArray(source.sections)) {
103
+ return { valid: false, errors: ["Source must be an object shaped { sections: [...] }."] };
104
+ }
105
+ const ids = new Set();
106
+ const allIds = new Set();
107
+ let total = 0;
108
+ const typeCounts = {};
109
+ // First pass: collect ids + flag duplicates / unknown types / missing fields.
110
+ walk(source, (node) => {
111
+ total++;
112
+ const type = node.type || "(missing)";
113
+ typeCounts[type] = (typeCounts[type] || 0) + 1;
114
+ if (!node.id)
115
+ errors.push(`A ${type} node is missing an id.`);
116
+ else if (ids.has(node.id))
117
+ errors.push(`Duplicate element id "${node.id}".`);
118
+ else
119
+ ids.add(node.id);
120
+ if (node.id)
121
+ allIds.add(node.id);
122
+ if (!node.type)
123
+ errors.push(`A node (id ${node.id || "?"}) is missing a type.`);
124
+ else if (!isKnownType(node.type))
125
+ warnings.push(`Unknown element type "${node.type}" (id ${node.id}).`);
126
+ // Form fields should carry a field_name so submissions map to data.
127
+ if (/^(input|email|phone-number|text-area|select|address|password)$/.test(node.type || "")) {
128
+ const fn = node.specials && node.specials.field_name;
129
+ if (!fn)
130
+ warnings.push(`Form field "${node.id}" (${node.type}) has no specials.field_name.`);
131
+ }
132
+ });
133
+ // Second pass: event targets must point at an element that exists in the page.
134
+ walk(source, (node) => {
135
+ for (const ev of node.events || []) {
136
+ const target = ev.open_page_id ? null : ev.target || ev.target_id;
137
+ if (target && !allIds.has(target)) {
138
+ warnings.push(`Event on "${node.id}" targets missing element "${target}".`);
139
+ }
140
+ }
141
+ });
142
+ return {
143
+ valid: errors.length === 0,
144
+ ...(errors.length ? { errors } : {}),
145
+ ...(warnings.length ? { warnings } : {}),
146
+ stats: { sections: source.sections.length, total_elements: total, element_types: typeCounts },
147
+ };
148
+ }
149
+ export { ELEMENT_TYPES };
package/dist/config.js ADDED
@@ -0,0 +1,97 @@
1
+ // Central resolution of connection settings for every entry path (stdio, install,
2
+ // login, remote HTTP). Precedence: explicit overrides > environment variables >
3
+ // saved config in the local SQLite db.
4
+ import { WebcakeCmsApi } from "./api.js";
5
+ import { getSavedConfig } from "./tools/context.js";
6
+ // Per-environment endpoints so you can switch with `--env <name>` / WEBCAKE_ENV
7
+ // instead of setting WEBCAKE_API_URL / WEBCAKE_APP_URL by hand. Default is prod.
8
+ export const ENVIRONMENTS = {
9
+ local: {
10
+ apiUrl: "http://localhost:24679",
11
+ appUrl: "http://localhost:5173",
12
+ preview: { kind: "path", base: "http://demo.localhost:24679" },
13
+ },
14
+ staging: {
15
+ apiUrl: "https://api.staging.storecake.io",
16
+ appUrl: "https://staging.webcake.io",
17
+ preview: { kind: "path", base: "https://staging2.webcake.me" },
18
+ },
19
+ prod: {
20
+ apiUrl: "https://api.storefront.webcake.io",
21
+ appUrl: "https://webcake.io",
22
+ preview: { kind: "subdomain", suffix: "webcake.me" },
23
+ },
24
+ };
25
+ export const DEFAULT_ENV = "prod";
26
+ /** Map a resolved API base back to its named environment (so the preview rule can be
27
+ * picked without threading the env around). Falls back to the default env. */
28
+ export function envFromApiUrl(apiUrl) {
29
+ const normalized = (apiUrl || "").replace(/\/$/, "");
30
+ for (const [name, p] of Object.entries(ENVIRONMENTS)) {
31
+ if (p.apiUrl === normalized)
32
+ return name;
33
+ }
34
+ return DEFAULT_ENV;
35
+ }
36
+ /** Resolve a site's public preview/live URL: a custom domain if set, otherwise the
37
+ * per-environment rule. Returns null if it can't be determined. */
38
+ export async function resolvePreviewUrl(api) {
39
+ let site;
40
+ try {
41
+ const res = await api.getSite();
42
+ site = (res && res.data) || res;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ if (!site)
48
+ return null;
49
+ const custom = site.primary_domain && site.primary_domain.domain;
50
+ if (custom)
51
+ return `https://${custom}`;
52
+ const rule = ENVIRONMENTS[envFromApiUrl(api.baseUrl)].preview;
53
+ if (rule.kind === "subdomain") {
54
+ const slug = site.site_slug && site.site_slug.slug;
55
+ return slug ? `https://${slug}.${rule.suffix}` : null;
56
+ }
57
+ return `${rule.base}/${api.siteId}`;
58
+ }
59
+ export function resolveEnv(name) {
60
+ if (!name)
61
+ return undefined;
62
+ return ENVIRONMENTS[name];
63
+ }
64
+ /** Resolve effective settings from overrides, env vars, named env preset, and saved config.
65
+ *
66
+ * Precedence for the URLs: explicit override → explicit env var → named-env preset
67
+ * (only when WEBCAKE_ENV / --env is set) → saved config (from `login`) → prod default.
68
+ * So with zero config you hit prod; `--env local` flips to localhost for testing; an
69
+ * explicit WEBCAKE_API_URL still wins; and a logged-in saved URL is respected. */
70
+ export function resolveSettings(overrides = {}) {
71
+ const saved = getSavedConfig();
72
+ const envName = overrides.env ?? process.env.WEBCAKE_ENV;
73
+ const preset = resolveEnv(envName); // undefined unless an env was explicitly named
74
+ const apiUrl = overrides.apiUrl ||
75
+ process.env.WEBCAKE_API_URL ||
76
+ preset?.apiUrl ||
77
+ saved.api_url ||
78
+ ENVIRONMENTS[DEFAULT_ENV].apiUrl;
79
+ const appUrl = overrides.appUrl ||
80
+ process.env.WEBCAKE_APP_URL ||
81
+ preset?.appUrl ||
82
+ ENVIRONMENTS[DEFAULT_ENV].appUrl;
83
+ const token = overrides.token || process.env.WEBCAKE_TOKEN || saved.token || "";
84
+ const siteId = overrides.siteId || process.env.WEBCAKE_SITE_ID || saved.site_id || "";
85
+ const sessionId = overrides.sessionId || process.env.WEBCAKE_SESSION_ID || saved.session_id || "";
86
+ return { apiUrl, appUrl, token, siteId, sessionId, env: envName };
87
+ }
88
+ /** Build a WebcakeCmsApi client from resolved settings. */
89
+ export function makeApi(overrides = {}) {
90
+ const s = resolveSettings(overrides);
91
+ return new WebcakeCmsApi({
92
+ baseUrl: s.apiUrl,
93
+ token: s.token,
94
+ siteId: s.siteId,
95
+ sessionId: s.sessionId,
96
+ });
97
+ }
package/dist/db.js ADDED
@@ -0,0 +1,96 @@
1
+ import Database from "better-sqlite3";
2
+ import { mkdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ // Persist in a stable home directory so saved config survives `npx` (where the
6
+ // package lives in an ephemeral cache dir) and rebuilds.
7
+ const CONFIG_DIR = process.env.WEBCAKE_CONFIG_DIR || join(homedir(), ".webcake-storefront-mcp");
8
+ mkdirSync(CONFIG_DIR, { recursive: true });
9
+ const DB_PATH = join(CONFIG_DIR, "webcake-mcp.db");
10
+ const db = new Database(DB_PATH);
11
+ // WAL mode for better concurrent reads
12
+ db.pragma("journal_mode = WAL");
13
+ // ── Schema ──
14
+ db.exec(`
15
+ CREATE TABLE IF NOT EXISTS config (
16
+ key TEXT PRIMARY KEY,
17
+ value TEXT NOT NULL
18
+ );
19
+ `);
20
+ // ── Simple key-value helpers ──
21
+ const stmtGet = db.prepare("SELECT value FROM config WHERE key = ?");
22
+ const stmtSet = db.prepare("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)");
23
+ const stmtDel = db.prepare("DELETE FROM config WHERE key = ?");
24
+ const stmtAll = db.prepare("SELECT key, value FROM config");
25
+ export function getConfig(key) {
26
+ const row = stmtGet.get(key);
27
+ return row ? row.value : null;
28
+ }
29
+ export function setConfig(key, value) {
30
+ stmtSet.run(key, String(value));
31
+ }
32
+ export function delConfig(key) {
33
+ stmtDel.run(key);
34
+ }
35
+ export function getAllConfig() {
36
+ const rows = stmtAll.all();
37
+ const result = {};
38
+ for (const row of rows)
39
+ result[row.key] = row.value;
40
+ return result;
41
+ }
42
+ // ── Image alt cache ──
43
+ db.exec(`
44
+ CREATE TABLE IF NOT EXISTS image_alt_cache (
45
+ url_key TEXT PRIMARY KEY,
46
+ url TEXT NOT NULL,
47
+ alt TEXT NOT NULL,
48
+ source TEXT,
49
+ updated_at INTEGER NOT NULL
50
+ );
51
+ `);
52
+ const stmtAltGet = db.prepare("SELECT url_key, url, alt, source, updated_at FROM image_alt_cache WHERE url_key = ?");
53
+ const stmtAltSet = db.prepare(`
54
+ INSERT INTO image_alt_cache (url_key, url, alt, source, updated_at)
55
+ VALUES (@url_key, @url, @alt, @source, @updated_at)
56
+ ON CONFLICT(url_key) DO UPDATE SET
57
+ url = excluded.url,
58
+ alt = excluded.alt,
59
+ source = excluded.source,
60
+ updated_at = excluded.updated_at
61
+ `);
62
+ const stmtAltList = db.prepare("SELECT url_key, url, alt, source, updated_at FROM image_alt_cache ORDER BY updated_at DESC LIMIT ? OFFSET ?");
63
+ const stmtAltCount = db.prepare("SELECT COUNT(*) AS n FROM image_alt_cache");
64
+ export function getImageAlt(urlKey) {
65
+ return stmtAltGet.get(urlKey) || null;
66
+ }
67
+ export function getImageAlts(urlKeys) {
68
+ const out = new Map();
69
+ for (const k of urlKeys) {
70
+ const row = stmtAltGet.get(k);
71
+ if (row)
72
+ out.set(k, row);
73
+ }
74
+ return out;
75
+ }
76
+ export function setImageAlt({ url_key, url, alt, source = "ai" }) {
77
+ stmtAltSet.run({ url_key, url, alt, source, updated_at: Date.now() });
78
+ }
79
+ export const setImageAlts = db.transaction((items) => {
80
+ for (const it of items) {
81
+ stmtAltSet.run({
82
+ url_key: it.url_key,
83
+ url: it.url,
84
+ alt: it.alt,
85
+ source: it.source || "ai",
86
+ updated_at: Date.now(),
87
+ });
88
+ }
89
+ });
90
+ export function listImageAlts(limit = 100, offset = 0) {
91
+ return stmtAltList.all(limit, offset);
92
+ }
93
+ export function countImageAlts() {
94
+ return stmtAltCount.get().n;
95
+ }
96
+ export default db;
package/dist/guides.js ADDED
@@ -0,0 +1,93 @@
1
+ export const HTTP_FUNCTION_GUIDE = `
2
+ # HTTP Function Guide
3
+
4
+ ## Syntax
5
+ export const [method]_[FunctionName] = (request) => { return result; }
6
+ - Method: lowercase (get, post, put, patch, delete)
7
+ - FunctionName: PascalCase
8
+ - Examples: get_Products, post_CreateOrder, delete_RemoveItem
9
+
10
+ ## Request object
11
+ - request.params — query params or body params
12
+ - request.customer — logged-in customer { id, name, email, first_name, last_name, phone_number, avatar }
13
+ - request.account — admin account { id, name, email, first_name, last_name, phone_number, avatar }
14
+ - request.data — full request params (including query string)
15
+
16
+ ## API endpoint after deploy
17
+ GET/POST/PUT/PATCH /api/v1/{site_id}/_functions/{FunctionName}
18
+
19
+ ## webcake-data (Database SDK, built-in, no config needed)
20
+ import { DBConnection } from 'webcake-data';
21
+ const db = new DBConnection();
22
+ const Model = db.model('table_name');
23
+
24
+ ### CRUD
25
+ - Model.create({ field: value })
26
+ - Model.insertMany([...])
27
+ - Model.find(filter).sort().limit().skip().select().exec()
28
+ - Model.findOne(filter, { select, sort, populate })
29
+ - Model.findById(id)
30
+ - Model.updateOne(filter, update)
31
+ - Model.findByIdAndUpdate(id, update)
32
+ - Model.updateMany(filter, update)
33
+ - Model.deleteOne(filter)
34
+ - Model.findByIdAndDelete(id)
35
+ - Model.deleteMany(filter)
36
+ - Model.countDocuments(filter)
37
+ - Model.exists(filter)
38
+
39
+ ### QueryBuilder
40
+ Model.find().where('age').gte(25).lte(40).in('role', ['admin']).like('email', '%@ex.com').sort({ age: -1 }).limit(20).skip(10).select('name email').exec()
41
+
42
+ ### Populate (joins)
43
+ Model.find().populate({ field: 'posts', table: 'posts', referenceField: 'user_id', select: 'title', where: {}, sort: {}, limit: 5 }).exec()
44
+
45
+ ### Operators
46
+ where, eq, ne, gt, gte, lt, lte, in, nin, between, like, sort, limit, skip, select, populate
47
+
48
+ ## Built-in Modules
49
+ - import { findArticleById, findArticle, createArticle, updateArticleById, deleteArticleById } from '@webcake/article'
50
+ - import { findCustomerById, findCustomerByPhone, findCustomerByEmail } from '@webcake/customer'
51
+ - import { addBonus } from '@webcake/promotion'
52
+ - import { getAccessToken } from '@webcake/token'
53
+ - import { sendMail } from '@webcake/app/automation'
54
+ All module functions take (request, ...args) and auto-use global token/site_id.
55
+
56
+ ## Sandbox Globals (no import needed)
57
+ - fetch(url, options) — HTTP requests
58
+ - URLSearchParams — URL query building
59
+ - console.log/warn/error — logging (captured in debug mode)
60
+ - global.domain, global.siteId, global.token, global.headers
61
+
62
+ ## Cron Jobs (jobs_config JSON)
63
+ { "jobs": [{ "functionLocation": "backend/http_function", "functionName": "myFunc", "executionConfig": { "cronExpression": "0 2 * * *" } }] }
64
+ `;
65
+ export const CUSTOM_CODE_GUIDE = `
66
+ # Custom Code Guide
67
+
68
+ Custom code is stored in site settings (applies to entire site, not per page).
69
+
70
+ ## Injection points
71
+ - code_before_head: HTML/script inserted before </head> (meta tags, external CSS, tracking scripts)
72
+ - code_before_body: HTML/script inserted before </body> (DOM-ready JS, widgets)
73
+ - code_custom_css: Custom CSS (auto-wrapped in <style>)
74
+ - code_custom_javascript: Custom JavaScript
75
+
76
+ ## webcake-fn (call HTTP functions from frontend)
77
+ Add CDN to code_before_head:
78
+ <script src="https://cdn.jsdelivr.net/npm/webcake-fn/dist/webcake-fn.umd.min.js"></script>
79
+
80
+ Then use window.api in code_custom_javascript or code_before_body:
81
+ - api.get_Products({ category: 'shoes' })
82
+ - api.post_CreateOrder({ items: [...] })
83
+ - Method lowercase + FunctionName matching backend export
84
+
85
+ ## Available globals
86
+ - window.pubsub.subscribe(event, callback) / window.pubsub.publish(event, data)
87
+ - window.useNotification(type, { title, message }) — type: 'success' | 'error' | 'warning'
88
+ - window.resizeLink(url, width, height) — returns { webp, cdn }
89
+ - window.SITE_DATA, window.DATA_ORDER — site context
90
+
91
+ ## Error handling
92
+ try { const r = await api.post_X(params); } catch (e) { window.useNotification('error', { title: 'Error', message: e.message }); }
93
+ `;
package/dist/http.js ADDED
@@ -0,0 +1,120 @@
1
+ // Remote MCP over Streamable-HTTP. Each client session carries its own credentials,
2
+ // supplied per-request via headers (x-webcake-jwt / x-webcake-site-id / x-webcake-api-url)
3
+ // or query params (?jwt=&site_id=&api_url=) for clients that can't set custom headers.
4
+ import { createServer as createHttpServer } from "node:http";
5
+ import { randomUUID } from "node:crypto";
6
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
8
+ import { createServer } from "./server.js";
9
+ import { makeApi } from "./config.js";
10
+ const MCP_PATH = "/mcp";
11
+ const QUERY_TO_HEADER = {
12
+ jwt: "x-webcake-jwt",
13
+ token: "x-webcake-jwt",
14
+ site_id: "x-webcake-site-id",
15
+ api_url: "x-webcake-api-url",
16
+ session_id: "x-webcake-session-id",
17
+ env: "x-webcake-env",
18
+ };
19
+ function header(req, name) {
20
+ const v = req.headers[name];
21
+ return Array.isArray(v) ? v[0] : v;
22
+ }
23
+ /** Copy recognised query params onto request headers so downstream reads are uniform. */
24
+ function applyQueryAuth(req) {
25
+ const q = (req.url ?? "").indexOf("?");
26
+ if (q === -1)
27
+ return;
28
+ const params = new URLSearchParams((req.url ?? "").slice(q + 1));
29
+ for (const [param, head] of Object.entries(QUERY_TO_HEADER)) {
30
+ const value = params.get(param);
31
+ if (value && req.headers[head] == null)
32
+ req.headers[head] = value;
33
+ }
34
+ }
35
+ function apiFromRequest(req) {
36
+ return makeApi({
37
+ token: header(req, "x-webcake-jwt"),
38
+ siteId: header(req, "x-webcake-site-id"),
39
+ apiUrl: header(req, "x-webcake-api-url"),
40
+ sessionId: header(req, "x-webcake-session-id"),
41
+ env: header(req, "x-webcake-env"),
42
+ });
43
+ }
44
+ function readBody(req) {
45
+ return new Promise((resolve, reject) => {
46
+ const chunks = [];
47
+ req.on("data", (c) => chunks.push(c));
48
+ req.on("end", () => {
49
+ const raw = Buffer.concat(chunks).toString("utf-8");
50
+ if (!raw)
51
+ return resolve(undefined);
52
+ try {
53
+ resolve(JSON.parse(raw));
54
+ }
55
+ catch (e) {
56
+ reject(e);
57
+ }
58
+ });
59
+ req.on("error", reject);
60
+ });
61
+ }
62
+ function rpcError(res, status, message) {
63
+ res.writeHead(status, { "content-type": "application/json" });
64
+ res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null }));
65
+ }
66
+ export async function startHttpServer(port) {
67
+ const transports = new Map();
68
+ const httpServer = createHttpServer(async (req, res) => {
69
+ const path = (req.url ?? "").split("?")[0];
70
+ if (path === "/health") {
71
+ res.writeHead(200, { "content-type": "application/json" });
72
+ res.end(JSON.stringify({ ok: true }));
73
+ return;
74
+ }
75
+ if (path !== MCP_PATH) {
76
+ return rpcError(res, 404, `Not found. Send MCP requests to ${MCP_PATH}.`);
77
+ }
78
+ applyQueryAuth(req);
79
+ const sidHeader = header(req, "mcp-session-id");
80
+ try {
81
+ // Reuse an existing session.
82
+ if (sidHeader && transports.has(sidHeader)) {
83
+ const transport = transports.get(sidHeader);
84
+ const body = req.method === "POST" ? await readBody(req) : undefined;
85
+ await transport.handleRequest(req, res, body);
86
+ return;
87
+ }
88
+ // New session: must be an initialize POST.
89
+ if (req.method === "POST") {
90
+ const body = await readBody(req);
91
+ if (!sidHeader && isInitializeRequest(body)) {
92
+ const transport = new StreamableHTTPServerTransport({
93
+ sessionIdGenerator: () => randomUUID(),
94
+ onsessioninitialized: (id) => {
95
+ transports.set(id, transport);
96
+ },
97
+ });
98
+ transport.onclose = () => {
99
+ if (transport.sessionId)
100
+ transports.delete(transport.sessionId);
101
+ };
102
+ const api = apiFromRequest(req);
103
+ const server = createServer(api);
104
+ await server.connect(transport);
105
+ await transport.handleRequest(req, res, body);
106
+ return;
107
+ }
108
+ return rpcError(res, 400, "Bad Request: send an initialize request first (no valid mcp-session-id).");
109
+ }
110
+ return rpcError(res, 400, "Bad Request: missing or unknown mcp-session-id.");
111
+ }
112
+ catch (e) {
113
+ const msg = e instanceof Error ? e.message : String(e);
114
+ if (!res.headersSent)
115
+ rpcError(res, 500, msg);
116
+ }
117
+ });
118
+ await new Promise((resolve) => httpServer.listen(port, resolve));
119
+ console.error(`[webcake-storefront] Streamable-HTTP MCP ready on http://localhost:${port}${MCP_PATH}`);
120
+ }