@pracht/vite-plugin 0.10.0 → 0.11.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
@@ -135,6 +135,21 @@ interface PrachtLlmsTxtOptions {
135
135
  * ```
136
136
  */
137
137
  exclude?: string[];
138
+ /**
139
+ * Ceiling on how many prerendered instances a single dynamic route
140
+ * contributes to the Pages section. Defaults to 50; `0` lists every
141
+ * instance.
142
+ *
143
+ * The instances kept are the first ones `getStaticPaths()` returns, after
144
+ * `exclude` is applied — the author's order, which for a blog is usually
145
+ * newest-first.
146
+ *
147
+ * llms.txt is an index, not a sitemap. A 5,000-post blog expanded through
148
+ * `getStaticPaths()` produces a 5,000-line, 180 KB file — larger than most
149
+ * agent context budgets. Truncation is never silent: a line above the Pages
150
+ * section names the route and the ratio it lists.
151
+ */
152
+ maxPagesPerRoute?: number;
138
153
  }
139
154
  /**
140
155
  * Optional client-router features, compiled out of the client bundle when
@@ -157,6 +172,16 @@ interface PrachtPluginOptions {
157
172
  * compiled out of the client bundle. See {@link PrachtClientOptions}.
158
173
  */
159
174
  client?: PrachtClientOptions;
175
+ /**
176
+ * Group the Preact runtime into a shared `vendor` chunk. Defaults to `true`.
177
+ *
178
+ * The group is appended to whatever `build.rollupOptions.output` chunking
179
+ * the app configures, so app-level grouping and the framework chunk compose
180
+ * (see `frameworkChunkGroups()`). Set `false` to contribute nothing at all —
181
+ * for an app that places `frameworkChunkGroups()` itself, or one that wants
182
+ * Preact merged into its own chunks.
183
+ */
184
+ vendorChunk?: boolean;
160
185
  appFile?: string;
161
186
  routesDir?: string;
162
187
  shellsDir?: string;
@@ -218,6 +243,50 @@ interface PrachtPluginOptions {
218
243
  llmsTxt?: false | PrachtLlmsTxtOptions;
219
244
  }
220
245
  //#endregion
246
+ //#region src/chunk-groups.d.ts
247
+ /**
248
+ * Pracht's client chunking policy, expressed as something an app can build on.
249
+ *
250
+ * The framework has exactly one opinion here: Preact belongs in its own chunk,
251
+ * shared by every route and cached across deploys that only change app code.
252
+ * Everything else about chunking is the app's call — merging the long tail of
253
+ * small initial chunks, splitting a heavy dependency out of a route, grouping
254
+ * by feature.
255
+ *
256
+ * Those two have to coexist, and under Rolldown that is not automatic:
257
+ * `output.codeSplitting` makes `manualChunks` and `advancedChunks` ignored
258
+ * outright, so a plugin that hard-codes one form silently deletes whichever
259
+ * form the app used. Pracht therefore looks at what the app configured and
260
+ * contributes its group in the same form, as one entry appended to the app's
261
+ * list rather than as a replacement for it.
262
+ *
263
+ * Precedence follows Rolldown's own rule — higher `priority` first, then
264
+ * declaration order. The app's groups are declared first, so an app group that
265
+ * would also capture Preact wins at equal priority, and pracht's group only
266
+ * takes what nothing else claimed. To keep the framework chunk intact while
267
+ * merging everything around it, give the app group a `test` that excludes
268
+ * Preact, or raise pracht's group by placing {@link frameworkChunkGroups}
269
+ * explicitly and setting `vendorChunk: false`.
270
+ */
271
+ /** Name of the chunk pracht groups the Preact runtime into. */
272
+ declare const FRAMEWORK_VENDOR_CHUNK = "vendor";
273
+ interface ChunkGroup {
274
+ name: string;
275
+ test?: RegExp | string;
276
+ priority?: number;
277
+ minSize?: number;
278
+ [option: string]: unknown;
279
+ }
280
+ /**
281
+ * Pracht's chunk groups, as a fresh array an app can place in its own
282
+ * `output.codeSplitting.groups`.
283
+ *
284
+ * Use this together with `pracht({ vendorChunk: false })` when the framework
285
+ * group has to sit somewhere other than last — pracht then contributes no
286
+ * chunking config of its own and the app's list is the whole policy.
287
+ */
288
+ declare function frameworkChunkGroups(): ChunkGroup[];
289
+ //#endregion
221
290
  //#region src/plugin-codegen.d.ts
222
291
  declare function createPrachtClientModuleSource(options?: PrachtPluginOptions, buildOptions?: {
223
292
  root?: string;
@@ -244,10 +313,12 @@ interface ExtractedCapability {
244
313
  name: string;
245
314
  /** Manifest-relative module path, e.g. "./capabilities/notes-search.ts". */
246
315
  file: string;
316
+ title: string;
247
317
  description: string;
248
318
  effect: string | null;
249
319
  httpPath: string | null;
250
320
  webmcp: boolean;
321
+ webmcpUntrustedContent: boolean;
251
322
  inputSchema: Record<string, unknown> | null;
252
323
  }
253
324
  /**
@@ -276,9 +347,17 @@ declare function createPrachtCapabilitiesClientModuleSource(options?: PrachtPlug
276
347
  * validation/middleware/policy stays server-side. Each dispatch carries the
277
348
  * transport marker header so audit events can attribute it to WebMCP.
278
349
  *
279
- * Targets the Chrome origin-trial API: `document.modelContext.registerTool()`
280
- * (Chrome 150+; `navigator.modelContext` is the deprecated pre-150 location
281
- * and is kept as a fallback). No-ops silently when the API is absent.
350
+ * Targets the WebMCP CG draft API: `document.modelContext.registerTool()`
351
+ * (ChatGPT desktop's built-in browser; Chromium 150+ within the 149–156
352
+ * origin trial the `document` getter landed in 150 and the deprecated
353
+ * `navigator.modelContext` alias was removed in 152, so trial builds before
354
+ * 150 are not targeted and no fallback is kept; current polyfills install the
355
+ * `document` shape). No-ops silently when the API is absent.
356
+ *
357
+ * `execute()` returns the capability envelope (`{ ok, data }` /
358
+ * `{ ok: false, error }`) as a plain object: per the spec the host serializes
359
+ * the returned value itself, so wrapping it in MCP-style content blocks would
360
+ * reach the agent double-encoded.
282
361
  */
283
362
  declare function createPrachtWebmcpModuleSource(options?: PrachtPluginOptions, buildOptions?: {
284
363
  root?: string;
@@ -287,4 +366,4 @@ declare function createPrachtWebmcpModuleSource(options?: PrachtPluginOptions, b
287
366
  //#region src/index.d.ts
288
367
  declare function pracht(options?: PrachtPluginOptions): Plugin[];
289
368
  //#endregion
290
- 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 PrachtClientOptions, type PrachtLlmsTxtOptions, type PrachtPluginOptions, type RenderMode, VITE_BUILTIN_ENV_VARS, createEnvSafetyPlugin, createPrachtCapabilitiesClientModuleSource, createPrachtClientModuleSource, createPrachtIslandsClientModuleSource, createPrachtRegistryModuleSource, createPrachtServerModuleSource, createPrachtWebmcpModuleSource, extractCapabilities, formatEnvLeakError, pracht, scanCodeForEnvLeaks };
369
+ export { type ChunkGroup, type EnvLeakReference, type EnvSafetyOptions, FRAMEWORK_VENDOR_CHUNK, 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 PrachtClientOptions, type PrachtLlmsTxtOptions, type PrachtPluginOptions, type RenderMode, VITE_BUILTIN_ENV_VARS, createEnvSafetyPlugin, createPrachtCapabilitiesClientModuleSource, createPrachtClientModuleSource, createPrachtIslandsClientModuleSource, createPrachtRegistryModuleSource, createPrachtServerModuleSource, createPrachtWebmcpModuleSource, extractCapabilities, formatEnvLeakError, frameworkChunkGroups, pracht, scanCodeForEnvLeaks };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { c as createRouteStaticPathsHints, d as extensionGlob, f as normalizeAdditionalExtensions, i as scanPagesDirectory, l as DEFAULT_ROUTE_EXTENSIONS, n as generatePagesManifestSource, o as createRouteHeadHints, p as withAdditionalExtensions, s as createRouteLoaderHints, u as LEGACY_BARE_ROUTE_EXTENSIONS } from "./pages-router-KcCRkhnf.mjs";
1
+ import { c as createRouteLoaderHints, d as LEGACY_BARE_ROUTE_EXTENSIONS, f as extensionGlob, i as scanPagesDirectory, l as createRouteStaticPathsHints, m as withAdditionalExtensions, n as generatePagesManifestSource, o as createRouteHeadHints, p as normalizeAdditionalExtensions, s as createRouteHeadersHints, u as DEFAULT_ROUTE_EXTENSIONS } from "./pages-router-MA9rOl88.mjs";
2
2
  import { createRequire, isBuiltin } from "node:module";
3
3
  import { preactSsrPrecompile } from "@pracht/preact-ssr-precompile";
4
4
  import preact from "@preact/preset-vite";
@@ -6,6 +6,7 @@ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "n
6
6
  import { dirname, extname, join, resolve } from "node:path";
7
7
  import { loadEnv, parseAst } from "vite";
8
8
  import { PRACHT_GRAPH_ONLY_ENV } from "@pracht/core/server";
9
+ import { DEV_ROUTE_DATA_STALE_EVENT } from "@pracht/core/client";
9
10
  import { CAPABILITY_SETTLED_EVENT, CAPABILITY_TRANSPORT_HEADER, CONFIRMATION_HEADER } from "@pracht/capabilities";
10
11
  import { extractCapabilityProjection, extractCapabilityRegistrations, extractDefineAppObjectBody, scanTopLevelProperties } from "@pracht/capabilities/static";
11
12
  import { createNodeServerEntryModule } from "@pracht/adapter-node";
@@ -26,6 +27,47 @@ function stripPrachtClientModuleQuery(id) {
26
27
  const query = id.slice(queryStart + 1).split("&").filter((part) => part !== CLIENT_MODULE_QUERY);
27
28
  return query.length > 0 ? `${path}?${query.join("&")}` : path;
28
29
  }
30
+ /** Extensions `@prefresh/vite` accepts: `/\.(c|m)?(t|j)sx?$/`, anchored at end. */
31
+ const PREFRESH_EXTENSION_RE = /\.((?:c|m)?[tj]sx?)$/i;
32
+ function isPrefreshCompatibleId(id) {
33
+ return PREFRESH_EXTENSION_RE.test(id);
34
+ }
35
+ /**
36
+ * The id to hand `@prefresh/vite` for a pracht client module.
37
+ *
38
+ * Prefresh uses the id for exactly three things: its `/\.(c|m)?(t|j)sx?$/`
39
+ * filter, a `/\.tsx?$/` check that picks the TypeScript parser plugin, and the
40
+ * key it embeds in the `$RefreshReg$` it injects. A query-carrying id fails the
41
+ * first two, which is why route and shell modules got no Fast Refresh at all —
42
+ * but simply stripping the query fails the third: one file under `src/routes`
43
+ * can reach the browser as *two* module instances, once through the route glob
44
+ * as `…/x.tsx?pracht-client` and once as a plain import from a sibling route.
45
+ * Both would then register under the same key, and `@prefresh/core` treats a
46
+ * second `register()` for a known key with a different function object as a
47
+ * pending component replacement — which the next unrelated Fast Refresh
48
+ * flushes, tearing down and re-running the untouched copy's effects.
49
+ *
50
+ * A reserved, length-prefixed namespace keeps the real extension last, so the
51
+ * filter and parser check still pass, while giving each complete module id its
52
+ * own registration key. Keeping the authored id verbatim makes the mapping
53
+ * injective; keeping it behind a non-file prefix prevents a real sibling such
54
+ * as `x.pracht-client.tsx` from colliding with the synthetic key. The id is
55
+ * never resolved against the filesystem; the JSX dev transform has already
56
+ * stamped `_jsxFileName` from the real id by the time prefresh runs, so dev
57
+ * source locations and open-in-editor are unaffected.
58
+ *
59
+ * Compiled formats whose real extension prefresh rejects (`.md`, `.mdx`, and
60
+ * configured additional formats) instead keep that extension in the basename
61
+ * and receive a synthetic `.jsx`. Their companion Vite plugin has already
62
+ * turned the authored format into JavaScript by the time this id is used.
63
+ */
64
+ function toPrachtClientPrefreshId(id) {
65
+ const stripped = stripPrachtClientModuleQuery(id);
66
+ const queryStart = stripped.indexOf("?");
67
+ const path = queryStart === -1 ? stripped : stripped.slice(0, queryStart);
68
+ const parserExtension = PREFRESH_EXTENSION_RE.exec(path)?.[1] ?? "jsx";
69
+ return `pracht-client:${id.length}:${id}.${parserExtension}`;
70
+ }
29
71
  function getRolldownLang(id) {
30
72
  const path = stripPrachtClientModuleQuery(id).split("?")[0];
31
73
  if (/\.(c|m)?tsx$/i.test(path)) return "tsx";
@@ -892,6 +934,87 @@ function enqueueDependencies(target, dependencies) {
892
934
  for (const name of dependencies) target.add(name);
893
935
  }
894
936
  //#endregion
937
+ //#region src/chunk-groups.ts
938
+ /**
939
+ * Pracht's client chunking policy, expressed as something an app can build on.
940
+ *
941
+ * The framework has exactly one opinion here: Preact belongs in its own chunk,
942
+ * shared by every route and cached across deploys that only change app code.
943
+ * Everything else about chunking is the app's call — merging the long tail of
944
+ * small initial chunks, splitting a heavy dependency out of a route, grouping
945
+ * by feature.
946
+ *
947
+ * Those two have to coexist, and under Rolldown that is not automatic:
948
+ * `output.codeSplitting` makes `manualChunks` and `advancedChunks` ignored
949
+ * outright, so a plugin that hard-codes one form silently deletes whichever
950
+ * form the app used. Pracht therefore looks at what the app configured and
951
+ * contributes its group in the same form, as one entry appended to the app's
952
+ * list rather than as a replacement for it.
953
+ *
954
+ * Precedence follows Rolldown's own rule — higher `priority` first, then
955
+ * declaration order. The app's groups are declared first, so an app group that
956
+ * would also capture Preact wins at equal priority, and pracht's group only
957
+ * takes what nothing else claimed. To keep the framework chunk intact while
958
+ * merging everything around it, give the app group a `test` that excludes
959
+ * Preact, or raise pracht's group by placing {@link frameworkChunkGroups}
960
+ * explicitly and setting `vendorChunk: false`.
961
+ */
962
+ /**
963
+ * Modules that make up the framework runtime's vendor chunk.
964
+ *
965
+ * `[\\/]` rather than `/` so the group matches on Windows, and no trailing
966
+ * boundary so the Preact family — `preact/hooks`, `preact-suspense`,
967
+ * `preact-render-to-string` — lands in one chunk with Preact itself.
968
+ */
969
+ const FRAMEWORK_VENDOR_TEST = /node_modules[\\/]preact/;
970
+ /** Name of the chunk pracht groups the Preact runtime into. */
971
+ const FRAMEWORK_VENDOR_CHUNK = "vendor";
972
+ /**
973
+ * Pracht's chunk groups, as a fresh array an app can place in its own
974
+ * `output.codeSplitting.groups`.
975
+ *
976
+ * Use this together with `pracht({ vendorChunk: false })` when the framework
977
+ * group has to sit somewhere other than last — pracht then contributes no
978
+ * chunking config of its own and the app's list is the whole policy.
979
+ */
980
+ function frameworkChunkGroups() {
981
+ return [{
982
+ name: FRAMEWORK_VENDOR_CHUNK,
983
+ test: FRAMEWORK_VENDOR_TEST
984
+ }];
985
+ }
986
+ /** Whether a module id belongs in the framework vendor chunk. */
987
+ function isFrameworkVendorModule(id) {
988
+ return FRAMEWORK_VENDOR_TEST.test(id);
989
+ }
990
+ /**
991
+ * Build the chunking config pracht contributes, given what the app configured.
992
+ *
993
+ * Returns a partial `output` because Vite merges a plugin's `config()` result
994
+ * over the user config and concatenates arrays: returning only pracht's group
995
+ * is what appends it to the app's list instead of replacing it.
996
+ */
997
+ function frameworkChunkConfig(output) {
998
+ if (Array.isArray(output)) return { warning: "build.rollupOptions.output is an array, so pracht did not add its Preact vendor chunk group. Add frameworkChunkGroups() from @pracht/vite-plugin to each output's codeSplitting.groups to keep the framework chunk." };
999
+ const options = output ?? {};
1000
+ if (options.codeSplitting === false) return {};
1001
+ const groups = frameworkChunkGroups();
1002
+ if (options.codeSplitting === void 0) {
1003
+ if (isRecord(options.advancedChunks)) return { output: { advancedChunks: { groups } } };
1004
+ if (typeof options.manualChunks === "function") {
1005
+ const appManualChunks = options.manualChunks;
1006
+ return { output: { manualChunks(id, meta) {
1007
+ if (isFrameworkVendorModule(id)) return FRAMEWORK_VENDOR_CHUNK;
1008
+ return appManualChunks(id, meta);
1009
+ } } };
1010
+ }
1011
+ }
1012
+ return { output: { codeSplitting: { groups } } };
1013
+ }
1014
+ function isRecord(value) {
1015
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1016
+ }
1017
+ //#endregion
895
1018
  //#region src/env-safety.ts
896
1019
  /**
897
1020
  * Env vars Vite defines on `import.meta.env` in every bundle, plus NODE_ENV
@@ -1220,6 +1343,116 @@ function createEnvSafetyPlugin(envSafety) {
1220
1343
  };
1221
1344
  }
1222
1345
  //#endregion
1346
+ //#region src/client-module-prefresh.ts
1347
+ /**
1348
+ * Give route and shell modules Preact Fast Refresh.
1349
+ *
1350
+ * `@prefresh/vite` gates its transform on `/\.(c|m)?(t|j)sx?$/`, a pattern
1351
+ * anchored at the end of the id — so an id carrying a query never matches.
1352
+ * Pracht loads route and shell modules in the browser through
1353
+ * `import.meta.glob(..., { query: "?pracht-client" })` so its post transform
1354
+ * can strip server-only exports, which means the module the browser actually
1355
+ * runs is `/src/routes/home.tsx?pracht-client`. Prefresh skipped it, no
1356
+ * `import.meta.hot.accept` was injected, and with no self-accepting boundary
1357
+ * the update propagated to the non-accepting virtual client entry: every edit
1358
+ * to a route or a shell became a full page reload with client state loss.
1359
+ * Components outside those directories were unaffected, which is why this hid
1360
+ * for so long — Fast Refresh worked everywhere except the files a route-based
1361
+ * framework is mostly made of.
1362
+ *
1363
+ * Running after `pracht:client-module-transform` (both are `post`; array order
1364
+ * decides) is deliberate: prefresh sees the stripped module, whose exports are
1365
+ * only components, rather than the authored one where a co-located `loader`
1366
+ * would stop it self-accepting anyway.
1367
+ *
1368
+ * The id prefresh is handed is synthetic — see `toPrachtClientPrefreshId`. It
1369
+ * must satisfy prefresh's extension filter *and* stay distinct from the id of
1370
+ * the authored file, because the same file can be in the client graph twice and
1371
+ * the id doubles as prefresh's component registration key.
1372
+ */
1373
+ function createClientModulePrefreshPlugin(preactPlugins, config = {}) {
1374
+ const transform = resolvePrefreshTransform(preactPlugins);
1375
+ if (!transform) return null;
1376
+ return {
1377
+ name: "pracht:client-module-prefresh",
1378
+ enforce: "post",
1379
+ apply: "serve",
1380
+ async transform(code, id, transformOptions) {
1381
+ if (transformOptions?.ssr) return null;
1382
+ const carriesClientQuery = isPrachtClientModuleId(id);
1383
+ const isBareCompiledFormat = !carriesClientQuery && !isPrefreshCompatibleId(id) && config.isRouteOrShellModule?.(id) === true;
1384
+ if (!carriesClientQuery && !isBareCompiledFormat) return null;
1385
+ return await transform.call(this, code, toPrachtClientPrefreshId(id), transformOptions);
1386
+ }
1387
+ };
1388
+ }
1389
+ /**
1390
+ * `@preact/preset-vite` returns a plugin array whose shape is its own business;
1391
+ * find prefresh by name rather than by position, and treat its absence as "no
1392
+ * Fast Refresh configured" rather than an error.
1393
+ */
1394
+ function resolvePrefreshTransform(preactPlugins) {
1395
+ for (const plugin of flattenPlugins(preactPlugins)) {
1396
+ if (plugin.name !== "prefresh") continue;
1397
+ const transform = plugin.transform;
1398
+ if (typeof transform === "function") return transform;
1399
+ if (transform && typeof transform === "object" && "handler" in transform) return transform.handler;
1400
+ }
1401
+ return null;
1402
+ }
1403
+ function flattenPlugins(plugins) {
1404
+ const flat = [];
1405
+ const visit = (option) => {
1406
+ if (!option || typeof option.then === "function") return;
1407
+ if (Array.isArray(option)) {
1408
+ for (const nested of option) visit(nested);
1409
+ return;
1410
+ }
1411
+ if (typeof option === "object" && "name" in option) flat.push(option);
1412
+ };
1413
+ for (const plugin of plugins) visit(plugin);
1414
+ return flat;
1415
+ }
1416
+ //#endregion
1417
+ //#region src/head-hint-reload.ts
1418
+ /**
1419
+ * Which changed files reach routes with generated client hints.
1420
+ *
1421
+ * Head and response-header hints decide whether a dependency edit must reload
1422
+ * the document; loader hints decide whether it must re-fetch active route data.
1423
+ * The importer walk is shared because all three are keyed by route source.
1424
+ */
1425
+ function toPosixPath$2(path) {
1426
+ return path.replace(/\\/g, "/");
1427
+ }
1428
+ function reachesRouteHintedModule(modules, serverRoot, routeHints, options = {}) {
1429
+ const pending = options.startAtImporters ? modules.flatMap((module) => [...module.importers ?? []]) : [...modules];
1430
+ const seen = /* @__PURE__ */ new Set();
1431
+ while (pending.length > 0) {
1432
+ const module = pending.pop();
1433
+ if (!module || seen.has(module)) continue;
1434
+ seen.add(module);
1435
+ const modulePath = module.file ?? module.id?.split("?", 1)[0];
1436
+ if (modulePath) {
1437
+ const normalizedPath = toPosixPath$2(modulePath);
1438
+ if (routeHints[normalizedPath.startsWith(serverRoot) ? normalizedPath.slice(serverRoot.length) : normalizedPath] === true) return true;
1439
+ }
1440
+ if (module.importers) pending.push(...module.importers);
1441
+ }
1442
+ return false;
1443
+ }
1444
+ //#endregion
1445
+ //#region src/route-data-stale.ts
1446
+ function sendRouteDataStale(server) {
1447
+ const hot = server.environments?.client?.hot;
1448
+ if (!hot) return false;
1449
+ hot.send({
1450
+ type: "custom",
1451
+ event: DEV_ROUTE_DATA_STALE_EVENT
1452
+ });
1453
+ return true;
1454
+ }
1455
+ //#endregion
1223
1456
  //#region src/hot-update-reload.ts
1224
1457
  /**
1225
1458
  * True when `file` participates in server rendering but has no runtime
@@ -1354,6 +1587,7 @@ function createDefaultNodeAdapter() {
1354
1587
  const CLIENT_FEATURE_DEFAULTS = { prefetch: true };
1355
1588
  const DEFAULTS = {
1356
1589
  client: CLIENT_FEATURE_DEFAULTS,
1590
+ vendorChunk: true,
1357
1591
  appFile: "/src/routes.ts",
1358
1592
  middlewareDir: "/src/middleware",
1359
1593
  routesDir: "/src/routes",
@@ -1380,6 +1614,7 @@ function resolveOptions(options) {
1380
1614
  };
1381
1615
  if (resolved.llmsTxt === void 0) resolved.llmsTxt = false;
1382
1616
  resolved.client = resolveClientOptions(options.client);
1617
+ if (typeof resolved.vendorChunk !== "boolean") throw new Error(`pracht({ vendorChunk }) expects a boolean, got ${JSON.stringify(resolved.vendorChunk)}.`);
1383
1618
  resolved.additionalExtensions = normalizeAdditionalExtensions(resolved.additionalExtensions);
1384
1619
  if (!new Set([
1385
1620
  "spa",
@@ -1418,6 +1653,10 @@ function validateLlmsTxt(llmsTxt) {
1418
1653
  if (llmsTxt.include !== void 0) {
1419
1654
  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)}.`);
