Files
webstudio/apps/builder/app/shared/resources.ts
Oleg Isonen dde30ffb54 feat: Improve MCP authoring, compact output, and agent onboarding (#5883)
## Summary

This improves how agents discover, author, inspect, and verify Webstudio
projects while reducing unnecessary MCP context usage.

## What changed

- Adds an **Agents** section to Project Settings with ready-to-run setup
commands and client configuration for Claude Code, Codex, Cursor, and VS
Code.
- Makes large list and schema responses compact and paginated by
default, with **verbose** available when complete records or diagnostics
are needed.
- Applies consistent compact output to assets, instances, styles, design
tokens, CLI schemas, and project-session metadata.
- Returns actionable expression warnings for props, resources, and
Collection bindings, including unknown variables and risky render-time
mutation resources.
- Improves Collection expression guidance so agents can bind array items
with the correct scoped variables.
- Reports rendered audit outcomes explicitly as complete, partial,
confirmation-required, or failed.
- Expands runtime output schemas so MCP clients can validate responses
reliably.
- Strengthens the packaged CLI smoke workflow with a realistic
seven-page stdio MCP scenario.

## Architecture

- Consolidates `project-build` from 65 file-oriented entrypoints to 11
architecture-owned surfaces.
- Separates the core model, contracts, runtime authoring, transfer,
state, project sessions, MCP, visual tooling, persistence codecs, and
authenticated server services.
- Isolates Node-only JSX evaluation from browser-safe transfer utilities
and authenticated server modules.
- Keeps persistence codecs independent from authenticated database
services, preventing unrelated module initialization.
- Defines shared instance-path types once and reuses them across runtime
modules.
- Preserves Immer initialization through explicit package side-effect
metadata.
- Adds package tests that prevent a return to source-file entrypoints or
generic exposure of Node-only surfaces.

## Validation

- Full repository checks, tests, typechecks, generated-file checks, and
fixtures
- All 163 Builder test files and 2,141 tests
- All 71 project-build test files and 1,794 tests
- Lint and package-boundary validation
- Full packed CLI release smoke test, including install, preview, agent
setup, seven-page stdio MCP workflow, rendered audit, and structured
commands
2026-07-15 18:11:28 +02:00

127 lines
3.5 KiB
TypeScript

import { atom, computed } from "nanostores";
import type { DataSource, Resource, ResourceRequest } from "@webstudio-is/sdk";
import { restResourcesLoader } from "./router-utils";
import { computeExpression } from "@webstudio-is/project-build/runtime";
import { fetch } from "./fetch.client";
import { getResourceKey } from "./resource-utils";
const MAX_PENDING_RESOURCES = 5;
export { getResourceKey };
const queue = new Map<string, ResourceRequest>();
const pending = new Map<string, ResourceRequest>();
const cache = new Map<string, unknown>();
export const $resourcesCache = atom(cache);
const updateCache = () => {
$resourcesCache.set(new Map(cache));
};
const $pendingUpdater = atom({});
const updatePending = () => {
$pendingUpdater.set({});
};
export const $hasPendingResources = computed(
$pendingUpdater,
() => queue.size > 0 || pending.size > 0
);
const loadResources = async () => {
const list = Array.from(queue.values()).slice(0, MAX_PENDING_RESOURCES);
for (const resource of list) {
const key = getResourceKey(resource);
queue.delete(key);
pending.set(key, resource);
}
updatePending();
const response = await fetch(restResourcesLoader(), {
method: "POST",
body: JSON.stringify(Array.from(pending.values())),
});
if (response.ok) {
const results = new Map<string, unknown>(await response.json());
for (const [key, result] of results) {
cache.set(key, result);
pending.delete(key);
}
}
updateCache();
updatePending();
// restart loading until queue is empty
scheduleLoading();
};
let timeoutId: undefined | number;
const scheduleLoading = () => {
// scheduling will be restarted after finishing pending one
// skip when there is nothing in queue
if (pending.size > 0 || queue.size === 0) {
return;
}
window.clearTimeout(timeoutId);
// start loading after one second of "preload" call
timeoutId = window.setTimeout(loadResources, 1000);
};
export const preloadResource = (resource: ResourceRequest) => {
const key = getResourceKey(resource);
if (queue.has(key) || pending.has(key) || cache.has(key)) {
return;
}
// deduplicate resources in queue
queue.set(key, resource);
updatePending();
scheduleLoading();
};
export const invalidateResource = (resource: ResourceRequest) => {
const key = getResourceKey(resource);
cache.delete(key);
preloadResource(resource);
};
/**
* Invalidate the assets system resource.
* Call this when assets are uploaded, deleted, or modified to refresh expressions using assets.
*/
export const invalidateAssets = () => {
const url = "/$resources/assets";
// System resources always use GET with no params/headers/body
const systemResourceRequest: ResourceRequest = {
name: "assets",
method: "get",
url,
searchParams: [],
headers: [],
};
invalidateResource(systemResourceRequest);
};
export const computeResourceRequest = (
resource: Resource,
values: Map<DataSource["id"], unknown>
): ResourceRequest => {
const request: ResourceRequest = {
name: resource.name,
method: resource.method,
url: computeExpression(resource.url, values),
searchParams: (resource.searchParams ?? []).map(({ name, value }) => ({
name,
value: computeExpression(value, values),
})),
headers: resource.headers.map(({ name, value }) => ({
name,
value: computeExpression(value, values),
})),
};
if (resource.body !== undefined) {
request.body = computeExpression(resource.body, values);
}
return request;
};