@pracht/vite-plugin 0.7.4 → 0.8.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/README.md CHANGED
@@ -86,3 +86,11 @@ Target-specific Vite plugins (e.g. `@cloudflare/vite-plugin`) are pulled in by
86
86
  the adapter package you install (`@pracht/adapter-cloudflare`,
87
87
  `@pracht/adapter-vercel`, etc.). The default path uses `@pracht/adapter-node`,
88
88
  which ships as a dependency of this package.
89
+
90
+ Custom adapters can expose two separate plugin hooks on `PrachtAdapter`:
91
+
92
+ - `vitePlugins()` for the platform's normal development and build integration.
93
+ - `graphVitePlugins()` for metadata-only CLI servers. This optional hook must
94
+ not start runtimes, listeners, persistent workers, or debuggers; use it only
95
+ for safe resolvers or platform-module stubs. When omitted, graph commands
96
+ load no adapter-contributed plugins.
package/dist/index.d.mts CHANGED
@@ -70,6 +70,13 @@ interface PrachtAdapter {
70
70
  * Returned plugins are appended to the plugin array returned by `pracht()`.
71
71
  */
72
72
  vitePlugins?(): Plugin[];
73
+ /**
74
+ * Vite plugins that can safely run in the CLI's graph-only server. These
75
+ * plugins may provide runtime-module stubs or other metadata-only support,
76
+ * but must not start deployment runtimes, listeners, or persistent workers.
77
+ * When omitted, graph commands load no adapter-contributed plugins.
78
+ */
79
+ graphVitePlugins?(): Plugin[];
73
80
  /**
74
81
  * If true, the adapter owns dev-server request handling and the vite-plugin
75
82
  * will not install its own SSR middleware. Used when the adapter contributes
@@ -103,6 +110,19 @@ interface PrachtLlmsTxtOptions {
103
110
  origin?: string;
104
111
  /** Sections to emit. Defaults to ["pages", "api", "capabilities"]. */
105
112
  include?: LlmsTxtSection[];
113
+ /**
114
+ * Route/API path patterns to leave out, using the same segment globs as
115
+ * `defineApp({ constraints })` (`*` = one segment, trailing `**` = the
116
+ * rest). llms.txt invites agents to fetch every URL it lists, so exclude
117
+ * anything an anonymous agent cannot use — pages behind an auth middleware,
118
+ * internal tooling, deliberate error routes. Capabilities are matched by
119
+ * their dispatch path (`/api/capabilities/**`).
120
+ *
121
+ * ```ts
122
+ * llmsTxt: { exclude: ["/dashboard", "/admin/**"] }
123
+ * ```
124
+ */
125
+ exclude?: string[];
106
126
  }