1420
1655
  }
1656
+ if (llmsTxt.maxPagesPerRoute !== void 0) {
1657
+ const value = llmsTxt.maxPagesPerRoute;
1658
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`pracht({ llmsTxt: { maxPagesPerRoute } }) expects a non-negative integer (0 lists every page), got ${JSON.stringify(value)}.`);
1659
+ }
1421
1660
  }
1422
1661
  function validateBudgets(budgets) {
1423
1662
  for (const [key, value] of Object.entries(budgets)) {
@@ -1740,16 +1979,30 @@ function createPrachtCapabilitiesClientModuleSource(options = {}, buildOptions =
1740
1979
  * validation/middleware/policy stays server-side. Each dispatch carries the
1741
1980
  * transport marker header so audit events can attribute it to WebMCP.
1742
1981
  *
1743
- * Targets the Chrome origin-trial API: `document.modelContext.registerTool()`
1744
- * (Chrome 150+; `navigator.modelContext` is the deprecated pre-150 location
1745
- * and is kept as a fallback). No-ops silently when the API is absent.
1982
+ * Targets the WebMCP CG draft API: `document.modelContext.registerTool()`
1983
+ * (ChatGPT desktop's built-in browser; Chromium 150+ within the 149–156
1984
+ * origin trial the `document` getter landed in 150 and the deprecated
1985
+ * `navigator.modelContext` alias was removed in 152, so trial builds before
1986
+ * 150 are not targeted and no fallback is kept; current polyfills install the
1987
+ * `document` shape). No-ops silently when the API is absent.
1988
+ *
1989
+ * `execute()` returns the capability envelope (`{ ok, data }` /
1990
+ * `{ ok: false, error }`) as a plain object: per the spec the host serializes
1991
+ * the returned value itself, so wrapping it in MCP-style content blocks would
1992
+ * reach the agent double-encoded.
1746
1993
  */
1747
1994
  function createPrachtWebmcpModuleSource(options = {}, buildOptions = {}) {
1748
1995
  const tools = extractCapabilities(options, buildOptions.root).filter((capability) => capability.webmcp).map((capability) => ({
1749
1996
  name: capability.name,
1997
+ ...capability.title ? { title: capability.title } : {},
1750
1998
  description: capability.description,
1751
- effect: capability.effect,
1752
- inputSchema: capability.inputSchema
1999
+ inputSchema: capability.inputSchema,
2000
+ annotations: {
2001
+ readOnlyHint: capability.effect === "read",
2002
+ ...capability.effect === "read" ? { destructiveHint: false } : {},
2003
+ idempotentHint: capability.effect === "read",
2004
+ ...capability.webmcpUntrustedContent ? { untrustedContentHint: true } : {}
2005
+ }
1753
2006
  }));
1754
2007
  return [
1755
2008
  "// Generated by @pracht/vite-plugin — WebMCP page-tool registration shim.",
@@ -1760,30 +2013,27 @@ function createPrachtWebmcpModuleSource(options = {}, buildOptions = {}) {
1760
2013
  "",
1761
2014
  "export function registerPrachtWebmcpTools() {",
1762
2015
  " const modelContext =",
1763
- " (typeof document !== \"undefined\" && document.modelContext) ||",
1764
- " (typeof navigator !== \"undefined\" && navigator.modelContext) ||",
1765
- " null;",
2016
+ " (typeof document !== \"undefined\" && document.modelContext) || null;",
1766
2017
  " if (!modelContext || typeof modelContext.registerTool !== \"function\") {",
1767
2018
  " return false;",
1768
2019
  " }",
1769
2020
  " for (const tool of tools) {",
1770
2021
  " try {",
1771
2022
  " const registration = modelContext.registerTool({",
1772
- " name: tool.name,",
1773
- " description: tool.description,",
1774
- " inputSchema: tool.inputSchema,",
1775
- " annotations: { readOnlyHint: tool.effect === \"read\" },",
1776
- " async execute(input) {",
1777
- " const result = await callCapability(tool.name, input, { headers: transportHeaders });",
1778
- " return { content: [{ type: \"text\", text: JSON.stringify(result) }] };",
2023
+ " ...tool,",
2024
+ " async execute(input, { signal } = {}) {",
2025
+ " return callCapability(tool.name, input, {",
2026
+ " headers: transportHeaders,",
2027
+ " signal,",
2028
+ " });",
1779
2029
  " },",
1780
2030
  " });",
1781
2031
  " if (registration && typeof registration.catch === \"function\") {",
1782
2032
  " registration.catch(() => {});",
1783
2033
  " }",
1784
2034
  " } catch {",
1785
- " // Origin-trial API surface may shift; a failed registration must",
1786
- " // never break the page.",
2035
+ " // The API is still an origin-trial surface; a failed registration",
2036
+ " // must never break the page.",
1787
2037
  " }",
1788
2038
  " }",
1789
2039
  " return true;",
@@ -1801,10 +2051,7 @@ function createPrachtWebmcpModuleSource(options = {}, buildOptions = {}) {
1801
2051
  function createWebmcpBootstrapSource() {
1802
2052
  return [
1803
2053
  "// WebMCP page tools — loaded only when the browser exposes the API.",
1804
- "if (",
1805
- " typeof document !== \"undefined\" &&",
1806
- " (document.modelContext || (typeof navigator !== \"undefined\" && navigator.modelContext))",
1807
- ") {",
2054
+ "if (typeof document !== \"undefined\" && document.modelContext) {",
1808
2055
  " import(\"virtual:pracht/webmcp\").catch(() => {});",
1809
2056
  "}",
1810
2057
  ""
@@ -1925,7 +2172,7 @@ function createPrachtClientModuleSource(options = {}, buildOptions = {}) {
1925
2172
  const appFilePosix = resolved.appFile.replace(/\\/g, "/").replace(/^\.\//, "");
1926
2173
  const appDir = (appFilePosix.startsWith("/") ? appFilePosix : `/${appFilePosix}`).replace(/\/[^/]*$/, "") || "/";
1927
2174
  return [
1928
- "import { resolveApp, initClientRouter, readHydrationState } from \"@pracht/core/client\";",
2175
+ "import { resolveApp, initClientRouter, readHydrationState, DEV_ROUTE_DATA_STALE_EVENT, refreshDevRouteData } from \"@pracht/core/client\";",
1929
2176
  appImport,
1930
2177
  "",
1931
2178
  `const routeLoaderHints = ${JSON.stringify(routeLoaderHints)};`,
@@ -2010,6 +2257,18 @@ function createPrachtClientModuleSource(options = {}, buildOptions = {}) {
2010
2257
  " });",
2011
2258
  "}",
2012
2259
  "",
2260
+ "// A route module's loader, head, and getStaticPaths are stripped",
2261
+ "// out of the browser copy, so Fast Refresh patching the component in place",
2262
+ "// leaves the page holding data the server would no longer send. The dev",
2263
+ "// server says when that happened; re-fetching route state is what the full",
2264
+ "// page reload used to deliver as a side effect. This entry is the only",
2265
+ "// module in the graph with an import.meta.hot of its own — an installed",
2266
+ "// @pracht/core is a pre-bundled dependency and has none. Production builds",
2267
+ "// replace import.meta.hot with undefined and drop the whole branch.",
2268
+ "if (import.meta.hot) {",
2269
+ " import.meta.hot.on(DEV_ROUTE_DATA_STALE_EVENT, refreshDevRouteData);",
2270
+ "}",
2271
+ "",
2013
2272
  ...hasWebmcpCapabilities(resolved, buildOptions.root) ? createWebmcpBootstrapSource() : []
2014
2273
  ].join("\n");
2015
2274
  }
@@ -2148,6 +2407,7 @@ function resolveLlmsTxtConfig(resolved, root = process.cwd()) {
2148
2407
  if (resolved.llmsTxt.origin) config.origin = resolved.llmsTxt.origin;
2149
2408
  if (resolved.llmsTxt.include) config.include = resolved.llmsTxt.include;
2150
2409
  if (resolved.llmsTxt.exclude?.length) config.exclude = resolved.llmsTxt.exclude;
2410
+ if (resolved.llmsTxt.maxPagesPerRoute !== void 0) config.maxPagesPerRoute = resolved.llmsTxt.maxPagesPerRoute;
2151
2411
  return config;
2152
2412
  }
2153
2413
  function createApplyRouteLoaderHintsSource() {
@@ -2189,6 +2449,19 @@ function createRouteHeadHintsForVirtualModules(options, root = process.cwd()) {
2189
2449
  rootRelativePrefix: prefix
2190
2450
  })));
2191
2451
  }
2452
+ function createRouteHeadersHintsForVirtualModules(options, root = process.cwd()) {
2453
+ if (options.pagesDir) return createRouteHeadersHints(resolve(root, options.pagesDir.slice(1)), {
2454
+ additionalExtensions: options.additionalExtensions,
2455
+ rootRelativePrefix: options.pagesDir
2456
+ });
2457
+ const appFileDir = dirname(resolve(root, options.appFile.slice(1)));
2458
+ const directories = [[options.routesDir, resolve(root, options.routesDir.slice(1))], [options.shellsDir, resolve(root, options.shellsDir.slice(1))]];
2459
+ return Object.assign({}, ...directories.map(([prefix, directory]) => createRouteHeadersHints(directory, {
2460
+ additionalExtensions: options.additionalExtensions,
2461
+ appFileDir,
2462
+ rootRelativePrefix: prefix
2463
+ })));
2464
+ }
2192
2465
  /**
2193
2466
  * `getStaticPaths()` presence per route file. Only routes matter — a shell
2194
2467
  * cannot enumerate paths — so unlike the head hints this skips the shells
@@ -2220,6 +2493,19 @@ function createRouteLoaderHintsForVirtualModules(options, root = process.cwd())
2220
2493
  rootRelativePrefix: options.routesDir
2221
2494
  });
2222
2495
  }
2496
+ /**
2497
+ * Server data modules that can own a separately wired route loader.
2498
+ *
2499
+ * These hints stay on the dev-server side: unlike the per-route table above,
2500
+ * the generated browser entry has no use for data-module filenames. The HMR
2501
+ * importer walk does, though — a shared module can be client-reachable through
2502
+ * a component and server-reachable through `route(..., { loader })`, so it
2503
+ * cannot rely on the server-only full-reload fallback.
2504
+ */
2505
+ function createServerLoaderHintsForHotUpdates(options, root = process.cwd()) {
2506
+ const hints = createRouteLoaderHints(resolve(root, options.serverDir.slice(1)), { rootRelativePrefix: options.serverDir });
2507
+ return Object.fromEntries(Object.entries(hints).filter((entry) => entry[1] === true));
2508
+ }
2223
2509
  function createPrachtRegistryModuleSource(options = {}) {
2224
2510
  const resolved = resolveOptions(options);
2225
2511
  const apiGlobs = [`${resolved.apiDir}/**/*.{ts,js,tsx,jsx}`, `!${resolved.apiDir}/**/*.d.ts`];
@@ -2276,6 +2562,38 @@ function generatePagesAppInlineSource(options, root = process.cwd()) {
2276
2562
  pagesAppSourceCache.set(cacheKey, source);
2277
2563
  return source;
2278
2564
  }
2565
+ function createAgentTrafficBuffer(limit = 200) {
2566
+ const capacity = Math.max(1, Math.floor(limit));
2567
+ const events = [];
2568
+ let recorded = 0;
2569
+ return {
2570
+ record(event) {
2571
+ recorded += 1;
2572
+ events.push({
2573
+ at: Date.now(),
2574
+ capability: event.capability,
2575
+ effect: event.effect,
2576
+ transport: event.transport,
2577
+ via: event.via,
2578
+ outcome: event.outcome,
2579
+ status: event.status,
2580
+ durationMs: event.durationMs,
2581
+ agent: event.agent ? {
2582
+ agentDomain: event.agent.agentDomain,
2583
+ keyId: event.agent.keyId
2584
+ } : null
2585
+ });
2586
+ while (events.length > capacity) events.shift();
2587
+ },
2588
+ snapshot() {
2589
+ return {
2590
+ limit: capacity,
2591
+ recorded,
2592
+ events: [...events].reverse()
2593
+ };
2594
+ }
2595
+ };
2596
+ }
2279
2597
  //#endregion
2280
2598
  //#region src/plugin-dev-ssr.ts
2281
2599
  const BODYLESS_METHODS = new Set(["GET", "HEAD"]);
@@ -2325,6 +2643,7 @@ function createDevSSRMiddleware(server, options = {}) {
2325
2643
  const withDevBase = (path) => devBase === "/" || !path.startsWith("/") ? path : `${devBase}${path.slice(1)}`;
2326
2644
  let warnedDevtoolsCollision = false;
2327
2645
  let warnedLlmsTxtCollision = false;
2646
+ const agentTraffic = createAgentTrafficBuffer();
2328
2647
  if (options.llmsTxt && typeof server.config.publicDir === "string") {
2329
2648
  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.");
2330
2649
  }
@@ -2345,6 +2664,7 @@ function createDevSSRMiddleware(server, options = {}) {
2345
2664
  server.config.logger.warn(`[pracht] An app route matches ${requestUrl.pathname}, which is reserved for the pracht devtools page in dev. The devtools page wins during development; the app route is only served in production builds.`);
2346
2665
  }
2347
2666
  await serveDevtools(server, res, {
2667
+ agentTraffic,
2348
2668
  apiRoutes: serverMod.apiRoutes ?? [],
2349
2669
  app: serverMod.resolvedApp,
2350
2670
  base: devBase,
@@ -2381,16 +2701,25 @@ function createDevSSRMiddleware(server, options = {}) {
2381
2701
  throw err;
2382
2702
  }
2383
2703
  const timings = {};
2704
+ let routeError;
2705
+ let capturedRouteError = false;
2706
+ let routeErrorContext;
2384
2707
  const response = await framework.handlePrachtRequest({
2385
2708
  app: serverMod.resolvedApp,
2386
2709
  registry: serverMod.registry,
2387
2710
  request: webRequest,
2388
2711
  debugErrors: true,
2712
+ onRouteError: (error, _requestPath, context) => {
2713
+ capturedRouteError = true;
2714
+ routeError = error;
2715
+ routeErrorContext = context;
2716
+ },
2389
2717
  clientEntryUrl: withDevBase(CLIENT_BROWSER_PATH),
2390
2718
  islandsEntryUrl: withDevBase(ISLANDS_CLIENT_BROWSER_PATH),
2391
2719
  islandsBootstrapRequired: serverMod.islandsBootstrapRequired === true,
2392
2720
  apiRoutes: serverMod.apiRoutes,
2393
- timings
2721
+ timings,
2722
+ onCapabilityAudit: agentTraffic.record
2394
2723
  });
2395
2724
  const responseContentType = response.headers.get("content-type") ?? "";
2396
2725
  if (response.status === 404 && !responseContentType.includes("application/json") && !routeMatchers.app?.notFound) return next();
@@ -2414,6 +2743,17 @@ function createDevSSRMiddleware(server, options = {}) {
2414
2743
  source.pipe(res);
2415
2744
  return;
2416
2745
  }
2746
+ if (shouldRenderDevErrorOverlay({
2747
+ capturedRouteError,
2748
+ contentType,
2749
+ exposeServerErrors: shouldExposeDevServerErrors(),
2750
+ hasErrorBoundary: routeErrorContext?.errorBoundary != null,
2751
+ status: response.status
2752
+ })) {
2753
+ const serverTiming = framework.formatServerTimingHeader(timings);
2754
+ await respondWithErrorOverlay(server, res, url, routeError, routeErrorContext, devBase, response.status, serverTiming);
2755
+ return;
2756
+ }
2417
2757
  let body = await response.text();
2418
2758
  if (contentType.includes("text/html")) body = await transformDevHtml(server, url, body, devBase);
2419
2759
  res.statusCode = response.status;
@@ -2632,27 +2972,100 @@ function escapeHtmlAttribute(value) {
2632
2972
  */
2633
2973
  async function serveDevtools(server, res, options) {
2634
2974
  const devtools = await server.ssrLoadModule("@pracht/core/devtools");
2635
- const capabilityModules = (await server.ssrLoadModule(PRACHT_SERVER_MODULE_ID)).registry?.capabilityModules;
2975
+ const serverModule = await server.ssrLoadModule(PRACHT_SERVER_MODULE_ID);
2976
+ const capabilityModules = serverModule.registry?.capabilityModules;
2977
+ const middlewareModules = serverModule.registry?.middlewareModules;
2636
2978
  const graph = await devtools.buildAppGraph({
2637
2979
  apiRoutes: options.apiRoutes,
2638
2980
  app: options.app,
2639
2981
  loadModule: async (file) => {
2640
2982
  return await resolveRegistryModule(capabilityModules, file) ?? server.ssrLoadModule(file);
2641
2983
  },
2984
+ loadSetupModule: async (file) => {
2985
+ return await resolveRegistryModule(middlewareModules, file) ?? server.ssrLoadModule(file);
2986
+ },
2987
+ verifyMcpTokenVerifier: async () => {
2988
+ const auth = options.app.agents?.mcp?.auth;
2989
+ if (!auth) return;
2990
+ await (await server.ssrLoadModule("@pracht/core/server")).loadMcpTokenVerifier(auth, serverModule.registry ?? {});
2991
+ },
2642
2992
  readSource: (file) => readFileSync(resolve(server.config.root, `.${file}`), "utf-8")
2643
2993
  });
2994
+ const agentTraffic = options.agentTraffic.snapshot();
2644
2995
  if (options.wantsJson) {
2645
2996
  res.statusCode = 200;
2646
2997
  res.setHeader("content-type", "application/json; charset=utf-8");
2647
- res.end(JSON.stringify(graph, null, 2));
2998
+ res.end(JSON.stringify({
2999
+ ...graph,
3000
+ agentTraffic
3001
+ }, null, 2));
2648
3002
  return;
2649
3003
  }
2650
- let html = devtools.buildDevtoolsHtml(graph, { base: options.base });
3004
+ let html = devtools.buildDevtoolsHtml(graph, {
3005
+ agentTraffic,
3006
+ base: options.base
3007
+ });
2651
3008
  html = await server.transformIndexHtml(options.url, html);
2652
3009
  res.statusCode = 200;
2653
3010
  res.setHeader("content-type", "text/html; charset=utf-8");
2654
3011
  res.end(html);
2655
3012
  }
3013
+ /**
3014
+ * True when a dev response should be replaced by the error overlay.
3015
+ *
3016
+ * The runtime only falls back to `text/plain` for a page render when neither
3017
+ * the route nor its shell declares an ErrorBoundary. When one does, the
3018
+ * response is the app's own error UI (`text/html`) and dev must leave it
3019
+ * alone. Route-state and capability failures are JSON and belong to the
3020
+ * client router, not to a human reading a document.
3021
+ */
3022
+ function shouldRenderDevErrorOverlay(options) {
3023
+ return options.capturedRouteError && options.exposeServerErrors && !options.hasErrorBoundary && options.status >= 500 && options.contentType.toLowerCase().startsWith("text/plain");
3024
+ }
3025
+ /**
3026
+ * The dev middleware passes `debugErrors: true` unconditionally, but the
3027
+ * runtime refuses to honor it when `NODE_ENV === "production"` (see
3028
+ * `shouldExposeServerErrors` in @pracht/core) — a dev server started inside a
3029
+ * container that exports `NODE_ENV=production` must not answer with internals.
3030
+ * The overlay is built from the raw error rather than from the runtime's
3031
+ * already-redacted body, so it has to repeat that check.
3032
+ */
3033
+ function shouldExposeDevServerErrors() {
3034
+ return (typeof process !== "undefined" ? process.env?.NODE_ENV : void 0) !== "production";
3035
+ }
3036
+ /**
3037
+ * Render a failed page render as the dev error overlay.
3038
+ *
3039
+ * `handlePrachtRequest` answers a render/loader/middleware failure with the
3040
+ * runtime's plain-text fallback whenever no ErrorBoundary claims it. In a
3041
+ * production adapter that is correct — a browser is not the audience. In dev
3042
+ * the browser *is* the audience, and the fallback is at its worst exactly when
3043
+ * it matters most: a compiler diagnostic arrives colourized for a terminal, so
3044
+ * `text/plain` renders every escape sequence literally.
3045
+ */
3046
+ async function respondWithErrorOverlay(server, res, url, error, context, base, status, serverTiming) {
3047
+ if (error instanceof Error) server.ssrFixStacktrace(error);
3048
+ const { buildErrorOverlayHtml } = await server.ssrLoadModule("@pracht/core/error-overlay");
3049
+ let html = buildErrorOverlayHtml({
3050
+ message: error instanceof Error ? error.message : String(error),
3051
+ stack: error instanceof Error ? error.stack : void 0,
3052
+ routeId: context?.routeId,
3053
+ file: context?.routeFile,
3054
+ loaderFile: context?.loaderFile,
3055
+ shellFile: context?.shellFile,
3056
+ phase: context?.phase,
3057
+ root: server.config.root,
3058
+ base
3059
+ });
3060
+ html = await server.transformIndexHtml(url, html);
3061
+ res.statusCode = status;
3062
+ res.setHeader("content-type", "text/html; charset=utf-8");
3063
+ applyDefaultSecurityHeaders(new Headers()).forEach((value, key) => {
3064
+ res.setHeader(key, value);
3065
+ });
3066
+ if (serverTiming) res.setHeader("Server-Timing", serverTiming);
3067
+ res.end(html);
3068
+ }
2656
3069
  async function handleDevError(server, req, res, next, url, error, base) {
2657
3070
  if (error instanceof Error) server.ssrFixStacktrace(error);
2658
3071
  if (req.headers["x-pracht-route-state-request"] === "1") {
@@ -2832,28 +3245,15 @@ async function nodeToWebRequest(req, maxBodySize, base = "/") {
2832
3245
  }
2833
3246
  //#endregion
2834
3247
  //#region src/index.ts
2835
- function reachesHeadBearingModule(modules, serverRoot, headHints) {
2836
- const pending = [...modules];
2837
- const seen = /* @__PURE__ */ new Set();
2838
- while (pending.length > 0) {
2839
- const module = pending.pop();
2840
- if (!module || seen.has(module)) continue;
2841
- seen.add(module);
2842
- const modulePath = module.file ?? module.id?.split("?", 1)[0];
2843
- if (modulePath) {
2844
- const normalizedPath = toPosixPath(modulePath);
2845
- if (headHints[normalizedPath.startsWith(serverRoot) ? normalizedPath.slice(serverRoot.length) : normalizedPath] === true) return true;
2846
- }
2847
- if (module.importers) pending.push(...module.importers);
2848
- }
2849
- return false;
2850
- }
2851
3248
  function pracht(options = {}) {
2852
3249
  const resolved = resolveOptions(options);
2853
3250
  const isPagesMode = !!resolved.pagesDir;
2854
3251
  let root = process.cwd();
2855
3252
  let routeFileDirs = [];
2856
3253
  let clientRouteHeadHints = {};
3254
+ let clientRouteHeadersHints = {};
3255
+ let clientRouteLoaderHints = {};
3256
+ let serverRouteLoaderHints = {};
2857
3257
  const routeFileExtensions = withAdditionalExtensions(DEFAULT_ROUTE_EXTENSIONS, resolved.additionalExtensions);
2858
3258
  let capabilityModulePaths = /* @__PURE__ */ new Set();
2859
3259
  if (isPagesMode && options.appFile) console.warn("[pracht] Both `pagesDir` and `appFile` are set. `pagesDir` takes precedence — `appFile` will be ignored.");
@@ -2863,6 +3263,7 @@ function pracht(options = {}) {
2863
3263
  const prachtPlugin = {
2864
3264
  name: "pracht",
2865
3265
  enforce: "pre",
3266
+ api: { llmsTxtEnabled: Boolean(resolved.llmsTxt) },
2866
3267
  config(_config, env) {
2867
3268
  const isEdge = resolved.adapter.edge === true;
2868
3269
  const isSSRBuild = env.isSsrBuild;
@@ -2873,6 +3274,8 @@ function pracht(options = {}) {
2873
3274
  const agentSurfaceDefine = env.command === "build" ? String(hasAgentSurface(resolved, configRoot)) : "true";
2874
3275
  const staticTargetDefine = String(env.command === "build" && resolved.adapter.staticTarget === true);
2875
3276
  const clientFeatureDefines = { __PRACHT_CLIENT_PREFETCH__: String(resolved.client.prefetch) };
3277
+ const clientChunkConfig = isSSRBuild || !resolved.vendorChunk ? {} : frameworkChunkConfig(_config.build?.rollupOptions?.output);
3278
+ if (clientChunkConfig.warning) console.warn(`[pracht] ${clientChunkConfig.warning}`);
2876
3279
  return {
2877
3280
  appType: "custom",
2878
3281
  envPrefix: ["VITE_", PUBLIC_ENV_PREFIX],
@@ -2885,9 +3288,7 @@ function pracht(options = {}) {
2885
3288
  },
2886
3289
  ...isSSRBuild ? {} : { build: { rollupOptions: {
2887
3290
  ...wantsIslandsEntry ? { input: [PRACHT_ISLANDS_CLIENT_MODULE_ID] } : {},
2888
- output: { manualChunks(id) {
2889
- if (id.includes("node_modules/preact") || id.includes("node_modules/preact-suspense")) return "vendor";
2890
- } }
3291
+ ...clientChunkConfig.output ? { output: clientChunkConfig.output } : {}
2891
3292
  } } },
2892
3293
  ...isEdge && isSSRBuild ? {
2893
3294
  ssr: {
@@ -2930,6 +3331,9 @@ function pracht(options = {}) {
2930
3331
  if (isIslandsClientModule(id)) return createPrachtIslandsClientModuleSource(resolved, { root });
2931
3332
  if (isClientModule(id)) {
2932
3333
  clientRouteHeadHints = createRouteHeadHintsForVirtualModules(resolved, root);
3334
+ clientRouteHeadersHints = createRouteHeadersHintsForVirtualModules(resolved, root);
3335
+ clientRouteLoaderHints = createRouteLoaderHintsForVirtualModules(resolved, root);
3336
+ serverRouteLoaderHints = createServerLoaderHintsForHotUpdates(resolved, root);
2933
3337
  return createPrachtClientModuleSource(resolved, { root });
2934
3338
  }
2935
3339
  if (isDevModule(id)) return createPrachtDevModuleSource(resolved, {
@@ -2983,24 +3387,58 @@ function pracht(options = {}) {
2983
3387
  const normalizedFile = toPosixPath(file);
2984
3388
  const relative = normalizedFile.startsWith(serverRoot) ? normalizedFile.slice(serverRoot.length) : normalizedFile;
2985
3389
  const changesRouteHeadSource = isPagesMode ? relative.startsWith(resolved.pagesDir) : relative.startsWith(resolved.routesDir) || relative.startsWith(resolved.shellsDir);
2986
- const changesRouteHeadDependency = reachesHeadBearingModule(modules, serverRoot, clientRouteHeadHints);
2987
- let shouldReloadClientHead = changesRouteHeadDependency;
3390
+ const changesRouteLoaderSource = isPagesMode ? relative.startsWith(resolved.pagesDir) : relative.startsWith(resolved.routesDir);
3391
+ const previousServerRouteLoaderHints = serverRouteLoaderHints;
3392
+ if (!isPagesMode && relative.startsWith(resolved.serverDir)) try {
3393
+ serverRouteLoaderHints = createServerLoaderHintsForHotUpdates(resolved, root);
3394
+ } catch {}
3395
+ const loaderDependencyHints = {
3396
+ ...clientRouteLoaderHints,
3397
+ ...previousServerRouteLoaderHints,
3398
+ ...serverRouteLoaderHints
3399
+ };
3400
+ const changesRouteHeadDependency = reachesRouteHintedModule(modules, serverRoot, clientRouteHeadHints, { startAtImporters: changesRouteHeadSource });
3401
+ const changesRouteHeadersDependency = reachesRouteHintedModule(modules, serverRoot, clientRouteHeadersHints, { startAtImporters: changesRouteHeadSource });
3402
+ const changesRouteLoaderDependency = reachesRouteHintedModule(modules, serverRoot, loaderDependencyHints, { startAtImporters: changesRouteLoaderSource });
3403
+ let shouldReloadClientEntry = changesRouteHeadDependency || changesRouteHeadersDependency;
2988
3404
  let clientHeadModule;
2989
- if (changesRouteHeadSource || changesRouteHeadDependency) clientHeadModule = server.moduleGraph.getModuleById(PRACHT_CLIENT_MODULE_ID);
3405
+ if (changesRouteHeadSource || changesRouteHeadDependency || changesRouteHeadersDependency) clientHeadModule = server.moduleGraph.getModuleById(PRACHT_CLIENT_MODULE_ID);
2990
3406
  if (changesRouteHeadSource) {
2991
- const previousHint = clientRouteHeadHints[relative];
3407
+ const previousHint = clientRouteHeadHints[relative] === true;
2992
3408
  try {
2993
3409
  const nextHints = createRouteHeadHintsForVirtualModules(resolved, root);
2994
- shouldReloadClientHead ||= previousHint === true || nextHints[relative] === true;
3410
+ shouldReloadClientEntry ||= previousHint !== (nextHints[relative] === true);
2995
3411
  clientRouteHeadHints = nextHints;
2996
3412
  } catch {
2997
- shouldReloadClientHead = true;
3413
+ shouldReloadClientEntry = true;
2998
3414
  }
2999
3415
  } else if (changesRouteHeadDependency && clientHeadModule) server.moduleGraph.invalidateModule(clientHeadModule);
3416
+ if (changesRouteHeadSource) {
3417
+ const previouslyHadHeaders = clientRouteHeadersHints[relative] === true;
3418
+ try {
3419
+ const nextHints = createRouteHeadersHintsForVirtualModules(resolved, root);
3420
+ shouldReloadClientEntry ||= previouslyHadHeaders || nextHints[relative] === true;
3421
+ clientRouteHeadersHints = nextHints;
3422
+ } catch {
3423
+ shouldReloadClientEntry = true;
3424
+ }
3425
+ }
3426
+ if (changesRouteLoaderSource) {
3427
+ const previousHint = clientRouteLoaderHints[relative] === true;
3428
+ try {
3429
+ const nextHints = createRouteLoaderHintsForVirtualModules(resolved, root);
3430
+ shouldReloadClientEntry ||= previousHint !== (nextHints[relative] === true);
3431
+ clientRouteLoaderHints = nextHints;
3432
+ } catch {
3433
+ shouldReloadClientEntry = true;
3434
+ }
3435
+ }
3000
3436
  if (isPagesMode && relative.startsWith(resolved.pagesDir)) {
3001
3437
  clearPagesAppSourceCache();
3002
3438
  invalidateVirtualModules(server);
3003
- if (!sendServerOnlyFullReload(server, file) && shouldReloadClientHead && clientHeadModule) return [...new Set([...modules, clientHeadModule])];
3439
+ const sentFullReload = sendServerOnlyFullReload(server, file);
3440
+ if (!sentFullReload && !shouldReloadClientEntry) sendRouteDataStale(server);
3441
+ if (!sentFullReload && shouldReloadClientEntry && clientHeadModule) return [...new Set([...modules, clientHeadModule])];
3004
3442
  return;
3005
3443
  }
3006
3444
  if (!isPagesMode && relative === resolved.appFile) {
@@ -3038,7 +3476,9 @@ function pracht(options = {}) {
3038
3476
  if (capabilityMod) server.moduleGraph.invalidateModule(capabilityMod);
3039
3477
  }
3040
3478
  }
3041
- if (!sendServerOnlyFullReload(server, file) && shouldReloadClientHead && clientHeadModule) return [...new Set([...modules, clientHeadModule])];
3479
+ const sentFullReload = sendServerOnlyFullReload(server, file);
3480
+ if (!sentFullReload && shouldReloadClientEntry && clientHeadModule) return [...new Set([...modules, clientHeadModule])];
3481
+ if (!sentFullReload && (changesRouteHeadSource || changesRouteLoaderDependency)) sendRouteDataStale(server);
3042
3482
  }
3043
3483
  };
3044
3484
  const configuredBasePlugin = {
@@ -3076,12 +3516,15 @@ function pracht(options = {}) {
3076
3516
  ...resolved.precompileSsrJsx === true ? {} : resolved.precompileSsrJsx,
3077
3517
  ssrOnly: true
3078
3518
  }) : null;
3519
+ const preactPlugins = preact();
3520
+ const clientModulePrefreshPlugin = createClientModulePrefreshPlugin(preactPlugins, { isRouteOrShellModule: (id) => isRouteOrShellFile(id, routeFileDirs, routeFileExtensions) });
3079
3521
  const plugins = [
3080
3522
  ...precompilePlugin ? [precompilePlugin] : [],
3081
- ...preact(),
3523
+ ...preactPlugins,
3082
3524
  prachtPlugin,
3083
3525
  configuredBasePlugin,
3084
3526
  clientModuleTransformPlugin,
3527
+ ...clientModulePrefreshPlugin ? [clientModulePrefreshPlugin] : [],
3085
3528
  ...edgeRuntimeSafetyPlugin ? [edgeRuntimeSafetyPlugin] : [],
3086
3529
  createEnvSafetyPlugin(resolved.envSafety)
3087
3530
  ];
@@ -3335,4 +3778,4 @@ function withTrailingSep(p) {
3335
3778
  return p.endsWith("/") ? p : `${p}/`;
3336
3779
  }
3337
3780
  //#endregion
3338
- 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 };
3781
+ export { FRAMEWORK_VENDOR_CHUNK, 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, frameworkChunkGroups, pracht, scanCodeForEnvLeaks };
@@ -46,6 +46,7 @@ function namedDeclarationRe(exportName) {
46
46
  return new RegExp(`export\\s+(?:async\\s+)?(?:function|const|let|var)\\s+${exportName}\\b`);
47
47
  }
48
48
  const HEAD_DECLARATION_RE = namedDeclarationRe("head");
49
+ const HEADERS_DECLARATION_RE = namedDeclarationRe("headers");
49
50
  const STATIC_PATHS_DECLARATION_RE = namedDeclarationRe("getStaticPaths");
50
51
  const EXPORT_BLOCK_RE = /export\s*\{([^}]*)\}\s*(?:from\s*["'][^"']+["'])?/g;
51
52
  const EXPORT_ALL_RE = /export\s+\*\s+from\b/;
@@ -168,10 +169,13 @@ function exportSpecifiersInclude(specifiers, exportName) {
168
169
  * Whether `source` exports `exportName`, via a declaration, an export block,
169
170
  * or an `export *` re-export (which could expose anything, so it counts).
170
171
  *
171
- * Comments and strings are masked first, so prose or a string literal
172
- * mentioning the name cannot produce a false positive.
172
+ * Ordinary TS/JS is parsed exactly, including string-literal export names.
173
+ * Custom syntaxes fall back to masked lexical detection so prose or a string
174
+ * literal mentioning the name cannot produce a false positive.
173
175
  */
174
176
  function detectNamedExport(source, exportName, declarationRe) {
177
+ const parsedResult = inspectParsedModule(source, exportName);
178
+ if (parsedResult !== void 0) return parsedResult;
175
179
  const analysisSource = maskCommentsAndStrings(source);
176
180
  if (declarationRe.test(analysisSource) || variableDeclarationExports(analysisSource, exportName)) return true;
177
181
  for (const match of analysisSource.matchAll(EXPORT_BLOCK_RE)) if (exportSpecifiersInclude(match[1], exportName)) return true;
@@ -180,6 +184,10 @@ function detectNamedExport(source, exportName, declarationRe) {
180
184
  function detectHeadExport(source) {
181
185
  return detectNamedExport(source, "head", HEAD_DECLARATION_RE);
182
186
  }
187
+ /** Whether the route or shell module exports document response headers. */
188
+ function detectHeadersExport(source) {
189
+ return detectNamedExport(source, "headers", HEADERS_DECLARATION_RE);
190
+ }
183
191
  /**
184
192
  * Whether the route module exports `getStaticPaths()`.
185
193
  *
@@ -195,25 +203,25 @@ function detectStaticPathsExport(source) {
195
203
  function isSyntaxNode(value) {
196
204
  return typeof value === "object" && value !== null && typeof value.type === "string";
197
205
  }
198
- function bindingIncludesLoader(node) {
206
+ function bindingIncludesName(node, exportName) {
199
207
  if (!isSyntaxNode(node)) return false;
200
- if (node.type === "Identifier") return node.name === "loader";
201
- if (node.type === "AssignmentPattern") return bindingIncludesLoader(node.left);
202
- if (node.type === "RestElement") return bindingIncludesLoader(node.argument);
203
- if (node.type === "ArrayPattern") return Array.isArray(node.elements) && node.elements.some(bindingIncludesLoader);
208
+ if (node.type === "Identifier") return node.name === exportName;
209
+ if (node.type === "AssignmentPattern") return bindingIncludesName(node.left, exportName);
210
+ if (node.type === "RestElement") return bindingIncludesName(node.argument, exportName);
211
+ if (node.type === "ArrayPattern") return Array.isArray(node.elements) && node.elements.some((element) => bindingIncludesName(element, exportName));
204
212
  if (node.type === "ObjectPattern") return Array.isArray(node.properties) && node.properties.some((property) => {
205
213
  if (!isSyntaxNode(property)) return false;
206
- return property.type === "RestElement" ? bindingIncludesLoader(property.argument) : bindingIncludesLoader(property.value);
214
+ return property.type === "RestElement" ? bindingIncludesName(property.argument, exportName) : bindingIncludesName(property.value, exportName);
207
215
  });
208
216
  return false;
209
217
  }
210
- function exportedNameIsLoader(node) {
218
+ function exportedNameMatches(node, exportName) {
211
219
  if (!isSyntaxNode(node)) return false;
212
- if (node.type === "Identifier") return node.name === "loader";
213
- if (node.type === "StringLiteral") return node.value === "loader";
220
+ if (node.type === "Identifier") return node.name === exportName;
221
+ if (node.type === "StringLiteral") return node.value === exportName;
214
222
  return false;
215
223
  }
216
- function inspectParsedModule(source) {
224
+ function inspectParsedModule(source, exportName) {
217
225
  for (const plugins of [["typescript", "jsx"], ["typescript"]]) {
218
226
  let body;
219
227
  try {
@@ -230,19 +238,19 @@ function inspectParsedModule(source) {
230
238
  continue;
231
239
  }
232
240
  if (statement.type !== "ExportNamedDeclaration" || statement.exportKind === "type") continue;
233
- if (Array.isArray(statement.specifiers) && statement.specifiers.some((specifier) => isSyntaxNode(specifier) && specifier.exportKind !== "type" && exportedNameIsLoader(specifier.exported))) return true;
241
+ if (Array.isArray(statement.specifiers) && statement.specifiers.some((specifier) => isSyntaxNode(specifier) && specifier.exportKind !== "type" && exportedNameMatches(specifier.exported, exportName))) return true;
234
242
  const declaration = statement.declaration;
235
243
  if (!isSyntaxNode(declaration)) continue;
236
244
  if (declaration.declare === true || declaration.type.startsWith("TS")) continue;
237
245
  if (declaration.type === "VariableDeclaration") {
238
- if (Array.isArray(declaration.declarations) && declaration.declarations.some((declarator) => isSyntaxNode(declarator) && bindingIncludesLoader(declarator.id))) return true;
239
- } else if (bindingIncludesLoader(declaration.id)) return true;
246
+ if (Array.isArray(declaration.declarations) && declaration.declarations.some((declarator) => isSyntaxNode(declarator) && bindingIncludesName(declarator.id, exportName))) return true;
247
+ } else if (bindingIncludesName(declaration.id, exportName)) return true;
240
248
  }
241
249
  return false;
242
250
  }
243
251
  }
244
252
  function detectLoaderExport(source) {
245
- const parsedResult = inspectParsedModule(source);
253
+ const parsedResult = inspectParsedModule(source, "loader");
246
254
  if (parsedResult !== void 0) return parsedResult;
247
255
  try {
248
256
  const [imports, exports] = parse$1(source);
@@ -309,6 +317,26 @@ function createRouteHeadHints(routesDir, options = {}) {
309
317
  }
310
318
  return hints;
311
319
  }
320
+ function createRouteHeadersHints(routesDir, options = {}) {
321
+ const files = [];
322
+ const hints = {};
323
+ const additionalExtensions = normalizeAdditionalExtensions(options.additionalExtensions);
324
+ scanRouteFiles(routesDir, files, withAdditionalExtensions(DEFAULT_ROUTE_EXTENSIONS, additionalExtensions));
325
+ for (const file of files) {
326
+ const extension = extname(file);
327
+ const hasHeaders = extension === ".md" || extension === ".mdx" || additionalExtensions.includes(extension) || detectHeadersExport(readFileSync(file, "utf-8"));
328
+ const relativeToRoutesDir = toPosixPath(relative(routesDir, file));
329
+ const routeRootPrefix = options.rootRelativePrefix?.replace(/\/$/, "");
330
+ const keys = /* @__PURE__ */ new Set();
331
+ if (options.appFileDir) {
332
+ const relativeToAppFile = toPosixPath(relative(options.appFileDir, file));
333
+ keys.add(relativeToAppFile.startsWith(".") ? relativeToAppFile : `./${relativeToAppFile}`);
334
+ }
335
+ if (routeRootPrefix) keys.add(`${routeRootPrefix}/${relativeToRoutesDir}`);
336
+ for (const key of keys) hints[key] = hasHeaders;
337
+ }
338
+ return hints;
339
+ }
312
340
  /**
313
341
  * Per-route-file `getStaticPaths()` presence, keyed the same way as the loader
314
342
  * and head hints.
@@ -375,6 +403,7 @@ function scan(dir, root, pages, pageExtensions, shellExtensions, additionalExten
375
403
  const revalidate = extractRevalidateSeconds(analysisSource, rel);
376
404
  const hasLoader = detectLoaderExport(analysisSource);
377
405
  const hasHead = ext === ".md" || ext === ".mdx" || additionalExtensions.has(ext) || detectHeadExport(analysisSource);
406
+ const hasHeaders = ext === ".md" || ext === ".mdx" || additionalExtensions.has(ext) || detectHeadersExport(analysisSource);
378
407
  pages.push({
379
408
  absolutePath: abs,
380
409
  relativePath: rel,
@@ -387,7 +416,8 @@ function scan(dir, root, pages, pageExtensions, shellExtensions, additionalExten
387
416
  revalidateSeconds: revalidate.seconds,
388
417
  hasRevalidateExport: revalidate.present,
389
418
  hasLoader,
390
- hasHead
419
+ hasHead,
420
+ hasHeaders
391
421
  });
392
422
  }
393
423
  }
@@ -585,4 +615,4 @@ function generateRoutesFile(pagesDir, outputPath, options) {
585
615
  ].join("\n"), "utf-8");
586
616
  }
587
617
  //#endregion
588
- export { sortRoutes as a, createRouteStaticPathsHints as c, extensionGlob as d, normalizeAdditionalExtensions as f, scanPagesDirectory as i, DEFAULT_ROUTE_EXTENSIONS as l, generatePagesManifestSource as n, createRouteHeadHints as o, withAdditionalExtensions as p, generateRoutesFile as r, createRouteLoaderHints as s, filePathToRoutePath as t, LEGACY_BARE_ROUTE_EXTENSIONS as u };
618
+ export { sortRoutes as a, createRouteLoaderHints as c, LEGACY_BARE_ROUTE_EXTENSIONS as d, extensionGlob as f, scanPagesDirectory as i, createRouteStaticPathsHints as l, withAdditionalExtensions as m, generatePagesManifestSource as n, createRouteHeadHints as o, normalizeAdditionalExtensions as p, generateRoutesFile as r, createRouteHeadersHints as s, filePathToRoutePath as t, DEFAULT_ROUTE_EXTENSIONS as u };
@@ -12,6 +12,7 @@ interface ScannedPage {
12
12
  hasRevalidateExport?: boolean;
13
13
  hasLoader?: boolean;
14
14
  hasHead?: boolean;
15
+ hasHeaders?: boolean;
15
16
  }
16
17
  interface PagesRouterOptions {
17
18
  pagesDir: string;
@@ -1,2 +1,2 @@
1
- import { a as sortRoutes, i as scanPagesDirectory, n as generatePagesManifestSource, r as generateRoutesFile, t as filePathToRoutePath } from "./pages-router-KcCRkhnf.mjs";
1
+ import { a as sortRoutes, i as scanPagesDirectory, n as generatePagesManifestSource, r as generateRoutesFile, t as filePathToRoutePath } from "./pages-router-MA9rOl88.mjs";
2
2
  export { filePathToRoutePath, generatePagesManifestSource, generateRoutesFile, scanPagesDirectory, sortRoutes };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pracht/vite-plugin",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",
@@ -46,10 +46,10 @@
46
46
  "@preact/preset-vite": "^2.10.5",
47
47
  "@prefresh/vite": "^2.0.0",
48
48
  "es-module-lexer": "^1.7.0",
49
- "@pracht/adapter-node": "0.4.1",
50
- "@pracht/capabilities": "0.2.0",
51
- "@pracht/core": "0.15.0",
52
- "@pracht/preact-ssr-precompile": "0.1.3"
49
+ "@pracht/adapter-node": "0.4.2",
50
+ "@pracht/capabilities": "0.3.0",
51
+ "@pracht/preact-ssr-precompile": "0.1.3",
52
+ "@pracht/core": "0.16.0"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "vite": "^8.0.0"