@pracht/vite-plugin 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -6,6 +6,8 @@ import { RenderMode, RenderMode as RenderMode$1 } from "@pracht/core";
6
6
  declare const PRACHT_CLIENT_MODULE_ID = "virtual:pracht/client";
7
7
  declare const PRACHT_SERVER_MODULE_ID = "virtual:pracht/server";
8
8
  declare const PRACHT_ISLANDS_CLIENT_MODULE_ID = "virtual:pracht/islands-client";
9
+ declare const PRACHT_CAPABILITIES_MODULE_ID = "virtual:pracht/capabilities";
10
+ declare const PRACHT_WEBMCP_MODULE_ID = "virtual:pracht/webmcp";
9
11
  //#endregion
10
12
  //#region src/env-safety.d.ts
11
13
  /**
@@ -84,6 +86,23 @@ interface PrachtAdapter {
84
86
  }
85
87
  //#endregion
86
88
  //#region src/plugin-options.d.ts
89
+ type LlmsTxtSection = "pages" | "api" | "capabilities";
90
+ interface PrachtLlmsTxtOptions {
91
+ /** H1 title. Defaults to the app's package.json `name`. */
92
+ title?: string;
93
+ /**
94
+ * Blockquote summary under the title. Defaults to the app's package.json
95
+ * `description`; omitted when neither is set.
96
+ */
97
+ description?: string;
98
+ /**
99
+ * Origin (e.g. "https://example.com") prepended to every link so llms.txt
100
+ * contains absolute URLs. Links stay root-relative when omitted.
101
+ */
102
+ origin?: string;
103
+ /** Sections to emit. Defaults to ["pages", "api", "capabilities"]. */
104
+ include?: LlmsTxtSection[];
105
+ }
87
106
  interface PrachtPluginOptions {
88
107
  appFile?: string;
89
108
  routesDir?: string;
@@ -96,6 +115,11 @@ interface PrachtPluginOptions {
96
115
  * `hydration: "islands"` routes. Defaults to "/src/islands".
97
116
  */
98
117
  islandsDir?: string;
118
+ /**
119
+ * Directory containing capability modules registered in the app manifest
120
+ * via `capabilities: { ... }`. Defaults to "/src/capabilities".
121
+ */
122
+ capabilitiesDir?: string;
99
123
  adapter?: PrachtAdapter;
100
124
  /** Enable file-system pages routing by pointing to the pages directory (e.g. "/src/pages"). */
101
125
  pagesDir?: string;
@@ -125,6 +149,12 @@ interface PrachtPluginOptions {
125
149
  * variables, or `false` to disable the check entirely.
126
150
  */
127
151
  envSafety?: false | EnvSafetyOptions;
152
+ /**
153
+ * Opt into emitting an llms.txt file (https://llmstxt.org) generated from
154
+ * the resolved app graph. `pracht build` writes `dist/client/llms.txt` and
155
+ * the dev server serves `/llms.txt` live. Disabled by default.
156
+ */
157
+ llmsTxt?: false | PrachtLlmsTxtOptions;
128
158
  }
129
159
  //#endregion
130
160
  //#region src/plugin-codegen.d.ts
@@ -137,14 +167,61 @@ declare function createPrachtClientModuleSource(options?: PrachtPluginOptions, b
137
167
  * manifest, the router, or the full client runtime: it only scans the DOM
138
168
  * for island markers and hydrates the islands present on the page.
139
169
  */
140
- declare function createPrachtIslandsClientModuleSource(options?: PrachtPluginOptions): string;
170
+ declare function createPrachtIslandsClientModuleSource(options?: PrachtPluginOptions, buildOptions?: {
171
+ root?: string;
172
+ }): string;
141
173
  declare function createPrachtServerModuleSource(options?: PrachtPluginOptions, buildOptions?: {
142
174
  root?: string;
143
175
  isBuild?: boolean;
144
176
  }): string;
145
177
  declare function createPrachtRegistryModuleSource(options?: PrachtPluginOptions): string;
146
178
  //#endregion
179
+ //#region src/plugin-capabilities.d.ts
180
+ interface ExtractedCapability {
181
+ name: string;
182
+ /** Manifest-relative module path, e.g. "./capabilities/notes-search.ts". */
183
+ file: string;
184
+ description: string;
185
+ effect: string | null;
186
+ httpPath: string | null;
187
+ webmcp: boolean;
188
+ inputSchema: Record<string, unknown> | null;
189
+ }
190
+ /**
191
+ * Extract capability registrations (name → module path) from the app
192
+ * manifest source and their exposure metadata from each capability source.
193
+ * Pages-router apps have no manifest, so capabilities are manifest-mode only.
194
+ */
195
+ declare function extractCapabilities(options?: PrachtPluginOptions, root?: string): ExtractedCapability[];
196
+ /**
197
+ * Generate `virtual:pracht/capabilities` — the browser-side `callCapability`
198
+ * helper plus the endpoint map for http-exposed capabilities. Side-effect
199
+ * free, so it costs zero bytes unless application code imports it.
200
+ *
201
+ * After every call settles, the helper announces itself on
202
+ * CAPABILITY_SETTLED_EVENT with the capability's effect class; the framework
203
+ * runtime revalidates route data for successful non-`read` calls (opt out
204
+ * per call via `{ revalidate: false }`).
205
+ */
206
+ declare function createPrachtCapabilitiesClientModuleSource(options?: PrachtPluginOptions, buildOptions?: {
207
+ root?: string;
208
+ }): string;
209
+ /**
210
+ * Generate `virtual:pracht/webmcp` — the disposable WebMCP registration shim.
211
+ * One page tool per `expose.webmcp` capability; `execute` dispatches through
212
+ * `callCapability`, so the user's session authenticates the call and all
213
+ * validation/middleware/policy stays server-side. Each dispatch carries the
214
+ * transport marker header so audit events can attribute it to WebMCP.
215
+ *
216
+ * Targets the Chrome origin-trial API: `document.modelContext.registerTool()`
217
+ * (Chrome 150+; `navigator.modelContext` is the deprecated pre-150 location
218
+ * and is kept as a fallback). No-ops silently when the API is absent.
219
+ */
220
+ declare function createPrachtWebmcpModuleSource(options?: PrachtPluginOptions, buildOptions?: {
221
+ root?: string;
222
+ }): string;
223
+ //#endregion
147
224
  //#region src/index.d.ts
148
225
  declare function pracht(options?: PrachtPluginOptions): Plugin[];
149
226
  //#endregion
150
- export { type EnvLeakReference, type EnvSafetyOptions, PRACHT_CLIENT_MODULE_ID, PRACHT_ISLANDS_CLIENT_MODULE_ID, PRACHT_SERVER_MODULE_ID, PUBLIC_ENV_PREFIX, type PrachtAdapter, type PrachtPluginOptions, type RenderMode, VITE_BUILTIN_ENV_VARS, createEnvSafetyPlugin, createPrachtClientModuleSource, createPrachtIslandsClientModuleSource, createPrachtRegistryModuleSource, createPrachtServerModuleSource, formatEnvLeakError, pracht, scanCodeForEnvLeaks };
227
+ export { type EnvLeakReference, type EnvSafetyOptions, type LlmsTxtSection, PRACHT_CAPABILITIES_MODULE_ID, PRACHT_CLIENT_MODULE_ID, PRACHT_ISLANDS_CLIENT_MODULE_ID, PRACHT_SERVER_MODULE_ID, PRACHT_WEBMCP_MODULE_ID, PUBLIC_ENV_PREFIX, type PrachtAdapter, type PrachtLlmsTxtOptions, type PrachtPluginOptions, type RenderMode, VITE_BUILTIN_ENV_VARS, createEnvSafetyPlugin, createPrachtCapabilitiesClientModuleSource, createPrachtClientModuleSource, createPrachtIslandsClientModuleSource, createPrachtRegistryModuleSource, createPrachtServerModuleSource, createPrachtWebmcpModuleSource, extractCapabilities, formatEnvLeakError, pracht, scanCodeForEnvLeaks };
package/dist/index.mjs CHANGED
@@ -5,7 +5,10 @@ import preact from "@preact/preset-vite";
5
5
  import { dirname, extname, join, resolve } from "node:path";
6
6
  import { parseAst } from "vite";
7
7
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
8
+ import { CAPABILITY_SETTLED_EVENT, CAPABILITY_TRANSPORT_HEADER, CONFIRMATION_HEADER, capabilityHttpPath, isValidCapabilityHttpPath } from "@pracht/capabilities";
9
+ import { evaluateLiteral, extractCapabilityRegistrations, extractDefineCapabilityArgs, scanTopLevelProperties } from "@pracht/capabilities/static";
8
10
  import { createNodeServerEntryModule } from "@pracht/adapter-node";
11
+ import { resolveRegistryModule } from "@pracht/core";
9
12
  //#region src/client-module-query.ts
10
13
  const CLIENT_MODULE_QUERY = "pracht-client";
11
14
  const PRACHT_CLIENT_MODULE_QUERY = `?${CLIENT_MODULE_QUERY}`;
@@ -1191,6 +1194,8 @@ function createEnvSafetyPlugin(envSafety) {
1191
1194
  const PRACHT_CLIENT_MODULE_ID = "virtual:pracht/client";
1192
1195
  const PRACHT_SERVER_MODULE_ID = "virtual:pracht/server";
1193
1196
  const PRACHT_ISLANDS_CLIENT_MODULE_ID = "virtual:pracht/islands-client";
1197
+ const PRACHT_CAPABILITIES_MODULE_ID = "virtual:pracht/capabilities";
1198
+ const PRACHT_WEBMCP_MODULE_ID = "virtual:pracht/webmcp";
1194
1199
  const CLIENT_BROWSER_PATH = "/@pracht/client.js";
1195
1200
  const ISLANDS_CLIENT_BROWSER_PATH = "/@pracht/islands.js";
1196
1201
  function readClientBuildAssets(root = process.cwd()) {
@@ -1256,6 +1261,12 @@ function isServerModule(id) {
1256
1261
  function isIslandsClientModule(id) {
1257
1262
  return id === "virtual:pracht/islands-client" || id === "/@pracht/islands.js" || id.endsWith("virtual:pracht/islands-client");
1258
1263
  }
1264
+ function isCapabilitiesModule(id) {
1265
+ return id === "virtual:pracht/capabilities" || id.endsWith("virtual:pracht/capabilities");
1266
+ }
1267
+ function isWebmcpModule(id) {
1268
+ return id === "virtual:pracht/webmcp" || id.endsWith("virtual:pracht/webmcp");
1269
+ }
1259
1270
  //#endregion
1260
1271
  //#region src/plugin-adapter.ts
1261
1272
  function createDefaultNodeAdapter() {
@@ -1277,6 +1288,7 @@ const DEFAULTS = {
1277
1288
  apiDir: "/src/api",
1278
1289
  serverDir: "/src/server",
1279
1290
  islandsDir: "/src/islands",
1291
+ capabilitiesDir: "/src/capabilities",
1280
1292
  adapter: createDefaultNodeAdapter(),
1281
1293
  pagesDir: "",
1282
1294
  pagesDefaultRender: "ssr",
@@ -1284,18 +1296,33 @@ const DEFAULTS = {
1284
1296
  maxBodySize: 1024 * 1024,
1285
1297
  budgets: {},
1286
1298
  precompileSsrJsx: false,
1287
- envSafety: {}
1299
+ envSafety: {},
1300
+ llmsTxt: false
1288
1301
  };
1289
1302
  function resolveOptions(options) {
1290
1303
  const resolved = {
1291
1304
  ...DEFAULTS,
1292
1305
  ...options
1293
1306
  };
1307
+ if (resolved.llmsTxt === void 0) resolved.llmsTxt = false;
1294
1308
  if (!Number.isInteger(resolved.prerenderConcurrency) || resolved.prerenderConcurrency <= 0) throw new Error("pracht({ prerenderConcurrency }) expects a positive integer.");
1295
1309
  if (!Number.isInteger(resolved.maxBodySize) || resolved.maxBodySize <= 0) throw new Error("pracht({ maxBodySize }) expects a positive integer number of bytes.");
1296
1310
  validateBudgets(resolved.budgets);
1311
+ validateLlmsTxt(resolved.llmsTxt);
1297
1312
  return resolved;
1298
1313
  }
1314
+ const LLMS_TXT_SECTIONS = new Set([
1315
+ "pages",
1316
+ "api",
1317
+ "capabilities"
1318
+ ]);
1319
+ function validateLlmsTxt(llmsTxt) {
1320
+ if (llmsTxt === false) return;
1321
+ if (typeof llmsTxt !== "object" || llmsTxt === null) throw new Error("pracht({ llmsTxt }) expects false or an options object.");
1322
+ if (llmsTxt.include !== void 0) {
1323
+ if (!(Array.isArray(llmsTxt.include) && llmsTxt.include.every((section) => LLMS_TXT_SECTIONS.has(section)))) throw new Error(`pracht({ llmsTxt: { include } }) expects an array of "pages", "api", and/or "capabilities", got ${JSON.stringify(llmsTxt.include)}.`);
1324
+ }
1325
+ }
1299
1326
  function validateBudgets(budgets) {
1300
1327
  for (const [key, value] of Object.entries(budgets)) {
1301
1328
  if (key !== "*" && !key.startsWith("/")) throw new Error(`pracht({ budgets }) keys must be "*" or a route path starting with "/", got ${JSON.stringify(key)}.`);
@@ -1305,6 +1332,288 @@ function validateBudgets(budgets) {
1305
1332
  }
1306
1333
  }
1307
1334
  //#endregion
1335
+ //#region src/plugin-capabilities.ts
1336
+ /**
1337
+ * Build-time capability projection for the browser.
1338
+ *
1339
+ * The client never loads capability modules (they are server-only), so the
1340
+ * `virtual:pracht/capabilities` and `virtual:pracht/webmcp` modules are
1341
+ * generated from static analysis of the app manifest and the registered
1342
+ * capability sources — the same approach the plugin already uses for
1343
+ * hydration-mode excludes. Only serializable metadata crosses the boundary:
1344
+ * capability names, HTTP endpoints, effects, and (for WebMCP tools)
1345
+ * description and input schema.
1346
+ *
1347
+ * The static analyzer itself lives in `@pracht/capabilities/static` and is
1348
+ * shared with `pracht verify`, so the build and verification can never
1349
+ * disagree about what is analyzable. Constraint it imposes: a capability's
1350
+ * `expose`, HTTP-projected `effect`, and WebMCP `input` values must be inline
1351
+ * literals (no imported constants or spreads) — the extractor parses the
1352
+ * literal text as data.
1353
+ * Extraction failures fail the build with a pointer to the offending file
1354
+ * rather than silently dropping an endpoint.
1355
+ */
1356
+ /**
1357
+ * Extract capability registrations (name → module path) from the app
1358
+ * manifest source and their exposure metadata from each capability source.
1359
+ * Pages-router apps have no manifest, so capabilities are manifest-mode only.
1360
+ */
1361
+ function extractCapabilities(options = {}, root = process.cwd()) {
1362
+ const resolved = resolveOptions(options);
1363
+ if (resolved.pagesDir) return [];
1364
+ const appFileAbs = resolve(root, resolved.appFile.replace(/^\//, ""));
1365
+ let manifestSource;
1366
+ try {
1367
+ manifestSource = readFileSync(appFileAbs, "utf-8");
1368
+ } catch {
1369
+ return [];
1370
+ }
1371
+ const registrations = extractCapabilityRegistrations(manifestSource);
1372
+ if (registrations.length === 0) return [];
1373
+ const appDir = dirname(appFileAbs);
1374
+ return registrations.map(({ name, file }) => {
1375
+ const capabilityFileAbs = file.startsWith("/") ? resolve(root, file.replace(/^\//, "")) : resolve(appDir, file);
1376
+ let source;
1377
+ try {
1378
+ source = readFileSync(capabilityFileAbs, "utf-8");
1379
+ } catch {
1380
+ throw new Error(`[pracht] Capability "${name}" references missing file ${JSON.stringify(file)}.`);
1381
+ }
1382
+ return extractCapabilityMetadata(name, file, source);
1383
+ });
1384
+ }
1385
+ function extractCapabilityMetadata(name, file, source) {
1386
+ const args = extractDefineCapabilityArgs(source);
1387
+ if (!args) throw new Error(`[pracht] Capability "${name}" (${file}) does not contain a defineCapability({ ... }) call the build can analyze.`);
1388
+ const properties = scanTopLevelProperties(args);
1389
+ const exposeText = properties.get("expose");
1390
+ if (!exposeText) return {
1391
+ name,
1392
+ file,
1393
+ description: "",
1394
+ effect: null,
1395
+ httpPath: null,
1396
+ webmcp: false,
1397
+ inputSchema: null
1398
+ };
1399
+ const expose = evaluateLiteral(exposeText);
1400
+ if (!isPlainObject(expose)) throw new Error(`[pracht] Capability "${name}" (${file}): "expose" must be an inline object literal so the client projection can be generated at build time.`);
1401
+ const http = expose.http;
1402
+ let httpPath = null;
1403
+ if (http === true) httpPath = capabilityHttpPath(name);
1404
+ else if (isPlainObject(http)) httpPath = typeof http.path === "string" ? http.path : capabilityHttpPath(name);
1405
+ if (httpPath && !isValidCapabilityHttpPath(httpPath)) throw new Error(`[pracht] Capability "${name}" (${file}): HTTP exposure "path" must be an exact same-origin pathname starting with "/".`);
1406
+ const webmcp = expose.webmcp === true;
1407
+ if (webmcp && !httpPath) throw new Error(`[pracht] Capability "${name}" (${file}): expose.webmcp requires expose.http.`);
1408
+ let description = "";
1409
+ const descriptionText = properties.get("description");
1410
+ if (descriptionText) {
1411
+ const value = evaluateLiteral(descriptionText);
1412
+ if (typeof value === "string") description = value;
1413
+ }
1414
+ let effect = null;
1415
+ const effectText = properties.get("effect");
1416
+ if (effectText) {
1417
+ const value = evaluateLiteral(effectText);
1418
+ if (typeof value === "string") effect = value;
1419
+ }
1420
+ if (httpPath && effect !== "read" && effect !== "write" && effect !== "destructive") throw new Error(`[pracht] Capability "${name}" (${file}) is exposed via HTTP, but its "effect" could not be extracted at build time. HTTP-exposed capabilities must declare "effect" as an inline "read", "write", or "destructive" string literal.`);
1421
+ let inputSchema = null;
1422
+ if (webmcp) {
1423
+ const inputText = properties.get("input");
1424
+ const value = inputText ? evaluateLiteral(inputText) : void 0;
1425
+ if (!isPlainObject(value)) throw new Error(`[pracht] Capability "${name}" (${file}) is exposed via WebMCP, but its "input" schema could not be extracted at build time. WebMCP-exposed capabilities must declare their input schema as an inline object literal.`);
1426
+ inputSchema = value;
1427
+ }
1428
+ return {
1429
+ name,
1430
+ file,
1431
+ description,
1432
+ effect,
1433
+ httpPath,
1434
+ webmcp,
1435
+ inputSchema
1436
+ };
1437
+ }
1438
+ /**
1439
+ * Generate `virtual:pracht/capabilities` — the browser-side `callCapability`
1440
+ * helper plus the endpoint map for http-exposed capabilities. Side-effect
1441
+ * free, so it costs zero bytes unless application code imports it.
1442
+ *
1443
+ * After every call settles, the helper announces itself on
1444
+ * CAPABILITY_SETTLED_EVENT with the capability's effect class; the framework
1445
+ * runtime revalidates route data for successful non-`read` calls (opt out
1446
+ * per call via `{ revalidate: false }`).
1447
+ */
1448
+ function createPrachtCapabilitiesClientModuleSource(options = {}, buildOptions = {}) {
1449
+ const capabilities = extractCapabilities(options, buildOptions.root);
1450
+ const endpoints = {};
1451
+ for (const capability of capabilities) if (capability.httpPath) endpoints[capability.name] = {
1452
+ method: "POST",
1453
+ path: capability.httpPath,
1454
+ effect: capability.effect
1455
+ };
1456
+ return [
1457
+ "// Generated by @pracht/vite-plugin from the app manifest capability registrations.",
1458
+ "// Contains only http-exposed capability names, endpoints, and effects —",
1459
+ "// capability modules themselves are server-only and never reach the client graph.",
1460
+ `const endpoints = ${JSON.stringify(endpoints)};`,
1461
+ "",
1462
+ "export const capabilityEndpoints = endpoints;",
1463
+ "",
1464
+ "async function dispatchCapability(endpoint, input, opts) {",
1465
+ " let response;",
1466
+ " try {",
1467
+ " const headers = new Headers(opts && opts.headers);",
1468
+ " headers.set(\"content-type\", \"application/json\");",
1469
+ " if (opts && opts.confirm) {",
1470
+ ` headers.set(${JSON.stringify(CONFIRMATION_HEADER)}, opts.confirm);`,
1471
+ " }",
1472
+ " response = await fetch(endpoint.path, {",
1473
+ " method: endpoint.method,",
1474
+ " headers,",
1475
+ " body: JSON.stringify(input === undefined ? {} : input),",
1476
+ " credentials: \"same-origin\",",
1477
+ " signal: opts && opts.signal,",
1478
+ " });",
1479
+ " } catch (error) {",
1480
+ " return {",
1481
+ " ok: false,",
1482
+ " error: { code: \"network_error\", message: String((error && error.message) || error) },",
1483
+ " };",
1484
+ " }",
1485
+ " try {",
1486
+ " return await response.json();",
1487
+ " } catch {",
1488
+ " return {",
1489
+ " ok: false,",
1490
+ " error: {",
1491
+ " code: \"invalid_response\",",
1492
+ " message: `Capability endpoint returned a non-JSON response (status ${response.status}).`,",
1493
+ " },",
1494
+ " };",
1495
+ " }",
1496
+ "}",
1497
+ "",
1498
+ "export async function callCapability(name, input, opts) {",
1499
+ " const endpoint = endpoints[name];",
1500
+ " if (!endpoint) {",
1501
+ " return {",
1502
+ " ok: false,",
1503
+ " error: {",
1504
+ " code: \"unknown_capability\",",
1505
+ " message: `No HTTP-exposed capability named \"${name}\" is registered.`,",
1506
+ " },",
1507
+ " };",
1508
+ " }",
1509
+ " const result = await dispatchCapability(endpoint, input, opts);",
1510
+ " // Announce the settled call so the route runtime can revalidate after",
1511
+ " // successful non-read effects. Best-effort — never breaks the call.",
1512
+ " try {",
1513
+ " if (typeof window !== \"undefined\") {",
1514
+ ` window.dispatchEvent(new CustomEvent(${JSON.stringify(CAPABILITY_SETTLED_EVENT)}, {`,
1515
+ " detail: {",
1516
+ " name,",
1517
+ " effect: endpoint.effect,",
1518
+ " ok: result && result.ok === true,",
1519
+ " revalidate: opts && opts.revalidate === false ? false : undefined,",
1520
+ " },",
1521
+ " }));",
1522
+ " }",
1523
+ " } catch {}",
1524
+ " return result;",
1525
+ "}",
1526
+ ""
1527
+ ].join("\n");
1528
+ }
1529
+ /**
1530
+ * Generate `virtual:pracht/webmcp` — the disposable WebMCP registration shim.
1531
+ * One page tool per `expose.webmcp` capability; `execute` dispatches through
1532
+ * `callCapability`, so the user's session authenticates the call and all
1533
+ * validation/middleware/policy stays server-side. Each dispatch carries the
1534
+ * transport marker header so audit events can attribute it to WebMCP.
1535
+ *
1536
+ * Targets the Chrome origin-trial API: `document.modelContext.registerTool()`
1537
+ * (Chrome 150+; `navigator.modelContext` is the deprecated pre-150 location
1538
+ * and is kept as a fallback). No-ops silently when the API is absent.
1539
+ */
1540
+ function createPrachtWebmcpModuleSource(options = {}, buildOptions = {}) {
1541
+ const tools = extractCapabilities(options, buildOptions.root).filter((capability) => capability.webmcp).map((capability) => ({
1542
+ name: capability.name,
1543
+ description: capability.description,
1544
+ effect: capability.effect,
1545
+ inputSchema: capability.inputSchema
1546
+ }));
1547
+ return [
1548
+ "// Generated by @pracht/vite-plugin — WebMCP page-tool registration shim.",
1549
+ "import { callCapability } from \"virtual:pracht/capabilities\";",
1550
+ "",
1551
+ `const tools = ${JSON.stringify(tools)};`,
1552
+ `const transportHeaders = { ${JSON.stringify(CAPABILITY_TRANSPORT_HEADER)}: "webmcp" };`,
1553
+ "",
1554
+ "export function registerPrachtWebmcpTools() {",
1555
+ " const modelContext =",
1556
+ " (typeof document !== \"undefined\" && document.modelContext) ||",
1557
+ " (typeof navigator !== \"undefined\" && navigator.modelContext) ||",
1558
+ " null;",
1559
+ " if (!modelContext || typeof modelContext.registerTool !== \"function\") {",
1560
+ " return false;",
1561
+ " }",
1562
+ " for (const tool of tools) {",
1563
+ " try {",
1564
+ " const registration = modelContext.registerTool({",
1565
+ " name: tool.name,",
1566
+ " description: tool.description,",
1567
+ " inputSchema: tool.inputSchema,",
1568
+ " annotations: { readOnlyHint: tool.effect === \"read\" },",
1569
+ " async execute(input) {",
1570
+ " const result = await callCapability(tool.name, input, { headers: transportHeaders });",
1571
+ " return { content: [{ type: \"text\", text: JSON.stringify(result) }] };",
1572
+ " },",
1573
+ " });",
1574
+ " if (registration && typeof registration.catch === \"function\") {",
1575
+ " registration.catch(() => {});",
1576
+ " }",
1577
+ " } catch {",
1578
+ " // Origin-trial API surface may shift; a failed registration must",
1579
+ " // never break the page.",
1580
+ " }",
1581
+ " }",
1582
+ " return true;",
1583
+ "}",
1584
+ "",
1585
+ "registerPrachtWebmcpTools();",
1586
+ ""
1587
+ ].join("\n");
1588
+ }
1589
+ /**
1590
+ * Snippet appended to the client entry / islands bootstrap when at least one
1591
+ * capability opts into WebMCP. Feature-detects before importing so browsers
1592
+ * without the origin trial never pay for the shim chunk.
1593
+ */
1594
+ function createWebmcpBootstrapSource() {
1595
+ return [
1596
+ "// WebMCP page tools — loaded only when the browser exposes the API.",
1597
+ "if (",
1598
+ " typeof document !== \"undefined\" &&",
1599
+ " (document.modelContext || (typeof navigator !== \"undefined\" && navigator.modelContext))",
1600
+ ") {",
1601
+ " import(\"virtual:pracht/webmcp\").catch(() => {});",
1602
+ "}",
1603
+ ""
1604
+ ];
1605
+ }
1606
+ function hasWebmcpCapabilities(options = {}, root = process.cwd()) {
1607
+ try {
1608
+ return extractCapabilities(options, root).some((capability) => capability.webmcp);
1609
+ } catch {
1610
+ return true;
1611
+ }
1612
+ }
1613
+ function isPlainObject(value) {
1614
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1615
+ }
1616
+ //#endregion
1308
1617
  //#region src/plugin-codegen.ts
1309
1618
  const ROUTE_MODULE_EXTENSIONS = new Set([
1310
1619
  ".ts",
@@ -1500,7 +1809,8 @@ function createPrachtClientModuleSource(options = {}, buildOptions = {}) {
1500
1809
  " findModuleKey,",
1501
1810
  " });",
1502
1811
  "}",
1503
- ""
1812
+ "",
1813
+ ...hasWebmcpCapabilities(resolved, buildOptions.root) ? createWebmcpBootstrapSource() : []
1504
1814
  ].join("\n");
1505
1815
  }
1506
1816
  /**
@@ -1509,15 +1819,17 @@ function createPrachtClientModuleSource(options = {}, buildOptions = {}) {
1509
1819
  * manifest, the router, or the full client runtime: it only scans the DOM
1510
1820
  * for island markers and hydrates the islands present on the page.
1511
1821
  */
1512
- function createPrachtIslandsClientModuleSource(options = {}) {
1513
- const islandsGlob = `${resolveOptions(options).islandsDir}/**/*.{ts,tsx,js,jsx}`;
1822
+ function createPrachtIslandsClientModuleSource(options = {}, buildOptions = {}) {
1823
+ const resolved = resolveOptions(options);
1824
+ const islandsGlob = `${resolved.islandsDir}/**/*.{ts,tsx,js,jsx}`;
1514
1825
  return [
1515
1826
  "import { hydrateIslands } from \"@pracht/core/islands-client\";",
1516
1827
  "",
1517
1828
  `const islandModules = import.meta.glob(${JSON.stringify(islandsGlob)});`,
1518
1829
  "",
1519
1830
  "hydrateIslands({ modules: islandModules });",
1520
- ""
1831
+ "",
1832
+ ...hasWebmcpCapabilities(resolved, buildOptions.root) ? createWebmcpBootstrapSource() : []
1521
1833
  ].join("\n");
1522
1834
  }
1523
1835
  function createPrachtServerModuleSource(options = {}, buildOptions = {}) {
@@ -1532,7 +1844,9 @@ function createPrachtServerModuleSource(options = {}, buildOptions = {}) {
1532
1844
  jsManifest: {}
1533
1845
  };
1534
1846
  const adapter = resolved.adapter;
1535
- const prachtImports = adapter?.serverImports ? adapter.serverImports + "\nimport { prerenderApp } from \"@pracht/core/server\";" : "import { resolveApp, resolveApiRoutes, prerenderApp } from \"@pracht/core/server\";";
1847
+ const llmsTxtConfig = resolveLlmsTxtConfig(resolved, buildOptions.root);
1848
+ let prachtImports = adapter?.serverImports ? adapter.serverImports + "\nimport { prerenderApp } from \"@pracht/core/server\";" : "import { resolveApp, resolveApiRoutes, prerenderApp } from \"@pracht/core/server\";";
1849
+ if (llmsTxtConfig) prachtImports += "\nimport { buildLlmsTxt } from \"@pracht/core/server\";";
1536
1850
  const appImport = isPagesMode ? generatePagesAppInlineSource(resolved, buildOptions.root) : `import { app } from ${JSON.stringify(resolved.appFile)};`;
1537
1851
  const islandsEntryUrl = buildOptions.isBuild ? clientBuild.islandsEntryUrl : ISLANDS_CLIENT_BROWSER_PATH;
1538
1852
  const islandsGlob = `${resolved.islandsDir}/**/*.{ts,tsx,js,jsx}`;
@@ -1563,11 +1877,37 @@ function createPrachtServerModuleSource(options = {}, buildOptions = {}) {
1563
1877
  `export const prerenderConcurrency = ${JSON.stringify(resolved.prerenderConcurrency)};`,
1564
1878
  `export const budgets = ${JSON.stringify(resolved.budgets)};`,
1565
1879
  "export { prerenderApp };",
1880
+ ...llmsTxtConfig ? [
1881
+ "// llms.txt (https://llmstxt.org) generated from the resolved app graph.",
1882
+ "// `pracht build` writes it to dist/client/llms.txt; the dev SSR",
1883
+ "// middleware serves it at /llms.txt.",
1884
+ `const llmsTxtConfig = ${JSON.stringify(llmsTxtConfig)};`,
1885
+ "export const generateLlmsTxt = () =>",
1886
+ " buildLlmsTxt({ ...llmsTxtConfig, apiRoutes, app: resolvedApp, registry });"
1887
+ ] : [],
1566
1888
  ""
1567
1889
  ];
1568
1890
  if (adapter) source.push(adapter.createServerEntryModule());
1569
1891
  return source.join("\n");
1570
1892
  }
1893
+ /**
1894
+ * Fill llms.txt title/description from the app's package.json when the user
1895
+ * did not set them explicitly. Returns null when the feature is disabled so
1896
+ * the server module codegen stays byte-for-byte unchanged.
1897
+ */
1898
+ function resolveLlmsTxtConfig(resolved, root = process.cwd()) {
1899
+ if (!resolved.llmsTxt) return null;
1900
+ let pkg = {};
1901
+ try {
1902
+ pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf-8"));
1903
+ } catch {}
1904
+ const config = { title: resolved.llmsTxt.title ?? (typeof pkg.name === "string" && pkg.name ? pkg.name : "App") };
1905
+ const description = resolved.llmsTxt.description ?? (typeof pkg.description === "string" && pkg.description ? pkg.description : void 0);
1906
+ if (description) config.description = description;
1907
+ if (resolved.llmsTxt.origin) config.origin = resolved.llmsTxt.origin;
1908
+ if (resolved.llmsTxt.include) config.include = resolved.llmsTxt.include;
1909
+ return config;
1910
+ }
1571
1911
  function createApplyRouteLoaderHintsSource() {
1572
1912
  return [
1573
1913
  "function applyRouteLoaderHints(resolvedApp, routeLoaderHints) {",
@@ -1618,6 +1958,7 @@ function createPrachtRegistryModuleSource(options = {}) {
1618
1958
  `export const middlewareModules = import.meta.glob(${JSON.stringify(`${resolved.middlewareDir}/**/*.{ts,tsx,js,jsx}`)});`,
1619
1959
  `export const apiModules = import.meta.glob(${JSON.stringify(`${resolved.apiDir}/**/*.{ts,js,tsx,jsx}`)});`,
1620
1960
  `export const dataModules = import.meta.glob(${JSON.stringify(`${resolved.serverDir}/**/*.{ts,js,tsx,jsx}`)});`,
1961
+ `export const capabilityModules = import.meta.glob(${JSON.stringify(`${resolved.capabilitiesDir}/**/*.{ts,js,tsx,jsx}`)});`,
1621
1962
  "",
1622
1963
  "export const registry = {",
1623
1964
  " routeModules,",
@@ -1625,6 +1966,7 @@ function createPrachtRegistryModuleSource(options = {}) {
1625
1966
  " middlewareModules,",
1626
1967
  " apiModules,",
1627
1968
  " dataModules,",
1969
+ " capabilityModules,",
1628
1970
  "};"
1629
1971
  ].join("\n");
1630
1972
  }
@@ -1654,9 +1996,14 @@ function generatePagesAppInlineSource(options, root = process.cwd()) {
1654
1996
  const BODYLESS_METHODS = new Set(["GET", "HEAD"]);
1655
1997
  const DEFAULT_MAX_BODY_SIZE = 1024 * 1024;
1656
1998
  const DEVTOOLS_JSON_PATH = "/_pracht.json";
1999
+ const LLMS_TXT_PATH = "/llms.txt";
1657
2000
  function createDevSSRMiddleware(server, options = {}) {
1658
2001
  const maxBodySize = options.maxBodySize ?? DEFAULT_MAX_BODY_SIZE;
1659
2002
  let warnedDevtoolsCollision = false;
2003
+ let warnedLlmsTxtCollision = false;
2004
+ if (options.llmsTxt && typeof server.config.publicDir === "string") {
2005
+ if (existsSync(join(server.config.publicDir, "llms.txt"))) server.config.logger.warn("[pracht] Both public/llms.txt and the pracht({ llmsTxt }) option are present. Dev serves the static public/llms.txt, but \"pracht build\" overwrites it with the generated content. Remove one to avoid a dev/production mismatch.");
2006
+ }
1660
2007
  return async (req, res, next) => {
1661
2008
  const url = req.url ?? "/";
1662
2009
  const requestUrl = new URL(url, "http://localhost");
@@ -1681,6 +2028,17 @@ function createDevSSRMiddleware(server, options = {}) {
1681
2028
  });
1682
2029
  return;
1683
2030
  }
2031
+ if (options.llmsTxt && requestUrl.pathname === "/llms.txt" && BODYLESS_METHODS.has((req.method ?? "GET").toUpperCase()) && typeof serverMod.generateLlmsTxt === "function") {
2032
+ if (!warnedLlmsTxtCollision && matchesResolvedRoute("/llms.txt", routeMatchers)) {
2033
+ warnedLlmsTxtCollision = true;
2034
+ server.config.logger.warn(`[pracht] An app route matches ${LLMS_TXT_PATH}, which is reserved by the pracht({ llmsTxt }) option. The generated llms.txt wins; disable the option to serve the app route instead.`);
2035
+ }
2036
+ const llmsTxt = await serverMod.generateLlmsTxt();
2037
+ res.statusCode = 200;
2038
+ res.setHeader("content-type", "text/plain; charset=utf-8");
2039
+ res.end(llmsTxt);
2040
+ return;
2041
+ }
1684
2042
  if (shouldBypassDevSSR(requestUrl, req, routeMatchers)) return next();
1685
2043
  if (isDevNotFoundRequest(requestUrl, req, routeMatchers)) return serveDevNotFound(server, res, next, url, requestUrl.pathname, routeMatchers);
1686
2044
  let webRequest;
@@ -1704,7 +2062,8 @@ function createDevSSRMiddleware(server, options = {}) {
1704
2062
  apiRoutes: serverMod.apiRoutes,
1705
2063
  timings
1706
2064
  });
1707
- if (response.status === 404 && !routeMatchers.app?.notFound) return next();
2065
+ const responseContentType = response.headers.get("content-type") ?? "";
2066
+ if (response.status === 404 && !responseContentType.includes("application/json") && !routeMatchers.app?.notFound) return next();
1708
2067
  const contentType = response.headers.get("content-type") ?? "text/html";
1709
2068
  let body = await response.text();
1710
2069
  if (contentType.includes("text/html")) body = await server.transformIndexHtml(url, body);
@@ -1726,10 +2085,13 @@ function createDevSSRMiddleware(server, options = {}) {
1726
2085
  */
1727
2086
  async function serveDevtools(server, res, options) {
1728
2087
  const devtools = await server.ssrLoadModule("@pracht/core/devtools");
2088
+ const capabilityModules = (await server.ssrLoadModule(PRACHT_SERVER_MODULE_ID)).registry?.capabilityModules;
1729
2089
  const graph = await devtools.buildAppGraph({
1730
2090
  apiRoutes: options.apiRoutes,
1731
2091
  app: options.app,
1732
- loadModule: (file) => server.ssrLoadModule(file),
2092
+ loadModule: async (file) => {
2093
+ return await resolveRegistryModule(capabilityModules, file) ?? server.ssrLoadModule(file);
2094
+ },
1733
2095
  readSource: (file) => readFileSync(resolve(server.config.root, `.${file}`), "utf-8")
1734
2096
  });
1735
2097
  if (options.wantsJson) {
@@ -1960,16 +2322,20 @@ function pracht(options = {}) {
1960
2322
  if (isIslandsClientModule(id)) return PRACHT_ISLANDS_CLIENT_MODULE_ID;
1961
2323
  if (isClientModule(id)) return PRACHT_CLIENT_MODULE_ID;
1962
2324
  if (isServerModule(id)) return PRACHT_SERVER_MODULE_ID;
2325
+ if (isCapabilitiesModule(id)) return PRACHT_CAPABILITIES_MODULE_ID;
2326
+ if (isWebmcpModule(id)) return PRACHT_WEBMCP_MODULE_ID;
1963
2327
  if (id === "@pracht/core/env/server" && !resolveIdOptions?.ssr && !resolveIdOptions?.scan) throw new Error(`[pracht] ${JSON.stringify(SERVER_ENV_MODULE_ID)} was imported by ${JSON.stringify(importer ?? "unknown module")} in client code. serverEnv is server-only — read it inside loaders, middleware, or API routes, or use publicEnv (PRACHT_PUBLIC_-prefixed variables) from "@pracht/core" instead.`);
1964
2328
  return null;
1965
2329
  },
1966
2330
  load(id) {
1967
- if (isIslandsClientModule(id)) return createPrachtIslandsClientModuleSource(resolved);
2331
+ if (isIslandsClientModule(id)) return createPrachtIslandsClientModuleSource(resolved, { root });
1968
2332
  if (isClientModule(id)) return createPrachtClientModuleSource(resolved, { root });
1969
2333
  if (isServerModule(id)) return createPrachtServerModuleSource(resolved, {
1970
2334
  root,
1971
2335
  isBuild
1972
2336
  });
2337
+ if (isCapabilitiesModule(id)) return createPrachtCapabilitiesClientModuleSource(resolved, { root });
2338
+ if (isWebmcpModule(id)) return createPrachtWebmcpModuleSource(resolved, { root });
1973
2339
  return null;
1974
2340
  },
1975
2341
  transform(code, id) {
@@ -1986,7 +2352,10 @@ function pracht(options = {}) {
1986
2352
  if (isPagesMode) watchPagesDirectory(server, resolved, root);
1987
2353
  if (resolved.adapter.ownsDevServer) return;
1988
2354
  return () => {
1989
- server.middlewares.use(createDevSSRMiddleware(server, { maxBodySize: resolved.maxBodySize }));
2355
+ server.middlewares.use(createDevSSRMiddleware(server, {
2356
+ llmsTxt: !!resolved.llmsTxt,
2357
+ maxBodySize: resolved.maxBodySize
2358
+ }));
1990
2359
  };
1991
2360
  },
1992
2361
  handleHotUpdate({ file, server }) {
@@ -2008,7 +2377,8 @@ function pracht(options = {}) {
2008
2377
  resolved.middlewareDir,
2009
2378
  resolved.apiDir,
2010
2379
  resolved.serverDir,
2011
- resolved.islandsDir
2380
+ resolved.islandsDir,
2381
+ resolved.capabilitiesDir
2012
2382
  ].some((dir) => relative.startsWith(dir))) {
2013
2383
  const serverMod = server.moduleGraph.getModuleById(PRACHT_SERVER_MODULE_ID);
2014
2384
  if (serverMod) server.moduleGraph.invalidateModule(serverMod);
@@ -2020,6 +2390,15 @@ function pracht(options = {}) {
2020
2390
  const islandsMod = server.moduleGraph.getModuleById(PRACHT_ISLANDS_CLIENT_MODULE_ID);
2021
2391
  if (islandsMod) server.moduleGraph.invalidateModule(islandsMod);
2022
2392
  }
2393
+ if (relative.startsWith(resolved.capabilitiesDir)) for (const moduleId of [
2394
+ PRACHT_CAPABILITIES_MODULE_ID,
2395
+ PRACHT_WEBMCP_MODULE_ID,
2396
+ PRACHT_CLIENT_MODULE_ID,
2397
+ PRACHT_ISLANDS_CLIENT_MODULE_ID
2398
+ ]) {
2399
+ const capabilityMod = server.moduleGraph.getModuleById(moduleId);
2400
+ if (capabilityMod) server.moduleGraph.invalidateModule(capabilityMod);
2401
+ }
2023
2402
  }
2024
2403
  }
2025
2404
  };
@@ -2112,7 +2491,8 @@ function createPrachtOptimizeDepsEntries(resolved) {
2112
2491
  `${toOptimizeDepsEntry(resolved.middlewareDir)}/**/*.${scriptExtensions}`,
2113
2492
  `${toOptimizeDepsEntry(resolved.apiDir)}/**/*.{ts,js,tsx,jsx}`,
2114
2493
  `${toOptimizeDepsEntry(resolved.serverDir)}/**/*.{ts,js,tsx,jsx}`,
2115
- `${toOptimizeDepsEntry(resolved.islandsDir)}/**/*.${scriptExtensions}`
2494
+ `${toOptimizeDepsEntry(resolved.islandsDir)}/**/*.${scriptExtensions}`,
2495
+ `${toOptimizeDepsEntry(resolved.capabilitiesDir)}/**/*.{ts,js,tsx,jsx}`
2116
2496
  ];
2117
2497
  return [...new Set(entries.filter(Boolean))];
2118
2498
  }
@@ -2180,4 +2560,4 @@ function withTrailingSep(p) {
2180
2560
  return p.endsWith("/") ? p : `${p}/`;
2181
2561
  }
2182
2562
  //#endregion
2183
- export { PRACHT_CLIENT_MODULE_ID, PRACHT_ISLANDS_CLIENT_MODULE_ID, PRACHT_SERVER_MODULE_ID, PUBLIC_ENV_PREFIX, VITE_BUILTIN_ENV_VARS, createEnvSafetyPlugin, createPrachtClientModuleSource, createPrachtIslandsClientModuleSource, createPrachtRegistryModuleSource, createPrachtServerModuleSource, formatEnvLeakError, pracht, scanCodeForEnvLeaks };
2563
+ export { PRACHT_CAPABILITIES_MODULE_ID, PRACHT_CLIENT_MODULE_ID, PRACHT_ISLANDS_CLIENT_MODULE_ID, PRACHT_SERVER_MODULE_ID, PRACHT_WEBMCP_MODULE_ID, PUBLIC_ENV_PREFIX, VITE_BUILTIN_ENV_VARS, createEnvSafetyPlugin, createPrachtCapabilitiesClientModuleSource, createPrachtClientModuleSource, createPrachtIslandsClientModuleSource, createPrachtRegistryModuleSource, createPrachtServerModuleSource, createPrachtWebmcpModuleSource, extractCapabilities, formatEnvLeakError, pracht, scanCodeForEnvLeaks };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pracht/vite-plugin",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Vite plugin for Pracht apps with virtual modules, dev SSR, prerendering, route inspection, and multi-adapter builds.",
5
5
  "keywords": [
6
6
  "pracht",
@@ -44,9 +44,10 @@
44
44
  "dependencies": {
45
45
  "@preact/preset-vite": "^2.10.5",
46
46
  "@prefresh/vite": "^2.0.0",
47
- "@pracht/adapter-node": "0.3.2",
48
- "@pracht/core": "0.10.2",
49
- "@pracht/preact-ssr-precompile": "0.1.2"
47
+ "@pracht/adapter-node": "0.3.3",
48
+ "@pracht/core": "0.11.0",
49
+ "@pracht/preact-ssr-precompile": "0.1.2",
50
+ "@pracht/capabilities": "0.1.0"
50
51
  },
51
52
  "peerDependencies": {
52
53
  "vite": "^8.0.0"