@pracht/vite-plugin 0.6.2 → 0.7.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.
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
  /**
@@ -26,7 +28,8 @@ interface EnvLeakReference {
26
28
  }
27
29
  /**
28
30
  * Scans JavaScript source for references to environment variables that are
29
- * neither public-prefixed, Vite built-ins, nor explicitly allowed.
31
+ * neither public-prefixed, Vite built-ins, nor explicitly allowed, plus reads
32
+ * that pull in the whole `import.meta.env` object.
30
33
  */
31
34
  declare function scanCodeForEnvLeaks(code: string, allow?: ReadonlySet<string>): EnvLeakReference[];
32
35
  interface EnvLeakProblem extends EnvLeakReference {
@@ -84,6 +87,23 @@ interface PrachtAdapter {
84
87
  }
85
88
  //#endregion
86
89
  //#region src/plugin-options.d.ts
90
+ type LlmsTxtSection = "pages" | "api" | "capabilities";
91
+ interface PrachtLlmsTxtOptions {
92
+ /** H1 title. Defaults to the app's package.json `name`. */
93
+ title?: string;
94
+ /**
95
+ * Blockquote summary under the title. Defaults to the app's package.json
96
+ * `description`; omitted when neither is set.
97
+ */
98
+ description?: string;
99
+ /**
100
+ * Origin (e.g. "https://example.com") prepended to every link so llms.txt
101
+ * contains absolute URLs. Links stay root-relative when omitted.
102
+ */
103
+ origin?: string;
104
+ /** Sections to emit. Defaults to ["pages", "api", "capabilities"]. */
105
+ include?: LlmsTxtSection[];
106
+ }
87
107
  interface PrachtPluginOptions {
88
108
  appFile?: string;
89
109
  routesDir?: string;
@@ -96,6 +116,11 @@ interface PrachtPluginOptions {
96
116
  * `hydration: "islands"` routes. Defaults to "/src/islands".
97
117
  */
98
118
  islandsDir?: string;
119
+ /**
120
+ * Directory containing capability modules registered in the app manifest
121
+ * via `capabilities: { ... }`. Defaults to "/src/capabilities".
122
+ */
123
+ capabilitiesDir?: string;
99
124
  adapter?: PrachtAdapter;
100
125
  /** Enable file-system pages routing by pointing to the pages directory (e.g. "/src/pages"). */
101
126
  pagesDir?: string;
@@ -125,6 +150,12 @@ interface PrachtPluginOptions {
125
150
  * variables, or `false` to disable the check entirely.
126
151
  */
127
152
  envSafety?: false | EnvSafetyOptions;
153
+ /**
154
+ * Opt into emitting an llms.txt file (https://llmstxt.org) generated from
155
+ * the resolved app graph. `pracht build` writes `dist/client/llms.txt` and
156
+ * the dev server serves `/llms.txt` live. Disabled by default.
157
+ */
158
+ llmsTxt?: false | PrachtLlmsTxtOptions;
128
159
  }
129
160
  //#endregion
130
161
  //#region src/plugin-codegen.d.ts
@@ -137,14 +168,61 @@ declare function createPrachtClientModuleSource(options?: PrachtPluginOptions, b
137
168
  * manifest, the router, or the full client runtime: it only scans the DOM
138
169
  * for island markers and hydrates the islands present on the page.
139
170
  */
140
- declare function createPrachtIslandsClientModuleSource(options?: PrachtPluginOptions): string;
171
+ declare function createPrachtIslandsClientModuleSource(options?: PrachtPluginOptions, buildOptions?: {
172
+ root?: string;
173
+ }): string;
141
174
  declare function createPrachtServerModuleSource(options?: PrachtPluginOptions, buildOptions?: {
142
175
  root?: string;
143
176
  isBuild?: boolean;
144
177
  }): string;
145
178
  declare function createPrachtRegistryModuleSource(options?: PrachtPluginOptions): string;
146
179
  //#endregion
180
+ //#region src/plugin-capabilities.d.ts
181
+ interface ExtractedCapability {
182
+ name: string;
183
+ /** Manifest-relative module path, e.g. "./capabilities/notes-search.ts". */
184
+ file: string;
185
+ description: string;
186
+ effect: string | null;
187
+ httpPath: string | null;
188
+ webmcp: boolean;
189
+ inputSchema: Record<string, unknown> | null;
190
+ }
191
+ /**
192
+ * Extract capability registrations (name → module path) from the app
193
+ * manifest source and their exposure metadata from each capability source.
194
+ * Pages-router apps have no manifest, so capabilities are manifest-mode only.
195
+ */
196
+ declare function extractCapabilities(options?: PrachtPluginOptions, root?: string): ExtractedCapability[];
197
+ /**
198
+ * Generate `virtual:pracht/capabilities` — the browser-side `callCapability`
199
+ * helper plus the endpoint map for http-exposed capabilities. Side-effect
200
+ * free, so it costs zero bytes unless application code imports it.
201
+ *
202
+ * After every call settles, the helper announces itself on
203
+ * CAPABILITY_SETTLED_EVENT with the capability's effect class; the framework
204
+ * runtime revalidates route data for successful non-`read` calls (opt out
205
+ * per call via `{ revalidate: false }`).
206
+ */
207
+ declare function createPrachtCapabilitiesClientModuleSource(options?: PrachtPluginOptions, buildOptions?: {
208
+ root?: string;
209
+ }): string;
210
+ /**
211
+ * Generate `virtual:pracht/webmcp` — the disposable WebMCP registration shim.
212
+ * One page tool per `expose.webmcp` capability; `execute` dispatches through
213
+ * `callCapability`, so the user's session authenticates the call and all
214
+ * validation/middleware/policy stays server-side. Each dispatch carries the
215
+ * transport marker header so audit events can attribute it to WebMCP.
216
+ *
217
+ * Targets the Chrome origin-trial API: `document.modelContext.registerTool()`
218
+ * (Chrome 150+; `navigator.modelContext` is the deprecated pre-150 location
219
+ * and is kept as a fallback). No-ops silently when the API is absent.
220
+ */
221
+ declare function createPrachtWebmcpModuleSource(options?: PrachtPluginOptions, buildOptions?: {
222
+ root?: string;
223
+ }): string;
224
+ //#endregion
147
225
  //#region src/index.d.ts
148
226
  declare function pracht(options?: PrachtPluginOptions): Plugin[];
149
227
  //#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 };
228
+ 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
@@ -3,9 +3,12 @@ import { createRequire } from "node:module";
3
3
  import { preactSsrPrecompile } from "@pracht/preact-ssr-precompile";
4
4
  import preact from "@preact/preset-vite";
5
5
  import { dirname, extname, join, resolve } from "node:path";
6
- import { parseAst } from "vite";
6
+ import { loadEnv, 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}`;
@@ -906,30 +909,51 @@ const VITE_BUILTIN_ENV_VARS = new Set([
906
909
  const PUBLIC_ENV_PREFIX = "PRACHT_PUBLIC_";
907
910
  /** Server-only core entry that must never resolve into client bundles. */
908
911
  const SERVER_ENV_MODULE_ID = "@pracht/core/env/server";
909
- const ENV_REFERENCE_RE = /\b(process\.env|import\.meta\.env)(?:\.([A-Za-z_$][A-Za-z0-9_$]*)|\[\s*(["'])([A-Za-z_$][A-Za-z0-9_$]*)\3\s*\])/g;
912
+ const ENV_REFERENCE_RE = /\b(process\.env|import\.meta\.env)(?:\??\.([A-Za-z_$][A-Za-z0-9_$]*)|(?:\?\.)?\[\s*(["'])([A-Za-z_$][A-Za-z0-9_$]*)\3\s*\])/g;
913
+ const WHOLE_ENV_READ_RE = /\bimport\.meta\.env\b(?!\s*\??\.\s*[A-Za-z_$])/g;
910
914
  /**
911
915
  * Scans JavaScript source for references to environment variables that are
912
- * neither public-prefixed, Vite built-ins, nor explicitly allowed.
916
+ * neither public-prefixed, Vite built-ins, nor explicitly allowed, plus reads
917
+ * that pull in the whole `import.meta.env` object.
913
918
  */
914
919
  function scanCodeForEnvLeaks(code, allow = /* @__PURE__ */ new Set()) {
915
- const findings = [];
916
- const seen = /* @__PURE__ */ new Set();
917
920
  const codePositions = getCodePositionMask(code);
921
+ const matches = [];
918
922
  for (const match of code.matchAll(ENV_REFERENCE_RE)) {
919
- if (!codePositions[match.index ?? -1]) continue;
923
+ const index = match.index ?? -1;
924
+ if (!codePositions[index]) continue;
920
925
  const accessor = match[1];
921
926
  const name = match[2] ?? match[4];
922
927
  if (!name) continue;
923
928
  if (name.startsWith("PRACHT_PUBLIC_")) continue;
924
929
  if (VITE_BUILTIN_ENV_VARS.has(name)) continue;
925
930
  if (allow.has(name)) continue;
926
- const key = `${accessor}.${name}`;
931
+ matches.push({
932
+ index,
933
+ reference: {
934
+ accessor,
935
+ name
936
+ }
937
+ });
938
+ }
939
+ if (!allow.has("*")) for (const match of code.matchAll(WHOLE_ENV_READ_RE)) {
940
+ const index = match.index ?? -1;
941
+ if (!codePositions[index]) continue;
942
+ matches.push({
943
+ index,
944
+ reference: {
945
+ accessor: "import.meta.env",
946
+ name: "*"
947
+ }
948
+ });
949
+ }
950
+ const findings = [];
951
+ const seen = /* @__PURE__ */ new Set();
952
+ for (const { reference } of matches.sort((a, b) => a.index - b.index)) {
953
+ const key = `${reference.accessor}.${reference.name}`;
927
954
  if (seen.has(key)) continue;
928
955
  seen.add(key);
929
- findings.push({
930
- accessor,
931
- name
932
- });
956
+ findings.push(reference);
933
957
  }
934
958
  return findings;
935
959
  }
@@ -1100,12 +1124,20 @@ function isIdentifierChar(char) {
1100
1124
  return !!char && /[A-Za-z0-9_$]/.test(char);
1101
1125
  }
1102
1126
  function formatEnvLeakError(problems) {
1127
+ const lines = problems.map((problem) => {
1128
+ const source = problem.sources.length > 0 ? ` (likely from ${problem.sources.map((file) => JSON.stringify(file)).join(", ")})` : "";
1129
+ return ` - ${problem.name === "*" ? "import.meta.env read as a whole object" : `${problem.accessor}.${problem.name}`} in chunk "${problem.chunk}"${source}`;
1130
+ });
1131
+ const wholeEnvGuidance = problems.some((problem) => problem.name === "*") ? [
1132
+ "",
1133
+ "A whole-object `import.meta.env` read (bare reference, destructuring, spread, or bracket access)",
1134
+ "is replaced at build time by an object literal containing every exposed variable — including the",
1135
+ "`VITE_` values Pracht does not treat as public. Read one key at a time (`import.meta.env.KEY`)."
1136
+ ] : [];
1103
1137
  return [
1104
1138
  "[pracht] Environment variable leak detected in the client bundle:",
1105
- ...problems.map((problem) => {
1106
- const source = problem.sources.length > 0 ? ` (likely from ${problem.sources.map((file) => JSON.stringify(file)).join(", ")})` : "";
1107
- return ` - ${problem.accessor}.${problem.name} in chunk "${problem.chunk}"${source}`;
1108
- }),
1139
+ ...lines,
1140
+ ...wholeEnvGuidance,
1109
1141
  "",
1110
1142
  `Only PRACHT_PUBLIC_-prefixed variables may be referenced in client code (prefer publicEnv from "@pracht/core" for typed public values).`,
1111
1143
  `Move server-only reads into loaders/API routes and access them via serverEnv from "@pracht/core/env/server",`,
@@ -1190,7 +1222,10 @@ function createEnvSafetyPlugin(envSafety) {
1190
1222
  //#region src/plugin-assets.ts
1191
1223
  const PRACHT_CLIENT_MODULE_ID = "virtual:pracht/client";
1192
1224
  const PRACHT_SERVER_MODULE_ID = "virtual:pracht/server";
1225
+ const PRACHT_DEV_MODULE_ID = "virtual:pracht/dev-metadata";
1193
1226
  const PRACHT_ISLANDS_CLIENT_MODULE_ID = "virtual:pracht/islands-client";
1227
+ const PRACHT_CAPABILITIES_MODULE_ID = "virtual:pracht/capabilities";
1228
+ const PRACHT_WEBMCP_MODULE_ID = "virtual:pracht/webmcp";
1194
1229
  const CLIENT_BROWSER_PATH = "/@pracht/client.js";
1195
1230
  const ISLANDS_CLIENT_BROWSER_PATH = "/@pracht/islands.js";
1196
1231
  function readClientBuildAssets(root = process.cwd()) {
@@ -1253,9 +1288,18 @@ function isClientModule(id) {
1253
1288
  function isServerModule(id) {
1254
1289
  return id === "virtual:pracht/server" || id.endsWith("virtual:pracht/server");
1255
1290
  }
1291
+ function isDevModule(id) {
1292
+ return id === "virtual:pracht/dev-metadata" || id.endsWith("virtual:pracht/dev-metadata");
1293
+ }
1256
1294
  function isIslandsClientModule(id) {
1257
1295
  return id === "virtual:pracht/islands-client" || id === "/@pracht/islands.js" || id.endsWith("virtual:pracht/islands-client");
1258
1296
  }
1297
+ function isCapabilitiesModule(id) {
1298
+ return id === "virtual:pracht/capabilities" || id.endsWith("virtual:pracht/capabilities");
1299
+ }
1300
+ function isWebmcpModule(id) {
1301
+ return id === "virtual:pracht/webmcp" || id.endsWith("virtual:pracht/webmcp");
1302
+ }
1259
1303
  //#endregion
1260
1304
  //#region src/plugin-adapter.ts
1261
1305
  function createDefaultNodeAdapter() {
@@ -1277,6 +1321,7 @@ const DEFAULTS = {
1277
1321
  apiDir: "/src/api",
1278
1322
  serverDir: "/src/server",
1279
1323
  islandsDir: "/src/islands",
1324
+ capabilitiesDir: "/src/capabilities",
1280
1325
  adapter: createDefaultNodeAdapter(),
1281
1326
  pagesDir: "",
1282
1327
  pagesDefaultRender: "ssr",
@@ -1284,18 +1329,33 @@ const DEFAULTS = {
1284
1329
  maxBodySize: 1024 * 1024,
1285
1330
  budgets: {},
1286
1331
  precompileSsrJsx: false,
1287
- envSafety: {}
1332
+ envSafety: {},
1333
+ llmsTxt: false
1288
1334
  };
1289
1335
  function resolveOptions(options) {
1290
1336
  const resolved = {
1291
1337
  ...DEFAULTS,
1292
1338
  ...options
1293
1339
  };
1340
+ if (resolved.llmsTxt === void 0) resolved.llmsTxt = false;
1294
1341
  if (!Number.isInteger(resolved.prerenderConcurrency) || resolved.prerenderConcurrency <= 0) throw new Error("pracht({ prerenderConcurrency }) expects a positive integer.");
1295
1342
  if (!Number.isInteger(resolved.maxBodySize) || resolved.maxBodySize <= 0) throw new Error("pracht({ maxBodySize }) expects a positive integer number of bytes.");
1296
1343
  validateBudgets(resolved.budgets);
1344
+ validateLlmsTxt(resolved.llmsTxt);
1297
1345
  return resolved;
1298
1346
  }
1347
+ const LLMS_TXT_SECTIONS = new Set([
1348
+ "pages",
1349
+ "api",
1350
+ "capabilities"
1351
+ ]);
1352
+ function validateLlmsTxt(llmsTxt) {
1353
+ if (llmsTxt === false) return;
1354
+ if (typeof llmsTxt !== "object" || llmsTxt === null) throw new Error("pracht({ llmsTxt }) expects false or an options object.");
1355
+ if (llmsTxt.include !== void 0) {
1356
+ 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)}.`);
1357
+ }
1358
+ }
1299
1359
  function validateBudgets(budgets) {
1300
1360
  for (const [key, value] of Object.entries(budgets)) {
1301
1361
  if (key !== "*" && !key.startsWith("/")) throw new Error(`pracht({ budgets }) keys must be "*" or a route path starting with "/", got ${JSON.stringify(key)}.`);
@@ -1305,6 +1365,288 @@ function validateBudgets(budgets) {
1305
1365
  }
1306
1366
  }
1307
1367
  //#endregion
1368
+ //#region src/plugin-capabilities.ts
1369
+ /**
1370
+ * Build-time capability projection for the browser.
1371
+ *
1372
+ * The client never loads capability modules (they are server-only), so the
1373
+ * `virtual:pracht/capabilities` and `virtual:pracht/webmcp` modules are
1374
+ * generated from static analysis of the app manifest and the registered
1375
+ * capability sources — the same approach the plugin already uses for
1376
+ * hydration-mode excludes. Only serializable metadata crosses the boundary:
1377
+ * capability names, HTTP endpoints, effects, and (for WebMCP tools)
1378
+ * description and input schema.
1379
+ *
1380
+ * The static analyzer itself lives in `@pracht/capabilities/static` and is
1381
+ * shared with `pracht verify`, so the build and verification can never
1382
+ * disagree about what is analyzable. Constraint it imposes: a capability's
1383
+ * `expose`, HTTP-projected `effect`, and WebMCP `input` values must be inline
1384
+ * literals (no imported constants or spreads) — the extractor parses the
1385
+ * literal text as data.
1386
+ * Extraction failures fail the build with a pointer to the offending file
1387
+ * rather than silently dropping an endpoint.
1388
+ */
1389
+ /**
1390
+ * Extract capability registrations (name → module path) from the app
1391
+ * manifest source and their exposure metadata from each capability source.
1392
+ * Pages-router apps have no manifest, so capabilities are manifest-mode only.
1393
+ */
1394
+ function extractCapabilities(options = {}, root = process.cwd()) {
1395
+ const resolved = resolveOptions(options);
1396
+ if (resolved.pagesDir) return [];
1397
+ const appFileAbs = resolve(root, resolved.appFile.replace(/^\//, ""));
1398
+ let manifestSource;
1399
+ try {
1400
+ manifestSource = readFileSync(appFileAbs, "utf-8");
1401
+ } catch {
1402
+ return [];
1403
+ }
1404
+ const registrations = extractCapabilityRegistrations(manifestSource);
1405
+ if (registrations.length === 0) return [];
1406
+ const appDir = dirname(appFileAbs);
1407
+ return registrations.map(({ name, file }) => {
1408
+ const capabilityFileAbs = file.startsWith("/") ? resolve(root, file.replace(/^\//, "")) : resolve(appDir, file);
1409
+ let source;
1410
+ try {
1411
+ source = readFileSync(capabilityFileAbs, "utf-8");
1412
+ } catch {
1413
+ throw new Error(`[pracht] Capability "${name}" references missing file ${JSON.stringify(file)}.`);
1414
+ }
1415
+ return extractCapabilityMetadata(name, file, source);
1416
+ });
1417
+ }
1418
+ function extractCapabilityMetadata(name, file, source) {
1419
+ const args = extractDefineCapabilityArgs(source);
1420
+ if (!args) throw new Error(`[pracht] Capability "${name}" (${file}) does not contain a defineCapability({ ... }) call the build can analyze.`);
1421
+ const properties = scanTopLevelProperties(args);
1422
+ const exposeText = properties.get("expose");
1423
+ if (!exposeText) return {
1424
+ name,
1425
+ file,
1426
+ description: "",
1427
+ effect: null,
1428
+ httpPath: null,
1429
+ webmcp: false,
1430
+ inputSchema: null
1431
+ };
1432
+ const expose = evaluateLiteral(exposeText);
1433
+ 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.`);
1434
+ const http = expose.http;
1435
+ let httpPath = null;
1436
+ if (http === true) httpPath = capabilityHttpPath(name);
1437
+ else if (isPlainObject(http)) httpPath = typeof http.path === "string" ? http.path : capabilityHttpPath(name);
1438
+ if (httpPath && !isValidCapabilityHttpPath(httpPath)) throw new Error(`[pracht] Capability "${name}" (${file}): HTTP exposure "path" must be an exact same-origin pathname starting with "/".`);
1439
+ const webmcp = expose.webmcp === true;
1440
+ if (webmcp && !httpPath) throw new Error(`[pracht] Capability "${name}" (${file}): expose.webmcp requires expose.http.`);
1441
+ let description = "";
1442
+ const descriptionText = properties.get("description");
1443
+ if (descriptionText) {
1444
+ const value = evaluateLiteral(descriptionText);
1445
+ if (typeof value === "string") description = value;
1446
+ }
1447
+ let effect = null;
1448
+ const effectText = properties.get("effect");
1449
+ if (effectText) {
1450
+ const value = evaluateLiteral(effectText);
1451
+ if (typeof value === "string") effect = value;
1452
+ }
1453
+ 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.`);
1454
+ let inputSchema = null;
1455
+ if (webmcp) {
1456
+ const inputText = properties.get("input");
1457
+ const value = inputText ? evaluateLiteral(inputText) : void 0;
1458
+ 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.`);
1459
+ inputSchema = value;
1460
+ }
1461
+ return {
1462
+ name,
1463
+ file,
1464
+ description,
1465
+ effect,
1466
+ httpPath,
1467
+ webmcp,
1468
+ inputSchema
1469
+ };
1470
+ }
1471
+ /**
1472
+ * Generate `virtual:pracht/capabilities` — the browser-side `callCapability`
1473
+ * helper plus the endpoint map for http-exposed capabilities. Side-effect
1474
+ * free, so it costs zero bytes unless application code imports it.
1475
+ *
1476
+ * After every call settles, the helper announces itself on
1477
+ * CAPABILITY_SETTLED_EVENT with the capability's effect class; the framework
1478
+ * runtime revalidates route data for successful non-`read` calls (opt out
1479
+ * per call via `{ revalidate: false }`).
1480
+ */
1481
+ function createPrachtCapabilitiesClientModuleSource(options = {}, buildOptions = {}) {
1482
+ const capabilities = extractCapabilities(options, buildOptions.root);
1483
+ const endpoints = {};
1484
+ for (const capability of capabilities) if (capability.httpPath) endpoints[capability.name] = {
1485
+ method: "POST",
1486
+ path: capability.httpPath,
1487
+ effect: capability.effect
1488
+ };
1489
+ return [
1490
+ "// Generated by @pracht/vite-plugin from the app manifest capability registrations.",
1491
+ "// Contains only http-exposed capability names, endpoints, and effects —",
1492
+ "// capability modules themselves are server-only and never reach the client graph.",
1493
+ `const endpoints = ${JSON.stringify(endpoints)};`,
1494
+ "",
1495
+ "export const capabilityEndpoints = endpoints;",
1496
+ "",
1497
+ "async function dispatchCapability(endpoint, input, opts) {",
1498
+ " let response;",
1499
+ " try {",
1500
+ " const headers = new Headers(opts && opts.headers);",
1501
+ " headers.set(\"content-type\", \"application/json\");",
1502
+ " if (opts && opts.confirm) {",
1503
+ ` headers.set(${JSON.stringify(CONFIRMATION_HEADER)}, opts.confirm);`,
1504
+ " }",
1505
+ " response = await fetch(endpoint.path, {",
1506
+ " method: endpoint.method,",
1507
+ " headers,",
1508
+ " body: JSON.stringify(input === undefined ? {} : input),",
1509
+ " credentials: \"same-origin\",",
1510
+ " signal: opts && opts.signal,",
1511
+ " });",
1512
+ " } catch (error) {",
1513
+ " return {",
1514
+ " ok: false,",
1515
+ " error: { code: \"network_error\", message: String((error && error.message) || error) },",
1516
+ " };",
1517
+ " }",
1518
+ " try {",
1519
+ " return await response.json();",
1520
+ " } catch {",
1521
+ " return {",
1522
+ " ok: false,",
1523
+ " error: {",
1524
+ " code: \"invalid_response\",",
1525
+ " message: `Capability endpoint returned a non-JSON response (status ${response.status}).`,",
1526
+ " },",
1527
+ " };",
1528
+ " }",
1529
+ "}",
1530
+ "",
1531
+ "export async function callCapability(name, input, opts) {",
1532
+ " const endpoint = endpoints[name];",
1533
+ " if (!endpoint) {",
1534
+ " return {",
1535
+ " ok: false,",
1536
+ " error: {",
1537
+ " code: \"unknown_capability\",",
1538
+ " message: `No HTTP-exposed capability named \"${name}\" is registered.`,",
1539
+ " },",
1540
+ " };",
1541
+ " }",
1542
+ " const result = await dispatchCapability(endpoint, input, opts);",
1543
+ " // Announce the settled call so the route runtime can revalidate after",
1544
+ " // successful non-read effects. Best-effort — never breaks the call.",
1545
+ " try {",
1546
+ " if (typeof window !== \"undefined\") {",
1547
+ ` window.dispatchEvent(new CustomEvent(${JSON.stringify(CAPABILITY_SETTLED_EVENT)}, {`,
1548
+ " detail: {",
1549
+ " name,",
1550
+ " effect: endpoint.effect,",
1551
+ " ok: result && result.ok === true,",
1552
+ " revalidate: opts && opts.revalidate === false ? false : undefined,",
1553
+ " },",
1554
+ " }));",
1555
+ " }",
1556
+ " } catch {}",
1557
+ " return result;",
1558
+ "}",
1559
+ ""
1560
+ ].join("\n");
1561
+ }
1562
+ /**
1563
+ * Generate `virtual:pracht/webmcp` — the disposable WebMCP registration shim.
1564
+ * One page tool per `expose.webmcp` capability; `execute` dispatches through
1565
+ * `callCapability`, so the user's session authenticates the call and all
1566
+ * validation/middleware/policy stays server-side. Each dispatch carries the
1567
+ * transport marker header so audit events can attribute it to WebMCP.
1568
+ *
1569
+ * Targets the Chrome origin-trial API: `document.modelContext.registerTool()`
1570
+ * (Chrome 150+; `navigator.modelContext` is the deprecated pre-150 location
1571
+ * and is kept as a fallback). No-ops silently when the API is absent.
1572
+ */
1573
+ function createPrachtWebmcpModuleSource(options = {}, buildOptions = {}) {
1574
+ const tools = extractCapabilities(options, buildOptions.root).filter((capability) => capability.webmcp).map((capability) => ({
1575
+ name: capability.name,
1576
+ description: capability.description,
1577
+ effect: capability.effect,
1578
+ inputSchema: capability.inputSchema
1579
+ }));
1580
+ return [
1581
+ "// Generated by @pracht/vite-plugin — WebMCP page-tool registration shim.",
1582
+ "import { callCapability } from \"virtual:pracht/capabilities\";",
1583
+ "",
1584
+ `const tools = ${JSON.stringify(tools)};`,
1585
+ `const transportHeaders = { ${JSON.stringify(CAPABILITY_TRANSPORT_HEADER)}: "webmcp" };`,
1586
+ "",
1587
+ "export function registerPrachtWebmcpTools() {",
1588
+ " const modelContext =",
1589
+ " (typeof document !== \"undefined\" && document.modelContext) ||",
1590
+ " (typeof navigator !== \"undefined\" && navigator.modelContext) ||",
1591
+ " null;",
1592
+ " if (!modelContext || typeof modelContext.registerTool !== \"function\") {",
1593
+ " return false;",
1594
+ " }",
1595
+ " for (const tool of tools) {",
1596
+ " try {",
1597
+ " const registration = modelContext.registerTool({",
1598
+ " name: tool.name,",
1599
+ " description: tool.description,",
1600
+ " inputSchema: tool.inputSchema,",
1601
+ " annotations: { readOnlyHint: tool.effect === \"read\" },",
1602
+ " async execute(input) {",
1603
+ " const result = await callCapability(tool.name, input, { headers: transportHeaders });",
1604
+ " return { content: [{ type: \"text\", text: JSON.stringify(result) }] };",
1605
+ " },",
1606
+ " });",
1607
+ " if (registration && typeof registration.catch === \"function\") {",
1608
+ " registration.catch(() => {});",
1609
+ " }",
1610
+ " } catch {",
1611
+ " // Origin-trial API surface may shift; a failed registration must",
1612
+ " // never break the page.",
1613
+ " }",
1614
+ " }",
1615
+ " return true;",
1616
+ "}",
1617
+ "",
1618
+ "registerPrachtWebmcpTools();",
1619
+ ""
1620
+ ].join("\n");
1621
+ }
1622
+ /**
1623
+ * Snippet appended to the client entry / islands bootstrap when at least one
1624
+ * capability opts into WebMCP. Feature-detects before importing so browsers
1625
+ * without the origin trial never pay for the shim chunk.
1626
+ */
1627
+ function createWebmcpBootstrapSource() {
1628
+ return [
1629
+ "// WebMCP page tools — loaded only when the browser exposes the API.",
1630
+ "if (",
1631
+ " typeof document !== \"undefined\" &&",
1632
+ " (document.modelContext || (typeof navigator !== \"undefined\" && navigator.modelContext))",
1633
+ ") {",
1634
+ " import(\"virtual:pracht/webmcp\").catch(() => {});",
1635
+ "}",
1636
+ ""
1637
+ ];
1638
+ }
1639
+ function hasWebmcpCapabilities(options = {}, root = process.cwd()) {
1640
+ try {
1641
+ return extractCapabilities(options, root).some((capability) => capability.webmcp);
1642
+ } catch {
1643
+ return true;
1644
+ }
1645
+ }
1646
+ function isPlainObject(value) {
1647
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1648
+ }
1649
+ //#endregion
1308
1650
  //#region src/plugin-codegen.ts
1309
1651
  const ROUTE_MODULE_EXTENSIONS = new Set([
1310
1652
  ".ts",
@@ -1500,7 +1842,8 @@ function createPrachtClientModuleSource(options = {}, buildOptions = {}) {
1500
1842
  " findModuleKey,",
1501
1843
  " });",
1502
1844
  "}",
1503
- ""
1845
+ "",
1846
+ ...hasWebmcpCapabilities(resolved, buildOptions.root) ? createWebmcpBootstrapSource() : []
1504
1847
  ].join("\n");
1505
1848
  }
1506
1849
  /**
@@ -1509,15 +1852,17 @@ function createPrachtClientModuleSource(options = {}, buildOptions = {}) {
1509
1852
  * manifest, the router, or the full client runtime: it only scans the DOM
1510
1853
  * for island markers and hydrates the islands present on the page.
1511
1854
  */
1512
- function createPrachtIslandsClientModuleSource(options = {}) {
1513
- const islandsGlob = `${resolveOptions(options).islandsDir}/**/*.{ts,tsx,js,jsx}`;
1855
+ function createPrachtIslandsClientModuleSource(options = {}, buildOptions = {}) {
1856
+ const resolved = resolveOptions(options);
1857
+ const islandsGlob = `${resolved.islandsDir}/**/*.{ts,tsx,js,jsx}`;
1514
1858
  return [
1515
1859
  "import { hydrateIslands } from \"@pracht/core/islands-client\";",
1516
1860
  "",
1517
1861
  `const islandModules = import.meta.glob(${JSON.stringify(islandsGlob)});`,
1518
1862
  "",
1519
1863
  "hydrateIslands({ modules: islandModules });",
1520
- ""
1864
+ "",
1865
+ ...hasWebmcpCapabilities(resolved, buildOptions.root) ? createWebmcpBootstrapSource() : []
1521
1866
  ].join("\n");
1522
1867
  }
1523
1868
  function createPrachtServerModuleSource(options = {}, buildOptions = {}) {
@@ -1532,7 +1877,9 @@ function createPrachtServerModuleSource(options = {}, buildOptions = {}) {
1532
1877
  jsManifest: {}
1533
1878
  };
1534
1879
  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\";";
1880
+ const llmsTxtConfig = resolveLlmsTxtConfig(resolved, buildOptions.root);
1881
+ let prachtImports = adapter?.serverImports ? adapter.serverImports + "\nimport { prerenderApp } from \"@pracht/core/server\";" : "import { resolveApp, resolveApiRoutes, prerenderApp } from \"@pracht/core/server\";";
1882
+ if (llmsTxtConfig) prachtImports += "\nimport { buildLlmsTxt } from \"@pracht/core/server\";";
1536
1883
  const appImport = isPagesMode ? generatePagesAppInlineSource(resolved, buildOptions.root) : `import { app } from ${JSON.stringify(resolved.appFile)};`;
1537
1884
  const islandsEntryUrl = buildOptions.isBuild ? clientBuild.islandsEntryUrl : ISLANDS_CLIENT_BROWSER_PATH;
1538
1885
  const islandsGlob = `${resolved.islandsDir}/**/*.{ts,tsx,js,jsx}`;
@@ -1563,11 +1910,54 @@ function createPrachtServerModuleSource(options = {}, buildOptions = {}) {
1563
1910
  `export const prerenderConcurrency = ${JSON.stringify(resolved.prerenderConcurrency)};`,
1564
1911
  `export const budgets = ${JSON.stringify(resolved.budgets)};`,
1565
1912
  "export { prerenderApp };",
1913
+ ...llmsTxtConfig ? [
1914
+ "// llms.txt (https://llmstxt.org) generated from the resolved app graph.",
1915
+ "// `pracht build` writes it to dist/client/llms.txt; the dev SSR",
1916
+ "// middleware serves it at /llms.txt.",
1917
+ `const llmsTxtConfig = ${JSON.stringify(llmsTxtConfig)};`,
1918
+ "export const generateLlmsTxt = () =>",
1919
+ " buildLlmsTxt({ ...llmsTxtConfig, apiRoutes, app: resolvedApp, registry });"
1920
+ ] : [],
1566
1921
  ""
1567
1922
  ];
1568
1923
  if (adapter) source.push(adapter.createServerEntryModule());
1569
1924
  return source.join("\n");
1570
1925
  }
1926
+ /**
1927
+ * Adapter-neutral app metadata used by development tooling. Keeping this
1928
+ * separate from the server entry avoids evaluating worker-only imports (for
1929
+ * example `cloudflare:workers`) in Vite's Node SSR environment.
1930
+ */
1931
+ function createPrachtDevModuleSource(options = {}, buildOptions = {}) {
1932
+ const resolved = resolveOptions(options);
1933
+ return [
1934
+ "import { resolveApp } from \"@pracht/core/server\";",
1935
+ resolved.pagesDir ? generatePagesAppInlineSource(resolved, buildOptions.root) : `import { app } from ${JSON.stringify(resolved.appFile)};`,
1936
+ "",
1937
+ createPrachtRegistryModuleSource(resolved),
1938
+ "",
1939
+ "export const resolvedApp = resolveApp(app);",
1940
+ ""
1941
+ ].join("\n");
1942
+ }
1943
+ /**
1944
+ * Fill llms.txt title/description from the app's package.json when the user
1945
+ * did not set them explicitly. Returns null when the feature is disabled so
1946
+ * the server module codegen stays byte-for-byte unchanged.
1947
+ */
1948
+ function resolveLlmsTxtConfig(resolved, root = process.cwd()) {
1949
+ if (!resolved.llmsTxt) return null;
1950
+ let pkg = {};
1951
+ try {
1952
+ pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf-8"));
1953
+ } catch {}
1954
+ const config = { title: resolved.llmsTxt.title ?? (typeof pkg.name === "string" && pkg.name ? pkg.name : "App") };
1955
+ const description = resolved.llmsTxt.description ?? (typeof pkg.description === "string" && pkg.description ? pkg.description : void 0);
1956
+ if (description) config.description = description;
1957
+ if (resolved.llmsTxt.origin) config.origin = resolved.llmsTxt.origin;
1958
+ if (resolved.llmsTxt.include) config.include = resolved.llmsTxt.include;
1959
+ return config;
1960
+ }
1571
1961
  function createApplyRouteLoaderHintsSource() {
1572
1962
  return [
1573
1963
  "function applyRouteLoaderHints(resolvedApp, routeLoaderHints) {",
@@ -1618,6 +2008,7 @@ function createPrachtRegistryModuleSource(options = {}) {
1618
2008
  `export const middlewareModules = import.meta.glob(${JSON.stringify(`${resolved.middlewareDir}/**/*.{ts,tsx,js,jsx}`)});`,
1619
2009
  `export const apiModules = import.meta.glob(${JSON.stringify(`${resolved.apiDir}/**/*.{ts,js,tsx,jsx}`)});`,
1620
2010
  `export const dataModules = import.meta.glob(${JSON.stringify(`${resolved.serverDir}/**/*.{ts,js,tsx,jsx}`)});`,
2011
+ `export const capabilityModules = import.meta.glob(${JSON.stringify(`${resolved.capabilitiesDir}/**/*.{ts,js,tsx,jsx}`)});`,
1621
2012
  "",
1622
2013
  "export const registry = {",
1623
2014
  " routeModules,",
@@ -1625,6 +2016,7 @@ function createPrachtRegistryModuleSource(options = {}) {
1625
2016
  " middlewareModules,",
1626
2017
  " apiModules,",
1627
2018
  " dataModules,",
2019
+ " capabilityModules,",
1628
2020
  "};"
1629
2021
  ].join("\n");
1630
2022
  }
@@ -1653,10 +2045,16 @@ function generatePagesAppInlineSource(options, root = process.cwd()) {
1653
2045
  //#region src/plugin-dev-ssr.ts
1654
2046
  const BODYLESS_METHODS = new Set(["GET", "HEAD"]);
1655
2047
  const DEFAULT_MAX_BODY_SIZE = 1024 * 1024;
2048
+ const CSS_MODULE_URL_RE = /\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/;
1656
2049
  const DEVTOOLS_JSON_PATH = "/_pracht.json";
2050
+ const LLMS_TXT_PATH = "/llms.txt";
1657
2051
  function createDevSSRMiddleware(server, options = {}) {
1658
2052
  const maxBodySize = options.maxBodySize ?? DEFAULT_MAX_BODY_SIZE;
1659
2053
  let warnedDevtoolsCollision = false;
2054
+ let warnedLlmsTxtCollision = false;
2055
+ if (options.llmsTxt && typeof server.config.publicDir === "string") {
2056
+ 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.");
2057
+ }
1660
2058
  return async (req, res, next) => {
1661
2059
  const url = req.url ?? "/";
1662
2060
  const requestUrl = new URL(url, "http://localhost");
@@ -1681,6 +2079,17 @@ function createDevSSRMiddleware(server, options = {}) {
1681
2079
  });
1682
2080
  return;
1683
2081
  }
2082
+ if (options.llmsTxt && requestUrl.pathname === "/llms.txt" && BODYLESS_METHODS.has((req.method ?? "GET").toUpperCase()) && typeof serverMod.generateLlmsTxt === "function") {
2083
+ if (!warnedLlmsTxtCollision && matchesResolvedRoute("/llms.txt", routeMatchers)) {
2084
+ warnedLlmsTxtCollision = true;
2085
+ 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.`);
2086
+ }
2087
+ const llmsTxt = await serverMod.generateLlmsTxt();
2088
+ res.statusCode = 200;
2089
+ res.setHeader("content-type", "text/plain; charset=utf-8");
2090
+ res.end(llmsTxt);
2091
+ return;
2092
+ }
1684
2093
  if (shouldBypassDevSSR(requestUrl, req, routeMatchers)) return next();
1685
2094
  if (isDevNotFoundRequest(requestUrl, req, routeMatchers)) return serveDevNotFound(server, res, next, url, requestUrl.pathname, routeMatchers);
1686
2095
  let webRequest;
@@ -1704,7 +2113,8 @@ function createDevSSRMiddleware(server, options = {}) {
1704
2113
  apiRoutes: serverMod.apiRoutes,
1705
2114
  timings
1706
2115
  });
1707
- if (response.status === 404 && !routeMatchers.app?.notFound) return next();
2116
+ const responseContentType = response.headers.get("content-type") ?? "";
2117
+ if (response.status === 404 && !responseContentType.includes("application/json") && !routeMatchers.app?.notFound) return next();
1708
2118
  const contentType = response.headers.get("content-type") ?? "text/html";
1709
2119
  let body = await response.text();
1710
2120
  if (contentType.includes("text/html")) body = await server.transformIndexHtml(url, body);
@@ -1721,15 +2131,166 @@ function createDevSSRMiddleware(server, options = {}) {
1721
2131
  };
1722
2132
  }
1723
2133
  /**
2134
+ * Build the development equivalent of the production CSS manifest for the
2135
+ * current route. Vite turns CSS imports into client-side style injection by
2136
+ * default; resolving the same imports through the active server environment
2137
+ * graphs lets pracht put real stylesheet links in the initial document and
2138
+ * avoid a first-paint FOUC.
2139
+ */
2140
+ async function createDevCssManifest(server, options) {
2141
+ const route = options.matchAppRoute(options.app, options.pathname)?.route ?? options.app.notFound;
2142
+ if (!route) return {};
2143
+ const manifest = {};
2144
+ const modules = [...route.shellFile ? [{
2145
+ file: route.shellFile,
2146
+ registry: options.registry.shellModules
2147
+ }] : [], {
2148
+ file: route.file,
2149
+ registry: options.registry.routeModules
2150
+ }];
2151
+ const results = await Promise.all(modules.map(async ({ file, registry }) => {
2152
+ if (!registry) return {
2153
+ file,
2154
+ urls: []
2155
+ };
2156
+ const moduleKey = findRegistryModuleKey(registry, file);
2157
+ if (!moduleKey) return {
2158
+ file,
2159
+ urls: []
2160
+ };
2161
+ const entries = await Promise.all(Object.values(server.environments).map((environment) => environment.moduleGraph.getModuleByUrl(moduleKey)));
2162
+ return {
2163
+ file,
2164
+ urls: [...new Set(entries.flatMap((entry) => collectDevCssUrls(entry)))]
2165
+ };
2166
+ }));
2167
+ for (const { file, urls } of results) if (urls.length > 0) manifest[file] = urls;
2168
+ return manifest;
2169
+ }
2170
+ function findRegistryModuleKey(modules, file) {
2171
+ if (!modules) return void 0;
2172
+ if (file in modules) return file;
2173
+ const suffix = `/${file.split("?")[0].replace(/\\/g, "/").replace(/^\.?\//, "")}`;
2174
+ return Object.keys(modules).find((key) => key.split("?")[0].replace(/\\/g, "/").endsWith(suffix));
2175
+ }
2176
+ function collectDevCssUrls(entry) {
2177
+ if (!entry) return [];
2178
+ const urls = /* @__PURE__ */ new Set();
2179
+ const visited = /* @__PURE__ */ new Set();
2180
+ const pending = [entry];
2181
+ while (pending.length > 0) {
2182
+ const module = pending.pop();
2183
+ if (visited.has(module)) continue;
2184
+ visited.add(module);
2185
+ if ((module.type === "css" || CSS_MODULE_URL_RE.test(module.url)) && !/[?&](?:inline|raw|url)(?:[=&]|$)/.test(module.url)) urls.add(module.url);
2186
+ pending.push(...[...module.importedModules].reverse());
2187
+ }
2188
+ return [...urls];
2189
+ }
2190
+ function injectDevCssLinks(html, manifest) {
2191
+ if (!html.includes("</head>")) return html;
2192
+ const tags = [...new Set(Object.values(manifest).flat())].map((url) => escapeHtmlAttribute(url)).filter((escapedUrl) => !html.includes(`href="${escapedUrl}"`)).map((escapedUrl) => `<link rel="stylesheet" href="${escapedUrl}">`);
2193
+ if (tags.length === 0) return html;
2194
+ return html.replace("</head>", ` ${tags.join("\n ")}\n </head>`);
2195
+ }
2196
+ async function injectDevCssForPath(server, path, html) {
2197
+ return injectDevCssLinks(html, await createDevCssManifest(server, await resolveDevCssContextForPath(server, path)));
2198
+ }
2199
+ async function resolveDevCssContextForPath(server, path) {
2200
+ const [framework, serverMod] = await Promise.all([server.ssrLoadModule("@pracht/core/server"), server.ssrLoadModule(PRACHT_DEV_MODULE_ID)]);
2201
+ const pathname = new URL(path, "http://localhost").pathname;
2202
+ return {
2203
+ app: serverMod.resolvedApp,
2204
+ matchAppRoute: framework.matchAppRoute,
2205
+ pathname,
2206
+ registry: serverMod.registry
2207
+ };
2208
+ }
2209
+ /**
2210
+ * Adapter-owned dev servers (for example Cloudflare's worker runtime) bypass
2211
+ * Vite's HTML transform hooks. Install this before the adapter middleware so
2212
+ * document responses still receive the same parser-blocking stylesheet links.
2213
+ */
2214
+ function createDevCssInjectionMiddleware(server) {
2215
+ let warned = false;
2216
+ return (req, res, next) => {
2217
+ const method = (req.method ?? "GET").toUpperCase();
2218
+ const accept = readRequestHeader(req.headers.accept).toLowerCase();
2219
+ if (method !== "GET" || !accept.includes("text/html")) {
2220
+ next();
2221
+ return;
2222
+ }
2223
+ const contextPromise = resolveDevCssContextForPath(server, req.url ?? "/").catch((error) => {
2224
+ if (!warned) {
2225
+ warned = true;
2226
+ server.config.logger.warn(`[pracht] Could not discover development stylesheets: ${error instanceof Error ? error.message : String(error)}`);
2227
+ }
2228
+ return null;
2229
+ });
2230
+ const chunks = [];
2231
+ const originalEnd = res.end.bind(res);
2232
+ const originalWriteHead = res.writeHead.bind(res);
2233
+ res.writeHead = ((statusCode, ...args) => {
2234
+ res.removeHeader("content-length");
2235
+ return Reflect.apply(originalWriteHead, res, [statusCode, ...args.map(stripContentLengthHeader)]);
2236
+ });
2237
+ res.write = ((chunk, encodingOrCallback, callback) => {
2238
+ chunks.push(toBuffer(chunk, encodingOrCallback));
2239
+ (typeof encodingOrCallback === "function" ? encodingOrCallback : typeof callback === "function" ? callback : void 0)?.();
2240
+ return true;
2241
+ });
2242
+ res.end = ((chunk, encodingOrCallback, callback) => {
2243
+ if (chunk != null) chunks.push(toBuffer(chunk, encodingOrCallback));
2244
+ const done = typeof encodingOrCallback === "function" ? encodingOrCallback : typeof callback === "function" ? callback : void 0;
2245
+ (async () => {
2246
+ const body = Buffer.concat(chunks);
2247
+ if (!String(res.getHeader("content-type") ?? "").includes("text/html")) {
2248
+ originalEnd(body, done);
2249
+ return;
2250
+ }
2251
+ try {
2252
+ const context = await contextPromise;
2253
+ const manifest = context ? await createDevCssManifest(server, context) : null;
2254
+ originalEnd(manifest ? injectDevCssLinks(body.toString("utf-8"), manifest) : body.toString("utf-8"), done);
2255
+ } catch {
2256
+ originalEnd(body, done);
2257
+ }
2258
+ })();
2259
+ return res;
2260
+ });
2261
+ next();
2262
+ };
2263
+ }
2264
+ function toBuffer(chunk, encoding) {
2265
+ if (Buffer.isBuffer(chunk)) return chunk;
2266
+ if (chunk instanceof Uint8Array) return Buffer.from(chunk);
2267
+ return Buffer.from(String(chunk), typeof encoding === "string" ? encoding : void 0);
2268
+ }
2269
+ function stripContentLengthHeader(value) {
2270
+ if (Array.isArray(value)) {
2271
+ const headers = [];
2272
+ for (let index = 0; index < value.length; index += 2) if (String(value[index]).toLowerCase() !== "content-length") headers.push(value[index], value[index + 1]);
2273
+ return headers;
2274
+ }
2275
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).filter(([name]) => name.toLowerCase() !== "content-length"));
2276
+ return value;
2277
+ }
2278
+ function escapeHtmlAttribute(value) {
2279
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2280
+ }
2281
+ /**
1724
2282
  * Serve the dev-only `/_pracht` devtools page (or `/_pracht.json`) built from
1725
2283
  * the same resolved app graph that `pracht inspect` reports.
1726
2284
  */
1727
2285
  async function serveDevtools(server, res, options) {
1728
2286
  const devtools = await server.ssrLoadModule("@pracht/core/devtools");
2287
+ const capabilityModules = (await server.ssrLoadModule(PRACHT_SERVER_MODULE_ID)).registry?.capabilityModules;
1729
2288
  const graph = await devtools.buildAppGraph({
1730
2289
  apiRoutes: options.apiRoutes,
1731
2290
  app: options.app,
1732
- loadModule: (file) => server.ssrLoadModule(file),
2291
+ loadModule: async (file) => {
2292
+ return await resolveRegistryModule(capabilityModules, file) ?? server.ssrLoadModule(file);
2293
+ },
1733
2294
  readSource: (file) => readFileSync(resolve(server.config.root, `.${file}`), "utf-8")
1734
2295
  });
1735
2296
  if (options.wantsJson) {
@@ -1933,9 +2494,12 @@ function pracht(options = {}) {
1933
2494
  const isSSRBuild = env.isSsrBuild;
1934
2495
  const configRoot = _config.root ?? process.cwd();
1935
2496
  const wantsIslandsEntry = env.command === "build" && !isSSRBuild && existsSync(resolveConfigPath(configRoot, resolved.islandsDir));
2497
+ const envDir = _config.envDir ? resolve(configRoot, _config.envDir) : configRoot;
2498
+ const publicEnvDefine = JSON.stringify(loadEnv(env.mode, envDir, PUBLIC_ENV_PREFIX));
1936
2499
  return {
1937
2500
  appType: "custom",
1938
2501
  envPrefix: ["VITE_", PUBLIC_ENV_PREFIX],
2502
+ define: { __PRACHT_PUBLIC_ENV__: publicEnvDefine },
1939
2503
  ...isSSRBuild ? {} : { build: { rollupOptions: {
1940
2504
  ...wantsIslandsEntry ? { input: [PRACHT_ISLANDS_CLIENT_MODULE_ID] } : {},
1941
2505
  output: { manualChunks(id) {
@@ -1959,17 +2523,23 @@ function pracht(options = {}) {
1959
2523
  resolveId(id, importer, resolveIdOptions) {
1960
2524
  if (isIslandsClientModule(id)) return PRACHT_ISLANDS_CLIENT_MODULE_ID;
1961
2525
  if (isClientModule(id)) return PRACHT_CLIENT_MODULE_ID;
2526
+ if (isDevModule(id)) return PRACHT_DEV_MODULE_ID;
1962
2527
  if (isServerModule(id)) return PRACHT_SERVER_MODULE_ID;
2528
+ if (isCapabilitiesModule(id)) return PRACHT_CAPABILITIES_MODULE_ID;
2529
+ if (isWebmcpModule(id)) return PRACHT_WEBMCP_MODULE_ID;
1963
2530
  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
2531
  return null;
1965
2532
  },
1966
2533
  load(id) {
1967
- if (isIslandsClientModule(id)) return createPrachtIslandsClientModuleSource(resolved);
2534
+ if (isIslandsClientModule(id)) return createPrachtIslandsClientModuleSource(resolved, { root });
1968
2535
  if (isClientModule(id)) return createPrachtClientModuleSource(resolved, { root });
2536
+ if (isDevModule(id)) return createPrachtDevModuleSource(resolved, { root });
1969
2537
  if (isServerModule(id)) return createPrachtServerModuleSource(resolved, {
1970
2538
  root,
1971
2539
  isBuild
1972
2540
  });
2541
+ if (isCapabilitiesModule(id)) return createPrachtCapabilitiesClientModuleSource(resolved, { root });
2542
+ if (isWebmcpModule(id)) return createPrachtWebmcpModuleSource(resolved, { root });
1973
2543
  return null;
1974
2544
  },
1975
2545
  transform(code, id) {
@@ -1984,11 +2554,25 @@ function pracht(options = {}) {
1984
2554
  },
1985
2555
  configureServer(server) {
1986
2556
  if (isPagesMode) watchPagesDirectory(server, resolved, root);
1987
- if (resolved.adapter.ownsDevServer) return;
2557
+ if (resolved.adapter.ownsDevServer) {
2558
+ server.middlewares.use(createDevCssInjectionMiddleware(server));
2559
+ return;
2560
+ }
1988
2561
  return () => {
1989
- server.middlewares.use(createDevSSRMiddleware(server, { maxBodySize: resolved.maxBodySize }));
2562
+ server.middlewares.use(createDevSSRMiddleware(server, {
2563
+ llmsTxt: !!resolved.llmsTxt,
2564
+ maxBodySize: resolved.maxBodySize
2565
+ }));
1990
2566
  };
1991
2567
  },
2568
+ async transformIndexHtml(html, context) {
2569
+ if (isBuild || !context.server || !html.includes("</head>")) return html;
2570
+ try {
2571
+ return await injectDevCssForPath(context.server, context.path, html);
2572
+ } catch {
2573
+ return html;
2574
+ }
2575
+ },
1992
2576
  handleHotUpdate({ file, server }) {
1993
2577
  const serverRoot = toPosixPath(server.config.root);
1994
2578
  const normalizedFile = toPosixPath(file);
@@ -2008,10 +2592,13 @@ function pracht(options = {}) {
2008
2592
  resolved.middlewareDir,
2009
2593
  resolved.apiDir,
2010
2594
  resolved.serverDir,
2011
- resolved.islandsDir
2595
+ resolved.islandsDir,
2596
+ resolved.capabilitiesDir
2012
2597
  ].some((dir) => relative.startsWith(dir))) {
2013
2598
  const serverMod = server.moduleGraph.getModuleById(PRACHT_SERVER_MODULE_ID);
2014
2599
  if (serverMod) server.moduleGraph.invalidateModule(serverMod);
2600
+ const devMod = server.moduleGraph.getModuleById(PRACHT_DEV_MODULE_ID);
2601
+ if (devMod) server.moduleGraph.invalidateModule(devMod);
2015
2602
  if (relative.startsWith(resolved.routesDir)) {
2016
2603
  const clientMod = server.moduleGraph.getModuleById(PRACHT_CLIENT_MODULE_ID);
2017
2604
  if (clientMod) server.moduleGraph.invalidateModule(clientMod);
@@ -2020,6 +2607,15 @@ function pracht(options = {}) {
2020
2607
  const islandsMod = server.moduleGraph.getModuleById(PRACHT_ISLANDS_CLIENT_MODULE_ID);
2021
2608
  if (islandsMod) server.moduleGraph.invalidateModule(islandsMod);
2022
2609
  }
2610
+ if (relative.startsWith(resolved.capabilitiesDir)) for (const moduleId of [
2611
+ PRACHT_CAPABILITIES_MODULE_ID,
2612
+ PRACHT_WEBMCP_MODULE_ID,
2613
+ PRACHT_CLIENT_MODULE_ID,
2614
+ PRACHT_ISLANDS_CLIENT_MODULE_ID
2615
+ ]) {
2616
+ const capabilityMod = server.moduleGraph.getModuleById(moduleId);
2617
+ if (capabilityMod) server.moduleGraph.invalidateModule(capabilityMod);
2618
+ }
2023
2619
  }
2024
2620
  }
2025
2621
  };
@@ -2112,7 +2708,8 @@ function createPrachtOptimizeDepsEntries(resolved) {
2112
2708
  `${toOptimizeDepsEntry(resolved.middlewareDir)}/**/*.${scriptExtensions}`,
2113
2709
  `${toOptimizeDepsEntry(resolved.apiDir)}/**/*.{ts,js,tsx,jsx}`,
2114
2710
  `${toOptimizeDepsEntry(resolved.serverDir)}/**/*.{ts,js,tsx,jsx}`,
2115
- `${toOptimizeDepsEntry(resolved.islandsDir)}/**/*.${scriptExtensions}`
2711
+ `${toOptimizeDepsEntry(resolved.islandsDir)}/**/*.${scriptExtensions}`,
2712
+ `${toOptimizeDepsEntry(resolved.capabilitiesDir)}/**/*.{ts,js,tsx,jsx}`
2116
2713
  ];
2117
2714
  return [...new Set(entries.filter(Boolean))];
2118
2715
  }
@@ -2140,8 +2737,10 @@ function watchPagesDirectory(server, resolved, root) {
2140
2737
  function invalidateVirtualModules(server) {
2141
2738
  const clientMod = server.moduleGraph.getModuleById(PRACHT_CLIENT_MODULE_ID);
2142
2739
  const serverMod = server.moduleGraph.getModuleById(PRACHT_SERVER_MODULE_ID);
2740
+ const devMod = server.moduleGraph.getModuleById(PRACHT_DEV_MODULE_ID);
2143
2741
  if (clientMod) server.moduleGraph.invalidateModule(clientMod);
2144
2742
  if (serverMod) server.moduleGraph.invalidateModule(serverMod);
2743
+ if (devMod) server.moduleGraph.invalidateModule(devMod);
2145
2744
  }
2146
2745
  const ROUTE_FILE_EXTENSIONS = new Set([
2147
2746
  ".ts",
@@ -2180,4 +2779,4 @@ function withTrailingSep(p) {
2180
2779
  return p.endsWith("/") ? p : `${p}/`;
2181
2780
  }
2182
2781
  //#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 };
2782
+ 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.1",
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,8 +44,9 @@
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",
47
+ "@pracht/adapter-node": "0.3.4",
48
+ "@pracht/core": "0.11.1",
49
+ "@pracht/capabilities": "0.1.0",
49
50
  "@pracht/preact-ssr-precompile": "0.1.2"
50
51
  },
51
52
  "peerDependencies": {