107
127
  interface PrachtPluginOptions {
108
128
  appFile?: string;
package/dist/index.mjs CHANGED
@@ -1,14 +1,15 @@
1
- import { i as scanPagesDirectory, n as generatePagesManifestSource, o as createRouteLoaderHints } from "./pages-router-CStJY2Zp.mjs";
2
- import { createRequire } from "node:module";
1
+ import { i as scanPagesDirectory, n as generatePagesManifestSource, o as createRouteLoaderHints } from "./pages-router-BQlG21oC.mjs";
2
+ import { createRequire, isBuiltin } from "node:module";
3
3
  import { preactSsrPrecompile } from "@pracht/preact-ssr-precompile";
4
4
  import preact from "@preact/preset-vite";
5
5
  import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
6
6
  import { dirname, extname, join, resolve } from "node:path";
7
7
  import { loadEnv, parseAst } from "vite";
8
+ import { PRACHT_GRAPH_ONLY_ENV } from "@pracht/core/server";
8
9
  import { CAPABILITY_SETTLED_EVENT, CAPABILITY_TRANSPORT_HEADER, CONFIRMATION_HEADER } from "@pracht/capabilities";
9
- import { extractCapabilityProjection, extractCapabilityRegistrations } from "@pracht/capabilities/static";
10
+ import { extractCapabilityProjection, extractCapabilityRegistrations, extractDefineAppObjectBody, scanTopLevelProperties } from "@pracht/capabilities/static";
10
11
  import { createNodeServerEntryModule } from "@pracht/adapter-node";
11
- import { resolveRegistryModule } from "@pracht/core";
12
+ import { applyDefaultSecurityHeaders, resolveRegistryModule } from "@pracht/core";
12
13
  //#region src/client-module-query.ts
13
14
  const CLIENT_MODULE_QUERY = "pracht-client";
14
15
  const PRACHT_CLIENT_MODULE_QUERY = `?${CLIENT_MODULE_QUERY}`;
@@ -1367,6 +1368,12 @@ function resolveOptions(options) {
1367
1368
  ...options
1368
1369
  };
1369
1370
  if (resolved.llmsTxt === void 0) resolved.llmsTxt = false;
1371
+ if (!new Set([
1372
+ "spa",
1373
+ "ssr",
1374
+ "ssg",
1375
+ "isg"
1376
+ ]).has(resolved.pagesDefaultRender)) throw new Error("pracht({ pagesDefaultRender }) expects \"spa\", \"ssr\", \"ssg\", or \"isg\".");
1370
1377
  if (!Number.isInteger(resolved.prerenderConcurrency) || resolved.prerenderConcurrency <= 0) throw new Error("pracht({ prerenderConcurrency }) expects a positive integer.");
1371
1378
  if (!Number.isInteger(resolved.maxBodySize) || resolved.maxBodySize <= 0) throw new Error("pracht({ maxBodySize }) expects a positive integer number of bytes.");
1372
1379
  validateBudgets(resolved.budgets);
@@ -1416,6 +1423,82 @@ function validateBudgets(budgets) {
1416
1423
  * rather than silently dropping an endpoint.
1417
1424
  */
1418
1425
  /**
1426
+ * Whether the app can reach the agent surface at all — registered capabilities
1427
+ * or a `defineApp({ agents })` config. Drives the `__PRACHT_AGENT_SURFACE__`
1428
+ * define, which lets the bundler drop the capability and Web Bot Auth runtimes
1429
+ * from the server bundle of apps that use neither.
1430
+ *
1431
+ * Deliberately one-sided: it only answers `false` when the manifest is readable
1432
+ * and provably free of both. An unreadable manifest, a parse failure, or any
1433
+ * spread inside the manifest file (which could carry registrations this
1434
+ * analyzer cannot see) answers `true`, so the runtime keeps deciding for
1435
+ * itself. Being wrong the other way would 404 a capability in production that
1436
+ * works in dev.
1437
+ */
1438
+ function hasAgentSurface(options = {}, root = process.cwd()) {
1439
+ const resolved = resolveOptions(options);
1440
+ if (resolved.pagesDir) return false;
1441
+ const appFileAbs = resolve(root, resolved.appFile.replace(/^\//, ""));
1442
+ let manifestSource;
1443
+ try {
1444
+ manifestSource = readFileSync(appFileAbs, "utf-8");
1445
+ } catch {
1446
+ return true;
1447
+ }
1448
+ const appBody = extractDefineAppObjectBody(manifestSource);
1449
+ if (appBody === null) return true;
1450
+ const properties = scanTopLevelProperties(appBody);
1451
+ if (properties.has("agents") || properties.has("capabilities")) return true;
1452
+ if (/\b(?:agents|capabilities)\b/.test(appBody)) return true;
1453
+ if (appBody.includes("...") || hasOpaqueTopLevelProperty(appBody)) return true;
1454
+ try {
1455
+ return extractCapabilityRegistrations(manifestSource).length > 0;
1456
+ } catch {
1457
+ return true;
1458
+ }
1459
+ }
1460
+ /** Whether an object literal body contains an opaque key at its top level. */
1461
+ function hasOpaqueTopLevelProperty(objectBody) {
1462
+ let braces = 0;
1463
+ let brackets = 0;
1464
+ let parentheses = 0;
1465
+ let expectingKey = true;
1466
+ for (let index = 0; index < objectBody.length; index += 1) {
1467
+ const char = objectBody[index];
1468
+ const next = objectBody[index + 1];
1469
+ if (char === "\"" || char === "'" || char === "`") {
1470
+ const quote = char;
1471
+ for (index += 1; index < objectBody.length; index += 1) if (objectBody[index] === "\\") index += 1;
1472
+ else if (objectBody[index] === quote) break;
1473
+ continue;
1474
+ }
1475
+ if (char === "/" && next === "/") {
1476
+ index = objectBody.indexOf("\n", index + 2);
1477
+ if (index === -1) break;
1478
+ continue;
1479
+ }
1480
+ if (char === "/" && next === "*") {
1481
+ const end = objectBody.indexOf("*/", index + 2);
1482
+ if (end === -1) return true;
1483
+ index = end + 1;
1484
+ continue;
1485
+ }
1486
+ if (char === "/") return true;
1487
+ const atTopLevel = braces === 0 && brackets === 0 && parentheses === 0;
1488
+ if (atTopLevel && expectingKey && char === "[") return true;
1489
+ if (atTopLevel && expectingKey && char === "\\") return true;
1490
+ if (atTopLevel && char === ":") expectingKey = false;
1491
+ if (atTopLevel && char === ",") expectingKey = true;
1492
+ if (char === "{") braces += 1;
1493
+ else if (char === "}") braces -= 1;
1494
+ else if (char === "[") brackets += 1;
1495
+ else if (char === "]") brackets -= 1;
1496
+ else if (char === "(") parentheses += 1;
1497
+ else if (char === ")") parentheses -= 1;
1498
+ }
1499
+ return false;
1500
+ }
1501
+ /**
1419
1502
  * Extract capability registrations (name → module path) from the app
1420
1503
  * manifest source and their exposure metadata from each capability source.
1421
1504
  * Pages-router apps have no manifest, so capabilities are manifest-mode only.
@@ -1489,7 +1572,7 @@ function extractCapabilityMetadata(name, file, source) {
1489
1572
  */
1490
1573
  function createPrachtCapabilitiesClientModuleSource(options = {}, buildOptions = {}) {
1491
1574
  const capabilities = extractCapabilities(options, buildOptions.root);
1492
- const endpoints = {};
1575
+ const endpoints = Object.create(null);
1493
1576
  for (const capability of capabilities) if (capability.httpPath) endpoints[capability.name] = {
1494
1577
  method: "POST",
1495
1578
  path: capability.httpPath,
@@ -1499,7 +1582,9 @@ function createPrachtCapabilitiesClientModuleSource(options = {}, buildOptions =
1499
1582
  "// Generated by @pracht/vite-plugin from the app manifest capability registrations.",
1500
1583
  "// Contains only http-exposed capability names, endpoints, and effects —",
1501
1584
  "// capability modules themselves are server-only and never reach the client graph.",
1502
- `const endpoints = ${JSON.stringify(endpoints)};`,
1585
+ "import { createUseCapability } from \"@pracht/core\";",
1586
+ "",
1587
+ `const endpoints = Object.assign(Object.create(null), JSON.parse(${JSON.stringify(JSON.stringify(endpoints))}));`,
1503
1588
  "",
1504
1589
  "export const capabilityEndpoints = endpoints;",
1505
1590
  "",
@@ -1508,7 +1593,9 @@ function createPrachtCapabilitiesClientModuleSource(options = {}, buildOptions =
1508
1593
  " try {",
1509
1594
  " const headers = new Headers(opts && opts.headers);",
1510
1595
  " headers.set(\"content-type\", \"application/json\");",
1511
- " if (opts && opts.confirm) {",
1596
+ " if (opts && opts.prepare) {",
1597
+ ` headers.delete(${JSON.stringify(CONFIRMATION_HEADER)});`,
1598
+ " } else if (opts && opts.confirm) {",
1512
1599
  ` headers.set(${JSON.stringify(CONFIRMATION_HEADER)}, opts.confirm);`,
1513
1600
  " }",
1514
1601
  " response = await fetch(endpoint.path, {",
@@ -1524,8 +1611,9 @@ function createPrachtCapabilitiesClientModuleSource(options = {}, buildOptions =
1524
1611
  " error: { code: \"network_error\", message: String((error && error.message) || error) },",
1525
1612
  " };",
1526
1613
  " }",
1614
+ " let result;",
1527
1615
  " try {",
1528
- " return await response.json();",
1616
+ " result = await response.json();",
1529
1617
  " } catch {",
1530
1618
  " return {",
1531
1619
  " ok: false,",
@@ -1535,6 +1623,23 @@ function createPrachtCapabilitiesClientModuleSource(options = {}, buildOptions =
1535
1623
  " },",
1536
1624
  " };",
1537
1625
  " }",
1626
+ " if (",
1627
+ " !result || typeof result !== \"object\" ||",
1628
+ " (result.ok !== true && result.ok !== false) ||",
1629
+ " (result.ok === true && !(\"data\" in result)) ||",
1630
+ " (result.ok === false &&",
1631
+ " (!result.error || typeof result.error !== \"object\" ||",
1632
+ " typeof result.error.code !== \"string\" || typeof result.error.message !== \"string\"))",
1633
+ " ) {",
1634
+ " return {",
1635
+ " ok: false,",
1636
+ " error: {",
1637
+ " code: \"invalid_response\",",
1638
+ " message: `Capability endpoint returned an invalid envelope (status ${response.status}).`,",
1639
+ " },",
1640
+ " };",
1641
+ " }",
1642
+ " return result;",
1538
1643
  "}",
1539
1644
  "",
1540
1645
  "export async function callCapability(name, input, opts) {",
@@ -1565,6 +1670,38 @@ function createPrachtCapabilitiesClientModuleSource(options = {}, buildOptions =
1565
1670
  " } catch {}",
1566
1671
  " return result;",
1567
1672
  "}",
1673
+ "",
1674
+ "// Nested client: dotted capability names become object paths, so",
1675
+ "// `capabilities.notes.search(input)` calls `callCapability(\"notes.search\", input)`.",
1676
+ "// Built from the same endpoint table, so there is one dispatch path.",
1677
+ "function buildCapabilityClient(names) {",
1678
+ " const root = Object.create(null);",
1679
+ " for (const name of names) {",
1680
+ " const segments = name.split(\".\");",
1681
+ " const leaf = segments.pop();",
1682
+ " let node = root;",
1683
+ " for (const segment of segments) {",
1684
+ " // A name that is both a namespace and a leaf (`a` plus `a.b`) would",
1685
+ " // collide; the namespace wins and the leaf stays reachable through",
1686
+ " // callCapability(). `pracht verify` reports the shadowed name.",
1687
+ " if (typeof node[segment] !== \"object\" || node[segment] === null) {",
1688
+ " node[segment] = Object.create(null);",
1689
+ " }",
1690
+ " node = node[segment];",
1691
+ " }",
1692
+ " if (typeof node[leaf] !== \"object\") {",
1693
+ " node[leaf] = (input, opts) => callCapability(name, input, opts);",
1694
+ " }",
1695
+ " }",
1696
+ " return root;",
1697
+ "}",
1698
+ "",
1699
+ "export const capabilities = /*@__PURE__*/ buildCapabilityClient(Object.keys(endpoints));",
1700
+ "",
1701
+ "// The hook's implementation lives in @pracht/core (typed and unit-tested);",
1702
+ "// only the app-specific dispatch is bound here, so every projection shares",
1703
+ "// one call path. Pure-annotated: apps that never call it pay nothing.",
1704
+ "export const useCapability = /*@__PURE__*/ createUseCapability(callCapability);",
1568
1705
  ""
1569
1706
  ].join("\n");
1570
1707
  }
@@ -1884,6 +2021,7 @@ function createPrachtServerModuleSource(options = {}, buildOptions = {}) {
1884
2021
  };
1885
2022
  const adapter = resolved.adapter;
1886
2023
  const llmsTxtConfig = resolveLlmsTxtConfig(resolved, buildOptions.root);
2024
+ const islandsBootstrapRequired = hasWebmcpCapabilities(resolved, buildOptions.root);
1887
2025
  let prachtImports = adapter?.serverImports ? adapter.serverImports + "\nimport { prerenderApp } from \"@pracht/core/server\";" : "import { resolveApp, resolveApiRoutes, prerenderApp } from \"@pracht/core/server\";";
1888
2026
  if (llmsTxtConfig) prachtImports += "\nimport { buildLlmsTxt } from \"@pracht/core/server\";";
1889
2027
  const appImport = isPagesMode ? generatePagesAppInlineSource(resolved, buildOptions.root) : `import { app } from ${JSON.stringify(resolved.appFile)};`;
@@ -1911,6 +2049,7 @@ function createPrachtServerModuleSource(options = {}, buildOptions = {}) {
1911
2049
  `export const buildTarget = ${JSON.stringify(adapter?.id ?? "node")};`,
1912
2050
  `export const clientEntryUrl = ${JSON.stringify(clientBuild.clientEntryUrl ?? "/@pracht/client.js")};`,
1913
2051
  `export const islandsEntryUrl = ${JSON.stringify(islandsEntryUrl ?? null)};`,
2052
+ `export const islandsBootstrapRequired = ${JSON.stringify(islandsBootstrapRequired)};`,
1914
2053
  `export const cssManifest = ${JSON.stringify(clientBuild.cssManifest)};`,
1915
2054
  `export const jsManifest = ${JSON.stringify(clientBuild.jsManifest)};`,
1916
2055
  `export const prerenderConcurrency = ${JSON.stringify(resolved.prerenderConcurrency)};`,
@@ -1937,12 +2076,14 @@ function createPrachtServerModuleSource(options = {}, buildOptions = {}) {
1937
2076
  function createPrachtDevModuleSource(options = {}, buildOptions = {}) {
1938
2077
  const resolved = resolveOptions(options);
1939
2078
  return [
1940
- "import { resolveApp } from \"@pracht/core/server\";",
2079
+ "import { resolveApp, resolveApiRoutes } from \"@pracht/core/server\";",
1941
2080
  resolved.pagesDir ? generatePagesAppInlineSource(resolved, buildOptions.root) : `import { app } from ${JSON.stringify(resolved.appFile)};`,
1942
2081
  "",
1943
2082
  createPrachtRegistryModuleSource(resolved),
1944
2083
  "",
1945
2084
  "export const resolvedApp = resolveApp(app);",
2085
+ `export const apiRoutes = resolveApiRoutes(Object.keys(apiModules), ${JSON.stringify(resolved.apiDir)});`,
2086
+ `export const buildTarget = ${JSON.stringify(resolved.adapter?.id ?? "node")};`,
1946
2087
  ""
1947
2088
  ].join("\n");
1948
2089
  }
@@ -1962,6 +2103,7 @@ function resolveLlmsTxtConfig(resolved, root = process.cwd()) {
1962
2103
  if (description) config.description = description;
1963
2104
  if (resolved.llmsTxt.origin) config.origin = resolved.llmsTxt.origin;
1964
2105
  if (resolved.llmsTxt.include) config.include = resolved.llmsTxt.include;
2106
+ if (resolved.llmsTxt.exclude?.length) config.exclude = resolved.llmsTxt.exclude;
1965
2107
  return config;
1966
2108
  }
1967
2109
  function createApplyRouteLoaderHintsSource() {
@@ -1997,6 +2139,7 @@ function createRouteLoaderHintsForVirtualModules(options, root = process.cwd())
1997
2139
  }
1998
2140
  function createPrachtRegistryModuleSource(options = {}) {
1999
2141
  const resolved = resolveOptions(options);
2142
+ const apiGlobs = [`${resolved.apiDir}/**/*.{ts,js,tsx,jsx}`, `!${resolved.apiDir}/**/*.d.ts`];
2000
2143
  const isPagesMode = !!resolved.pagesDir;
2001
2144
  const routeGlob = isPagesMode ? `${resolved.pagesDir}/**/*.{ts,tsx,js,jsx,md,mdx}` : `${resolved.routesDir}/**/*.{ts,tsx,js,jsx,md,mdx}`;
2002
2145
  const routeTsrxGlob = isPagesMode ? `${resolved.pagesDir}/**/*.tsrx` : `${resolved.routesDir}/**/*.tsrx`;
@@ -2012,7 +2155,7 @@ function createPrachtRegistryModuleSource(options = {}) {
2012
2155
  ` ...import.meta.glob(${JSON.stringify(shellTsrxGlob)}),`,
2013
2156
  `};`,
2014
2157
  `export const middlewareModules = import.meta.glob(${JSON.stringify(`${resolved.middlewareDir}/**/*.{ts,tsx,js,jsx}`)});`,
2015
- `export const apiModules = import.meta.glob(${JSON.stringify(`${resolved.apiDir}/**/*.{ts,js,tsx,jsx}`)});`,
2158
+ `export const apiModules = import.meta.glob(${JSON.stringify(apiGlobs)});`,
2016
2159
  `export const dataModules = import.meta.glob(${JSON.stringify(`${resolved.serverDir}/**/*.{ts,js,tsx,jsx}`)});`,
2017
2160
  `export const capabilityModules = import.meta.glob(${JSON.stringify(`${resolved.capabilitiesDir}/**/*.{ts,js,tsx,jsx}`)});`,
2018
2161
  "",
@@ -2093,6 +2236,9 @@ function createDevSSRMiddleware(server, options = {}) {
2093
2236
  const llmsTxt = await serverMod.generateLlmsTxt();
2094
2237
  res.statusCode = 200;
2095
2238
  res.setHeader("content-type", "text/plain; charset=utf-8");
2239
+ applyDefaultSecurityHeaders(new Headers()).forEach((value, key) => {
2240
+ res.setHeader(key, value);
2241
+ });
2096
2242
  res.end(llmsTxt);
2097
2243
  return;
2098
2244
  }
@@ -2116,12 +2262,14 @@ function createDevSSRMiddleware(server, options = {}) {
2116
2262
  request: webRequest,
2117
2263
  debugErrors: true,
2118
2264
  clientEntryUrl: CLIENT_BROWSER_PATH,
2265
+ islandsEntryUrl: ISLANDS_CLIENT_BROWSER_PATH,
2266
+ islandsBootstrapRequired: serverMod.islandsBootstrapRequired === true,
2119
2267
  apiRoutes: serverMod.apiRoutes,
2120
2268
  timings
2121
2269
  });
2122
2270
  const responseContentType = response.headers.get("content-type") ?? "";
2123
2271
  if (response.status === 404 && !responseContentType.includes("application/json") && !routeMatchers.app?.notFound) return next();
2124
- const contentType = response.headers.get("content-type") ?? "text/html";
2272
+ const contentType = response.headers.get("content-type") ?? "";
2125
2273
  let body = await response.text();
2126
2274
  if (contentType.includes("text/html")) body = await server.transformIndexHtml(url, body);
2127
2275
  res.statusCode = response.status;
@@ -2442,6 +2590,8 @@ const DEV_ASSET_EXTENSIONS = new Set([
2442
2590
  ".js",
2443
2591
  ".json",
2444
2592
  ".map",
2593
+ ".markdown",
2594
+ ".md",
2445
2595
  ".mjs",
2446
2596
  ".pdf",
2447
2597
  ".png",
@@ -2500,13 +2650,18 @@ function pracht(options = {}) {
2500
2650
  const isEdge = resolved.adapter.edge === true;
2501
2651
  const isSSRBuild = env.isSsrBuild;
2502
2652
  const configRoot = _config.root ?? process.cwd();
2503
- const wantsIslandsEntry = env.command === "build" && !isSSRBuild && existsSync(resolveConfigPath(configRoot, resolved.islandsDir));
2653
+ const wantsIslandsEntry = env.command === "build" && !isSSRBuild && (existsSync(resolveConfigPath(configRoot, resolved.islandsDir)) || hasWebmcpCapabilities(resolved, configRoot));
2504
2654
  const envDir = _config.envDir ? resolve(configRoot, _config.envDir) : configRoot;
2505
2655
  const publicEnvDefine = JSON.stringify(loadEnv(env.mode, envDir, PUBLIC_ENV_PREFIX));
2656
+ const agentSurfaceDefine = env.command === "build" ? String(hasAgentSurface(resolved, configRoot)) : "true";
2506
2657
  return {
2507
2658
  appType: "custom",
2508
2659
  envPrefix: ["VITE_", PUBLIC_ENV_PREFIX],
2509
- define: { __PRACHT_PUBLIC_ENV__: publicEnvDefine },
2660
+ resolve: { dedupe: PREACT_DEDUPE },
2661
+ define: {
2662
+ __PRACHT_PUBLIC_ENV__: publicEnvDefine,
2663
+ __PRACHT_AGENT_SURFACE__: agentSurfaceDefine
2664
+ },
2510
2665
  ...isSSRBuild ? {} : { build: { rollupOptions: {
2511
2666
  ...wantsIslandsEntry ? { input: [PRACHT_ISLANDS_CLIENT_MODULE_ID] } : {},
2512
2667
  output: { manualChunks(id) {
@@ -2518,6 +2673,15 @@ function pracht(options = {}) {
2518
2673
  noExternal: true,
2519
2674
  target: "webworker"
2520
2675
  },
2676
+ environments: { ssr: { resolve: {
2677
+ conditions: [
2678
+ "worker",
2679
+ "module",
2680
+ "browser",
2681
+ "development|production"
2682
+ ],
2683
+ external: ["node:module"]
2684
+ } } },
2521
2685
  build: { rollupOptions: { external: [/^cloudflare:/] } }
2522
2686
  } : {}
2523
2687
  };
@@ -2643,6 +2807,7 @@ function pracht(options = {}) {
2643
2807
  };
2644
2808
  }
2645
2809
  };
2810
+ const edgeRuntimeSafetyPlugin = resolved.adapter.edge ? createEdgeRuntimeSafetyPlugin() : null;
2646
2811
  const optimizeDepsEntriesPlugin = {
2647
2812
  name: "pracht:optimize-deps-entries",
2648
2813
  enforce: "post",
@@ -2659,13 +2824,77 @@ function pracht(options = {}) {
2659
2824
  ...preact(),
2660
2825
  prachtPlugin,
2661
2826
  clientModuleTransformPlugin,
2827
+ ...edgeRuntimeSafetyPlugin ? [edgeRuntimeSafetyPlugin] : [],
2662
2828
  createEnvSafetyPlugin(resolved.envSafety)
2663
2829
  ];
2664
- const adapterPlugins = resolved.adapter.vitePlugins?.();
2830
+ const adapterPlugins = isGraphOnlyMode() ? resolved.adapter.graphVitePlugins?.() : resolved.adapter.vitePlugins?.();
2665
2831
  if (adapterPlugins?.length) plugins.push(...adapterPlugins);
2666
2832
  plugins.push(optimizeDepsEntriesPlugin);
2667
2833
  return plugins;
2668
2834
  }
2835
+ function isGraphOnlyMode() {
2836
+ return process.env[PRACHT_GRAPH_ONLY_ENV] === "1";
2837
+ }
2838
+ function createEdgeRuntimeSafetyPlugin() {
2839
+ let isSsrBuild = false;
2840
+ return {
2841
+ name: "pracht:edge-runtime-safety",
2842
+ apply: "build",
2843
+ enforce: "post",
2844
+ configResolved(config) {
2845
+ isSsrBuild = !!config.build.ssr;
2846
+ },
2847
+ generateBundle(_options, bundle) {
2848
+ const consumer = this.environment?.config?.consumer;
2849
+ if (!(consumer ? consumer === "server" : isSsrBuild)) return;
2850
+ const survivors = [];
2851
+ for (const [fileName, output] of Object.entries(bundle)) {
2852
+ if (output.type !== "chunk") continue;
2853
+ for (const specifier of collectNodeBuiltinImports(this.parse(output.code))) survivors.push({
2854
+ chunk: fileName,
2855
+ specifier
2856
+ });
2857
+ }
2858
+ if (survivors.length === 0) return;
2859
+ this.error([
2860
+ "[pracht] Edge server bundle retains Node.js builtin imports that are unavailable at runtime:",
2861
+ ...survivors.map(({ chunk, specifier }) => ` - ${specifier} in ${chunk}`),
2862
+ "Remove the Node-only dependency or move that route to a Node deployment target."
2863
+ ].join("\n"));
2864
+ }
2865
+ };
2866
+ }
2867
+ function collectNodeBuiltinImports(program) {
2868
+ const imports = /* @__PURE__ */ new Set();
2869
+ function sourceValue(node) {
2870
+ if (!node || typeof node !== "object" || !("value" in node)) return null;
2871
+ return typeof node.value === "string" ? node.value : null;
2872
+ }
2873
+ function visit(node) {
2874
+ if (Array.isArray(node)) {
2875
+ for (const item of node) visit(item);
2876
+ return;
2877
+ }
2878
+ if (!node || typeof node !== "object") return;
2879
+ const record = node;
2880
+ const type = record.type;
2881
+ if (type === "ImportDeclaration" || type === "ExportAllDeclaration" || type === "ExportNamedDeclaration" || type === "ImportExpression") {
2882
+ const specifier = sourceValue(record.source);
2883
+ if (specifier && isBuiltin(specifier)) imports.add(specifier);
2884
+ } else if (type === "CallExpression") {
2885
+ const callee = record.callee;
2886
+ const isImport = callee?.type === "Import";
2887
+ const isRequire = callee?.type === "Identifier" && callee.name === "require";
2888
+ if (isImport || isRequire) {
2889
+ const specifier = sourceValue(record.arguments?.[0]);
2890
+ if (specifier && isBuiltin(specifier)) imports.add(specifier);
2891
+ }
2892
+ }
2893
+ for (const value of Object.values(record)) visit(value);
2894
+ }
2895
+ visit(program);
2896
+ return imports;
2897
+ }
2669
2898
  const MANIFEST_CORE_IMPORTS = new Set([
2670
2899
  "defineApp",
2671
2900
  "group",
@@ -2685,6 +2914,7 @@ const PRACHT_OPTIMIZE_DEPS_INCLUDE = [
2685
2914
  "@pracht/core/islands-client",
2686
2915
  "@pracht/core/manifest"
2687
2916
  ];
2917
+ const PREACT_DEDUPE = ["preact", "preact-render-to-string"];
2688
2918
  function createPrachtOptimizeDepsInclude(root) {
2689
2919
  try {
2690
2920
  if (!toPosixPath(createRequire(join(root, "package.json")).resolve("@pracht/core/package.json")).includes("/node_modules/")) return [];
@@ -2706,10 +2936,12 @@ function withPrachtOptimizeDepsEntries(config, prachtEntries, prachtInclude) {
2706
2936
  function createPrachtOptimizeDepsEntries(resolved) {
2707
2937
  const scriptExtensions = "{ts,tsx,js,jsx}";
2708
2938
  const routeExtensions = "{ts,tsx,js,jsx,md,mdx,tsrx}";
2939
+ const apiDir = toOptimizeDepsEntry(resolved.apiDir);
2940
+ const apiEntries = [`${apiDir}/**/*.{ts,js,tsx,jsx}`, `!${apiDir}/**/*.d.ts`];
2709
2941
  const entries = resolved.pagesDir ? [
2710
2942
  `${toOptimizeDepsEntry(resolved.pagesDir)}/**/*.${routeExtensions}`,
2711
2943
  `${toOptimizeDepsEntry(resolved.middlewareDir)}/**/*.${scriptExtensions}`,
2712
- `${toOptimizeDepsEntry(resolved.apiDir)}/**/*.{ts,js,tsx,jsx}`,
2944
+ ...apiEntries,
2713
2945
  `${toOptimizeDepsEntry(resolved.serverDir)}/**/*.{ts,js,tsx,jsx}`,
2714
2946
  `${toOptimizeDepsEntry(resolved.islandsDir)}/**/*.${scriptExtensions}`
2715
2947
  ] : [
@@ -2717,7 +2949,7 @@ function createPrachtOptimizeDepsEntries(resolved) {
2717
2949
  `${toOptimizeDepsEntry(resolved.routesDir)}/**/*.${routeExtensions}`,
2718
2950
  `${toOptimizeDepsEntry(resolved.shellsDir)}/**/*.${routeExtensions}`,
2719
2951
  `${toOptimizeDepsEntry(resolved.middlewareDir)}/**/*.${scriptExtensions}`,
2720
- `${toOptimizeDepsEntry(resolved.apiDir)}/**/*.{ts,js,tsx,jsx}`,
2952
+ ...apiEntries,
2721
2953
  `${toOptimizeDepsEntry(resolved.serverDir)}/**/*.{ts,js,tsx,jsx}`,
2722
2954
  `${toOptimizeDepsEntry(resolved.islandsDir)}/**/*.${scriptExtensions}`,
2723
2955
  `${toOptimizeDepsEntry(resolved.capabilitiesDir)}/**/*.{ts,js,tsx,jsx}`
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
2
  import { basename, extname, join, relative } from "node:path";
3
+ import { maskCommentsAndStrings } from "@pracht/capabilities/static";
3
4
  //#region src/route-loader-hints.ts
4
5
  const ROUTE_EXTENSIONS = new Set([
5
6
  ".tsx",
@@ -84,6 +85,8 @@ const SHELL_EXTENSIONS = new Set([
84
85
  function scanPagesDirectory(pagesDir) {
85
86
  const pages = [];
86
87
  scan(pagesDir, pagesDir, pages);
88
+ const appShell = pages.find((page) => page.routePath === "__shell__");
89
+ if (appShell?.hasRevalidateExport) throw new Error(`[pracht] Pages app shell ${JSON.stringify(appShell.relativePath)} exports REVALIDATE, but app shells are not ISG routes. Declare the policy on each ISG page instead.`);
87
90
  return sortRoutes(pages);
88
91
  }
89
92
  function scan(dir, root, pages) {
@@ -105,10 +108,11 @@ function scan(dir, root, pages) {
105
108
  if (name.startsWith("_") && name !== "_app") continue;
106
109
  const rel = relative(root, abs);
107
110
  const routePath = filePathToRoutePath(rel);
108
- const source = readFileSync(abs, "utf-8");
109
- const renderMode = extractRenderMode(source);
110
- const hydrationMode = extractHydrationMode(source);
111
- const hasLoader = detectLoaderExport(source);
111
+ const analysisSource = maskMarkdownFences(readFileSync(abs, "utf-8"), rel);
112
+ const renderMode = extractQuotedPageExport(analysisSource, "RENDER_MODE", rel);
113
+ const hydrationMode = extractQuotedPageExport(analysisSource, "HYDRATION", rel);
114
+ const revalidate = extractRevalidateSeconds(analysisSource, rel);
115
+ const hasLoader = detectLoaderExport(analysisSource);
112
116
  pages.push({
113
117
  absolutePath: abs,
114
118
  relativePath: rel,
@@ -118,6 +122,8 @@ function scan(dir, root, pages) {
118
122
  isDynamic: routePath.split("/").some((segment) => segment.startsWith(":")),
119
123
  renderMode,
120
124
  hydrationMode,
125
+ revalidateSeconds: revalidate.seconds,
126
+ hasRevalidateExport: revalidate.present,
121
127
  hasLoader
122
128
  });
123
129
  }
@@ -159,15 +165,70 @@ function getRouteSegmentSpecificity(segment) {
159
165
  if (segment.startsWith(":")) return 2;
160
166
  return 3;
161
167
  }
162
- const RENDER_MODE_RE = /export\s+const\s+RENDER_MODE\s*=\s*["'](\w+)["']/;
163
- function extractRenderMode(source) {
164
- const match = RENDER_MODE_RE.exec(source);
165
- return match ? match[1] : void 0;
168
+ function extractQuotedPageExport(source, name, relativePath) {
169
+ const declarations = [...maskCommentsAndStrings(source).matchAll(new RegExp(`export\\s+const\\s+${name}\\s*=`, "g"))];
170
+ if (declarations.length === 0) return void 0;
171
+ if (declarations.length > 1) throw new Error(`[pracht] Pages route ${JSON.stringify(relativePath)} exports ${name} more than once.`);
172
+ const declaration = declarations[0];
173
+ const valueStart = (declaration.index ?? 0) + declaration[0].length;
174
+ return source.slice(valueStart).trimStart().match(/^["'](\w+)["']/)?.[1];
166
175
  }
167
- const HYDRATION_RE = /export\s+const\s+HYDRATION\s*=\s*["'](\w+)["']/;
168
- function extractHydrationMode(source) {
169
- const match = HYDRATION_RE.exec(source);
170
- return match ? match[1] : void 0;
176
+ const REVALIDATE_RE = /export\s+const\s+REVALIDATE\s*=\s*([^;\n]+)/;
177
+ function extractRevalidateSeconds(source, relativePath) {
178
+ const matches = [...maskCommentsAndStrings(source).matchAll(new RegExp(REVALIDATE_RE, "g"))];
179
+ if (matches.length === 0) return { present: false };
180
+ if (matches.length > 1) throw new Error(`[pracht] Pages route ${JSON.stringify(relativePath)} exports REVALIDATE more than once.`);
181
+ const expression = matches[0][1].trim().replace(/\s+as\s+const$/, "");
182
+ if (!/^\d(?:_?\d)*$/.test(expression)) throw new Error(`[pracht] Pages route ${JSON.stringify(relativePath)} must export REVALIDATE as a positive integer literal number of seconds (for example, \`export const REVALIDATE = 60\`).`);
183
+ const seconds = Number(expression.replaceAll("_", ""));
184
+ if (!Number.isSafeInteger(seconds) || seconds <= 0) throw new Error(`[pracht] Pages route ${JSON.stringify(relativePath)} must export REVALIDATE as a positive integer literal number of seconds within JavaScript's safe integer range.`);
185
+ return {
186
+ present: true,
187
+ seconds
188
+ };
189
+ }
190
+ /** Mask Markdown fenced examples while preserving source offsets and top-level MDX exports. */
191
+ function maskMarkdownFences(source, relativePath) {
192
+ if (!/\.mdx?$/.test(relativePath)) return source;
193
+ const chars = source.split("");
194
+ let activeFence = null;
195
+ for (const line of source.matchAll(/.*(?:\r?\n|$)/g)) {
196
+ if (line[0] === "") continue;
197
+ const lineStart = line.index ?? 0;
198
+ const stripped = stripMarkdownContainerPrefix(line[0].replace(/\r?\n$/, ""));
199
+ const fenceContent = activeFence && stripped.content.startsWith(" ".repeat(activeFence.continuationIndent)) ? stripped.content.slice(activeFence.continuationIndent) : stripped.content;
200
+ const opening = activeFence ? null : /^ {0,3}(`{3,}|~{3,})/.exec(fenceContent);
201
+ const closing = activeFence ? new RegExp(`^ {0,3}\\${activeFence.character}{${activeFence.length},}[ \\t]*$`).test(fenceContent) : false;
202
+ if (activeFence || opening) for (let offset = 0; offset < line[0].length; offset += 1) {
203
+ const index = lineStart + offset;
204
+ if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " ";
205
+ }
206
+ if (closing) activeFence = null;
207
+ else if (opening) activeFence = {
208
+ character: opening[1][0],
209
+ continuationIndent: stripped.continuationIndent,
210
+ length: opening[1].length
211
+ };
212
+ }
213
+ return chars.join("");
214
+ }
215
+ function stripMarkdownContainerPrefix(line) {
216
+ let content = line;
217
+ let continuationIndent = 0;
218
+ while (true) {
219
+ const quote = /^ {0,3}> ?/.exec(content);
220
+ if (quote) {
221
+ content = content.slice(quote[0].length);
222
+ continue;
223
+ }
224
+ const list = /^ {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+/.exec(content);
225
+ if (!list) return {
226
+ content,
227
+ continuationIndent
228
+ };
229
+ continuationIndent += list[0].length;
230
+ content = content.slice(list[0].length);
231
+ }
171
232
  }
172
233
  function generatePagesManifestSource(pages, options) {
173
234
  const pagesDir = options.pagesDir;
@@ -175,16 +236,20 @@ function generatePagesManifestSource(pages, options) {
175
236
  const prefix = options.pagesDirPrefix;
176
237
  const useImport = options.useImportSyntax ?? false;
177
238
  const appFile = scanAllFiles(pagesDir).find((f) => basename(f, extname(f)) === "_app" && SHELL_EXTENSIONS.has(extname(f)));
178
- const lines = ["import { defineApp, group, route } from \"@pracht/core/manifest\";", ""];
239
+ const lines = [`import { ${pages.some((page) => page.revalidateSeconds !== void 0) ? "defineApp, group, route, timeRevalidate" : "defineApp, group, route"} } from "@pracht/core/manifest";`, ""];
179
240
  const routeEntries = [];
180
241
  const notFoundPage = pages.find((page) => page.routePath === "/404");
242
+ if (notFoundPage?.hasRevalidateExport) throw new Error(`[pracht] Pages not-found module ${JSON.stringify(notFoundPage.relativePath)} exports REVALIDATE, but not-found responses are never ISG routes.`);
181
243
  for (const page of pages) {
182
244
  if (page === notFoundPage) continue;
183
245
  const render = page.renderMode ?? defaultRender;
246
+ if (render === "isg" && page.revalidateSeconds === void 0) throw new Error(`[pracht] Pages route ${JSON.stringify(page.relativePath)} uses render mode "isg" but does not export a revalidation policy. Add \`export const REVALIDATE = 60\` with a positive integer number of seconds, or use another render mode.`);
247
+ if (render !== "isg" && page.hasRevalidateExport) throw new Error(`[pracht] Pages route ${JSON.stringify(page.relativePath)} exports REVALIDATE but its effective render mode is ${JSON.stringify(render)}. REVALIDATE is only valid with \`RENDER_MODE = "isg"\` (or \`pagesDefaultRender: "isg"\`).`);
184
248
  const filePath = prefix ? `${prefix}/${page.relativePath.replace(/\\/g, "/")}` : `./${page.relativePath.replace(/\\/g, "/")}`;
185
249
  const fileRef = useImport ? `() => import(${JSON.stringify(filePath)})` : JSON.stringify(filePath);
186
250
  const metaParts = [`render: ${JSON.stringify(render)}`, `hasLoader: ${page.hasLoader ? "true" : "false"}`];
187
251
  if (page.hydrationMode) metaParts.push(`hydration: ${JSON.stringify(page.hydrationMode)}`);
252
+ if (page.revalidateSeconds !== void 0) metaParts.push(`revalidate: timeRevalidate(${page.revalidateSeconds})`);
188
253
  routeEntries.push(` route(${JSON.stringify(page.routePath)}, ${fileRef}, { ${metaParts.join(", ")} })`);
189
254
  }
190
255
  const notFoundEntry = notFoundPage ? buildNotFoundEntry(notFoundPage, {
@@ -8,6 +8,8 @@ interface ScannedPage {
8
8
  isDynamic: boolean;
9
9
  renderMode?: string;
10
10
  hydrationMode?: string;
11
+ revalidateSeconds?: number;
12
+ hasRevalidateExport?: boolean;
11
13
  hasLoader?: boolean;
12
14
  }
13
15
  interface PagesRouterOptions {
@@ -1,2 +1,2 @@
1
- import { a as sortRoutes, i as scanPagesDirectory, n as generatePagesManifestSource, r as generateRoutesFile, t as filePathToRoutePath } from "./pages-router-CStJY2Zp.mjs";
1
+ import { a as sortRoutes, i as scanPagesDirectory, n as generatePagesManifestSource, r as generateRoutesFile, t as filePathToRoutePath } from "./pages-router-BQlG21oC.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.7.4",
3
+ "version": "0.8.0",
4
4
  "description": "Vite plugin for Pracht apps with virtual modules, dev SSR, prerendering, route inspection, and multi-adapter builds.",
5
5
  "keywords": [
6
6
  "pracht",
@@ -44,10 +44,10 @@
44
44
  "dependencies": {
45
45
  "@preact/preset-vite": "^2.10.5",
46
46
  "@prefresh/vite": "^2.0.0",
47
- "@pracht/core": "0.11.3",
48
- "@pracht/adapter-node": "0.3.6",
49
- "@pracht/preact-ssr-precompile": "0.1.2",
50
- "@pracht/capabilities": "0.1.1"
47
+ "@pracht/adapter-node": "0.3.9",
48
+ "@pracht/capabilities": "0.2.0",
49
+ "@pracht/core": "0.13.0",
50
+ "@pracht/preact-ssr-precompile": "0.1.3"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "vite": "^8.0.0"