@pracht/vite-plugin 0.4.4 → 0.6.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
@@ -5,6 +5,41 @@ import { RenderMode, RenderMode as RenderMode$1 } from "@pracht/core";
5
5
  //#region src/plugin-assets.d.ts
6
6
  declare const PRACHT_CLIENT_MODULE_ID = "virtual:pracht/client";
7
7
  declare const PRACHT_SERVER_MODULE_ID = "virtual:pracht/server";
8
+ declare const PRACHT_ISLANDS_CLIENT_MODULE_ID = "virtual:pracht/islands-client";
9
+ //#endregion
10
+ //#region src/env-safety.d.ts
11
+ /**
12
+ * Env vars Vite defines on `import.meta.env` in every bundle, plus NODE_ENV
13
+ * which Vite's define pass statically replaces at build time (so it can never
14
+ * leak and is referenced by countless dependencies).
15
+ */
16
+ declare const VITE_BUILTIN_ENV_VARS: Set<string>;
17
+ /** Prefix that marks an env var as intentionally public. */
18
+ declare const PUBLIC_ENV_PREFIX = "PRACHT_PUBLIC_";
19
+ interface EnvSafetyOptions {
20
+ /** Env var names allowed to appear in client bundles despite not being public. */
21
+ allow?: string[];
22
+ }
23
+ interface EnvLeakReference {
24
+ accessor: "process.env" | "import.meta.env";
25
+ name: string;
26
+ }
27
+ /**
28
+ * Scans JavaScript source for references to environment variables that are
29
+ * neither public-prefixed, Vite built-ins, nor explicitly allowed.
30
+ */
31
+ declare function scanCodeForEnvLeaks(code: string, allow?: ReadonlySet<string>): EnvLeakReference[];
32
+ interface EnvLeakProblem extends EnvLeakReference {
33
+ chunk: string;
34
+ sources: string[];
35
+ }
36
+ declare function formatEnvLeakError(problems: EnvLeakProblem[]): string;
37
+ /**
38
+ * Build-time leak detection: scans rendered client chunks for references to
39
+ * non-public env vars and fails the build with the variable, chunk, and the
40
+ * likely source module.
41
+ */
42
+ declare function createEnvSafetyPlugin(envSafety: false | EnvSafetyOptions): Plugin;
8
43
  //#endregion
9
44
  //#region src/plugin-adapter.d.ts
10
45
  /**
@@ -56,6 +91,11 @@ interface PrachtPluginOptions {
56
91
  middlewareDir?: string;
57
92
  apiDir?: string;
58
93
  serverDir?: string;
94
+ /**
95
+ * Directory containing island components hydrated on
96
+ * `hydration: "islands"` routes. Defaults to "/src/islands".
97
+ */
98
+ islandsDir?: string;
59
99
  adapter?: PrachtAdapter;
60
100
  /** Enable file-system pages routing by pointing to the pages directory (e.g. "/src/pages"). */
61
101
  pagesDir?: string;
@@ -65,17 +105,39 @@ interface PrachtPluginOptions {
65
105
  prerenderConcurrency?: number;
66
106
  /** Maximum request body size (bytes) accepted by the dev SSR middleware. Defaults to 1 MiB. */
67
107
  maxBodySize?: number;
108
+ /**
109
+ * Per-route gzip client-JS budgets evaluated by `pracht build`, e.g.
110
+ * `{ "*": "120kb", "/dashboard": "200kb" }`. `"*"` applies to every route;
111
+ * explicit route paths override it. Values are byte counts or size strings
112
+ * ("120kb", "1mb"). Exceeded budgets fail the build unless
113
+ * `pracht build --no-budget-fail` is used.
114
+ */
115
+ budgets?: Record<string, string | number>;
68
116
  /**
69
117
  * Opt into precompiling safe Preact JSX DOM subtrees for SSR/SSG server bundles.
70
118
  * Client bundles keep the normal Preact JSX transform for hydration.
71
119
  */
72
120
  precompileSsrJsx?: boolean | PreactSsrPrecompileOptions;
121
+ /**
122
+ * Client-bundle env leak detection. Enabled by default: production client
123
+ * chunks referencing `process.env.X` / `import.meta.env.X` for a non-public
124
+ * variable fail the build. Pass `{ allow: ["NAME"] }` to permit specific
125
+ * variables, or `false` to disable the check entirely.
126
+ */
127
+ envSafety?: false | EnvSafetyOptions;
73
128
  }
74
129
  //#endregion
75
130
  //#region src/plugin-codegen.d.ts
76
131
  declare function createPrachtClientModuleSource(options?: PrachtPluginOptions, buildOptions?: {
77
132
  root?: string;
78
133
  }): string;
134
+ /**
135
+ * Source of `virtual:pracht/islands-client` — the tiny bootstrap loaded by
136
+ * `hydration: "islands"` routes. It deliberately does NOT import the app
137
+ * manifest, the router, or the full client runtime: it only scans the DOM
138
+ * for island markers and hydrates the islands present on the page.
139
+ */
140
+ declare function createPrachtIslandsClientModuleSource(options?: PrachtPluginOptions): string;
79
141
  declare function createPrachtServerModuleSource(options?: PrachtPluginOptions, buildOptions?: {
80
142
  root?: string;
81
143
  isBuild?: boolean;
@@ -85,4 +147,4 @@ declare function createPrachtRegistryModuleSource(options?: PrachtPluginOptions)
85
147
  //#region src/index.d.ts
86
148
  declare function pracht(options?: PrachtPluginOptions): Plugin[];
87
149
  //#endregion
88
- export { PRACHT_CLIENT_MODULE_ID, PRACHT_SERVER_MODULE_ID, type PrachtAdapter, type PrachtPluginOptions, type RenderMode, createPrachtClientModuleSource, createPrachtRegistryModuleSource, createPrachtServerModuleSource, pracht };
150
+ export { type EnvLeakReference, type EnvSafetyOptions, PRACHT_CLIENT_MODULE_ID, PRACHT_ISLANDS_CLIENT_MODULE_ID, PRACHT_SERVER_MODULE_ID, PUBLIC_ENV_PREFIX, type PrachtAdapter, type PrachtPluginOptions, type RenderMode, VITE_BUILTIN_ENV_VARS, createEnvSafetyPlugin, createPrachtClientModuleSource, createPrachtIslandsClientModuleSource, createPrachtRegistryModuleSource, createPrachtServerModuleSource, formatEnvLeakError, pracht, scanCodeForEnvLeaks };
package/dist/index.mjs CHANGED
@@ -1,9 +1,10 @@
1
- import { i as scanPagesDirectory, n as generatePagesManifestSource, o as createRouteLoaderHints } from "./pages-router-BN3V7ii7.mjs";
1
+ import { i as scanPagesDirectory, n as generatePagesManifestSource, o as createRouteLoaderHints } from "./pages-router-BsVlzz-e.mjs";
2
+ import { createRequire } from "node:module";
2
3
  import { preactSsrPrecompile } from "@pracht/preact-ssr-precompile";
3
4
  import preact from "@preact/preset-vite";
4
- import { dirname, resolve } from "node:path";
5
+ import { dirname, extname, join, resolve } from "node:path";
5
6
  import { parseAst } from "vite";
6
- import { existsSync, readFileSync } from "node:fs";
7
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
7
8
  import { createNodeServerEntryModule } from "@pracht/adapter-node";
8
9
  //#region src/client-module-query.ts
9
10
  const CLIENT_MODULE_QUERY = "pracht-client";
@@ -887,20 +888,323 @@ function enqueueDependencies(target, dependencies) {
887
888
  for (const name of dependencies) target.add(name);
888
889
  }
889
890
  //#endregion
891
+ //#region src/env-safety.ts
892
+ /**
893
+ * Env vars Vite defines on `import.meta.env` in every bundle, plus NODE_ENV
894
+ * which Vite's define pass statically replaces at build time (so it can never
895
+ * leak and is referenced by countless dependencies).
896
+ */
897
+ const VITE_BUILTIN_ENV_VARS = new Set([
898
+ "MODE",
899
+ "DEV",
900
+ "PROD",
901
+ "SSR",
902
+ "BASE_URL",
903
+ "NODE_ENV"
904
+ ]);
905
+ /** Prefix that marks an env var as intentionally public. */
906
+ const PUBLIC_ENV_PREFIX = "PRACHT_PUBLIC_";
907
+ /** Server-only core entry that must never resolve into client bundles. */
908
+ const SERVER_ENV_MODULE_ID = "@pracht/core/env/server";
909
+ const ENV_REFERENCE_RE = /\b(process\.env|import\.meta\.env)(?:\.([A-Za-z_$][A-Za-z0-9_$]*)|\[\s*(["'])([A-Za-z_$][A-Za-z0-9_$]*)\3\s*\])/g;
910
+ /**
911
+ * Scans JavaScript source for references to environment variables that are
912
+ * neither public-prefixed, Vite built-ins, nor explicitly allowed.
913
+ */
914
+ function scanCodeForEnvLeaks(code, allow = /* @__PURE__ */ new Set()) {
915
+ const findings = [];
916
+ const seen = /* @__PURE__ */ new Set();
917
+ const codePositions = getCodePositionMask(code);
918
+ for (const match of code.matchAll(ENV_REFERENCE_RE)) {
919
+ if (!codePositions[match.index ?? -1]) continue;
920
+ const accessor = match[1];
921
+ const name = match[2] ?? match[4];
922
+ if (!name) continue;
923
+ if (name.startsWith("PRACHT_PUBLIC_")) continue;
924
+ if (VITE_BUILTIN_ENV_VARS.has(name)) continue;
925
+ if (allow.has(name)) continue;
926
+ const key = `${accessor}.${name}`;
927
+ if (seen.has(key)) continue;
928
+ seen.add(key);
929
+ findings.push({
930
+ accessor,
931
+ name
932
+ });
933
+ }
934
+ return findings;
935
+ }
936
+ function getCodePositionMask(code) {
937
+ const mask = new Uint8Array(code.length);
938
+ const templateExpressionDepths = [];
939
+ let mode = "code";
940
+ let regexCharClass = false;
941
+ let i = 0;
942
+ while (i < code.length) {
943
+ const char = code[i];
944
+ const next = code[i + 1];
945
+ if (mode === "line-comment") {
946
+ if (char === "\n" || char === "\r") {
947
+ mode = "code";
948
+ mask[i] = 1;
949
+ }
950
+ i++;
951
+ continue;
952
+ }
953
+ if (mode === "block-comment") {
954
+ if (char === "*" && next === "/") {
955
+ mode = "code";
956
+ i += 2;
957
+ } else i++;
958
+ continue;
959
+ }
960
+ if (mode === "single" || mode === "double") {
961
+ const quote = mode === "single" ? "'" : "\"";
962
+ if (char === "\\") {
963
+ i += 2;
964
+ continue;
965
+ }
966
+ if (char === quote || char === "\n" || char === "\r") mode = "code";
967
+ i++;
968
+ continue;
969
+ }
970
+ if (mode === "regex") {
971
+ if (char === "\\") {
972
+ i += 2;
973
+ continue;
974
+ }
975
+ if (char === "[") {
976
+ regexCharClass = true;
977
+ i++;
978
+ continue;
979
+ }
980
+ if (char === "]") {
981
+ regexCharClass = false;
982
+ i++;
983
+ continue;
984
+ }
985
+ if (char === "/" && !regexCharClass) {
986
+ regexCharClass = false;
987
+ i++;
988
+ while (i < code.length && isIdentifierChar(code[i])) i++;
989
+ mode = "code";
990
+ continue;
991
+ }
992
+ if (char === "\n" || char === "\r") {
993
+ regexCharClass = false;
994
+ mode = "code";
995
+ }
996
+ i++;
997
+ continue;
998
+ }
999
+ if (mode === "template") {
1000
+ if (char === "\\") {
1001
+ i += 2;
1002
+ continue;
1003
+ }
1004
+ if (char === "`") {
1005
+ mode = "code";
1006
+ i++;
1007
+ continue;
1008
+ }
1009
+ if (char === "$" && next === "{") {
1010
+ mask[i] = 1;
1011
+ mask[i + 1] = 1;
1012
+ templateExpressionDepths.push(1);
1013
+ mode = "code";
1014
+ i += 2;
1015
+ continue;
1016
+ }
1017
+ i++;
1018
+ continue;
1019
+ }
1020
+ mask[i] = 1;
1021
+ if (char === "/" && next === "/") {
1022
+ mask[i + 1] = 1;
1023
+ mode = "line-comment";
1024
+ i += 2;
1025
+ continue;
1026
+ }
1027
+ if (char === "/" && next === "*") {
1028
+ mask[i + 1] = 1;
1029
+ mode = "block-comment";
1030
+ i += 2;
1031
+ continue;
1032
+ }
1033
+ if (char === "/" && isRegexLiteralStart(code, i)) {
1034
+ mode = "regex";
1035
+ regexCharClass = false;
1036
+ i++;
1037
+ continue;
1038
+ }
1039
+ if (char === "'") {
1040
+ mode = "single";
1041
+ i++;
1042
+ continue;
1043
+ }
1044
+ if (char === "\"") {
1045
+ mode = "double";
1046
+ i++;
1047
+ continue;
1048
+ }
1049
+ if (char === "`") {
1050
+ mode = "template";
1051
+ i++;
1052
+ continue;
1053
+ }
1054
+ if (templateExpressionDepths.length > 0) {
1055
+ const top = templateExpressionDepths.length - 1;
1056
+ if (char === "{") templateExpressionDepths[top]++;
1057
+ else if (char === "}") {
1058
+ templateExpressionDepths[top]--;
1059
+ if (templateExpressionDepths[top] === 0) {
1060
+ templateExpressionDepths.pop();
1061
+ mode = "template";
1062
+ }
1063
+ }
1064
+ }
1065
+ i++;
1066
+ }
1067
+ return mask;
1068
+ }
1069
+ function isRegexLiteralStart(code, slashIndex) {
1070
+ let i = slashIndex - 1;
1071
+ while (i >= 0 && /\s/.test(code[i])) i--;
1072
+ if (i < 0) return true;
1073
+ const previous = code[i];
1074
+ if (previous === ">" && code[i - 1] === "=") return true;
1075
+ if ("([{=,:;!?&|^~<>*%+-".includes(previous)) return true;
1076
+ if (isIdentifierChar(previous)) {
1077
+ let start = i;
1078
+ while (start >= 0 && isIdentifierChar(code[start])) start--;
1079
+ const word = code.slice(start + 1, i + 1);
1080
+ return new Set([
1081
+ "await",
1082
+ "case",
1083
+ "delete",
1084
+ "do",
1085
+ "else",
1086
+ "in",
1087
+ "instanceof",
1088
+ "new",
1089
+ "of",
1090
+ "return",
1091
+ "throw",
1092
+ "typeof",
1093
+ "void",
1094
+ "yield"
1095
+ ]).has(word);
1096
+ }
1097
+ return false;
1098
+ }
1099
+ function isIdentifierChar(char) {
1100
+ return !!char && /[A-Za-z0-9_$]/.test(char);
1101
+ }
1102
+ function formatEnvLeakError(problems) {
1103
+ return [
1104
+ "[pracht] Environment variable leak detected in the client bundle:",
1105
+ ...problems.map((problem) => {
1106
+ const source = problem.sources.length > 0 ? ` (likely from ${problem.sources.map((file) => JSON.stringify(file)).join(", ")})` : "";
1107
+ return ` - ${problem.accessor}.${problem.name} in chunk "${problem.chunk}"${source}`;
1108
+ }),
1109
+ "",
1110
+ `Only PRACHT_PUBLIC_-prefixed variables may be referenced in client code (prefer publicEnv from "@pracht/core" for typed public values).`,
1111
+ `Move server-only reads into loaders/API routes and access them via serverEnv from "@pracht/core/env/server",`,
1112
+ "or allowlist intentionally-safe names with pracht({ envSafety: { allow: [...] } })."
1113
+ ].join("\n");
1114
+ }
1115
+ function stripIdQuery(id) {
1116
+ const queryStart = id.indexOf("?");
1117
+ return queryStart === -1 ? id : id.slice(0, queryStart);
1118
+ }
1119
+ /**
1120
+ * Build-time leak detection: scans rendered client chunks for references to
1121
+ * non-public env vars and fails the build with the variable, chunk, and the
1122
+ * likely source module.
1123
+ */
1124
+ function createEnvSafetyPlugin(envSafety) {
1125
+ const allow = new Set(envSafety === false ? [] : envSafety.allow ?? []);
1126
+ const moduleEnvReferences = /* @__PURE__ */ new Map();
1127
+ let isSsrBuild = false;
1128
+ return {
1129
+ name: "pracht:env-safety",
1130
+ apply: "build",
1131
+ enforce: "post",
1132
+ configResolved(config) {
1133
+ isSsrBuild = !!config.build.ssr;
1134
+ },
1135
+ transform(code, id, transformOptions) {
1136
+ if (envSafety === false) return null;
1137
+ if (transformOptions?.ssr) return null;
1138
+ const moduleId = stripIdQuery(id);
1139
+ if (moduleId.includes("node_modules")) return null;
1140
+ const findings = scanCodeForEnvLeaks(code, allow);
1141
+ if (findings.length > 0) moduleEnvReferences.set(moduleId, findings);
1142
+ return null;
1143
+ },
1144
+ generateBundle(_options, bundle) {
1145
+ if (envSafety === false) return;
1146
+ const consumer = this.environment?.config?.consumer;
1147
+ if (!(consumer ? consumer === "client" : !isSsrBuild)) return;
1148
+ const problems = [];
1149
+ const seen = /* @__PURE__ */ new Set();
1150
+ const addProblem = (problem) => {
1151
+ const key = `${problem.chunk}:${problem.accessor}.${problem.name}`;
1152
+ if (seen.has(key)) return;
1153
+ seen.add(key);
1154
+ problems.push(problem);
1155
+ };
1156
+ for (const [fileName, output] of Object.entries(bundle)) {
1157
+ if (output.type !== "chunk") continue;
1158
+ const moduleIds = (output.moduleIds ?? Object.keys(output.modules ?? {})).map(stripIdQuery);
1159
+ for (const moduleId of moduleIds) {
1160
+ const references = moduleEnvReferences.get(moduleId);
1161
+ if (!references) continue;
1162
+ for (const reference of references) addProblem({
1163
+ ...reference,
1164
+ chunk: fileName,
1165
+ sources: [moduleId]
1166
+ });
1167
+ }
1168
+ for (const finding of scanCodeForEnvLeaks(output.code, allow)) {
1169
+ const sources = moduleIds.filter((moduleId) => moduleEnvReferences.get(moduleId)?.some((reference) => reference.name === finding.name));
1170
+ addProblem({
1171
+ ...finding,
1172
+ chunk: fileName,
1173
+ sources
1174
+ });
1175
+ }
1176
+ }
1177
+ if (problems.length > 0) this.error(formatEnvLeakError(problems));
1178
+ this.emitFile({
1179
+ fileName: "_pracht/env-safety.json",
1180
+ source: JSON.stringify({
1181
+ findings: problems,
1182
+ version: 1
1183
+ }, null, 2),
1184
+ type: "asset"
1185
+ });
1186
+ }
1187
+ };
1188
+ }
1189
+ //#endregion
890
1190
  //#region src/plugin-assets.ts
891
1191
  const PRACHT_CLIENT_MODULE_ID = "virtual:pracht/client";
892
1192
  const PRACHT_SERVER_MODULE_ID = "virtual:pracht/server";
1193
+ const PRACHT_ISLANDS_CLIENT_MODULE_ID = "virtual:pracht/islands-client";
893
1194
  const CLIENT_BROWSER_PATH = "/@pracht/client.js";
1195
+ const ISLANDS_CLIENT_BROWSER_PATH = "/@pracht/islands.js";
894
1196
  function readClientBuildAssets(root = process.cwd()) {
895
1197
  const manifestPath = ["dist/client/.vite/manifest.json", "dist/.vite/manifest.json"].map((candidate) => resolve(root, candidate)).find((candidate) => existsSync(candidate));
896
1198
  if (!manifestPath) return {
897
1199
  clientEntryUrl: null,
1200
+ islandsEntryUrl: null,
898
1201
  cssManifest: {},
899
1202
  jsManifest: {}
900
1203
  };
901
1204
  const rawManifest = readFileSync(manifestPath, "utf-8");
902
1205
  const manifest = JSON.parse(rawManifest);
903
1206
  const clientEntry = manifest[PRACHT_CLIENT_MODULE_ID];
1207
+ const islandsEntry = manifest[PRACHT_ISLANDS_CLIENT_MODULE_ID];
904
1208
  const cssManifest = {};
905
1209
  const jsManifest = {};
906
1210
  for (const [key, entry] of Object.entries(manifest)) {
@@ -910,12 +1214,20 @@ function readClientBuildAssets(root = process.cwd()) {
910
1214
  if (deps.css.length > 0) cssManifest[manifestKey] = deps.css.map((f) => `/${f}`);
911
1215
  if (deps.js.length > 0) jsManifest[manifestKey] = deps.js.map((f) => `/${f}`);
912
1216
  }
1217
+ addEntryDeps(manifest, jsManifest, PRACHT_CLIENT_MODULE_ID, clientEntry);
1218
+ addEntryDeps(manifest, jsManifest, PRACHT_ISLANDS_CLIENT_MODULE_ID, islandsEntry);
913
1219
  return {
914
1220
  clientEntryUrl: clientEntry ? `/${clientEntry.file}` : null,
1221
+ islandsEntryUrl: islandsEntry ? `/${islandsEntry.file}` : null,
915
1222
  cssManifest,
916
1223
  jsManifest
917
1224
  };
918
1225
  }
1226
+ function addEntryDeps(manifest, jsManifest, entryKey, entry) {
1227
+ if (!entry) return;
1228
+ const deps = collectTransitiveDeps(manifest, entryKey).js.filter((file) => file !== entry.file);
1229
+ if (deps.length > 0) jsManifest[entryKey] = deps.map((file) => `/${file}`);
1230
+ }
919
1231
  function collectTransitiveDeps(manifest, key) {
920
1232
  const css = /* @__PURE__ */ new Set();
921
1233
  const js = /* @__PURE__ */ new Set();
@@ -941,6 +1253,9 @@ function isClientModule(id) {
941
1253
  function isServerModule(id) {
942
1254
  return id === "virtual:pracht/server" || id.endsWith("virtual:pracht/server");
943
1255
  }
1256
+ function isIslandsClientModule(id) {
1257
+ return id === "virtual:pracht/islands-client" || id === "/@pracht/islands.js" || id.endsWith("virtual:pracht/islands-client");
1258
+ }
944
1259
  //#endregion
945
1260
  //#region src/plugin-adapter.ts
946
1261
  function createDefaultNodeAdapter() {
@@ -961,12 +1276,15 @@ const DEFAULTS = {
961
1276
  shellsDir: "/src/shells",
962
1277
  apiDir: "/src/api",
963
1278
  serverDir: "/src/server",
1279
+ islandsDir: "/src/islands",
964
1280
  adapter: createDefaultNodeAdapter(),
965
1281
  pagesDir: "",
966
1282
  pagesDefaultRender: "ssr",
967
1283
  prerenderConcurrency: 10,
968
1284
  maxBodySize: 1024 * 1024,
969
- precompileSsrJsx: false
1285
+ budgets: {},
1286
+ precompileSsrJsx: false,
1287
+ envSafety: {}
970
1288
  };
971
1289
  function resolveOptions(options) {
972
1290
  const resolved = {
@@ -975,10 +1293,115 @@ function resolveOptions(options) {
975
1293
  };
976
1294
  if (!Number.isInteger(resolved.prerenderConcurrency) || resolved.prerenderConcurrency <= 0) throw new Error("pracht({ prerenderConcurrency }) expects a positive integer.");
977
1295
  if (!Number.isInteger(resolved.maxBodySize) || resolved.maxBodySize <= 0) throw new Error("pracht({ maxBodySize }) expects a positive integer number of bytes.");
1296
+ validateBudgets(resolved.budgets);
978
1297
  return resolved;
979
1298
  }
1299
+ function validateBudgets(budgets) {
1300
+ for (const [key, value] of Object.entries(budgets)) {
1301
+ if (key !== "*" && !key.startsWith("/")) throw new Error(`pracht({ budgets }) keys must be "*" or a route path starting with "/", got ${JSON.stringify(key)}.`);
1302
+ const isValidNumber = typeof value === "number" && Number.isFinite(value) && value > 0;
1303
+ const isValidString = typeof value === "string" && value.trim().length > 0;
1304
+ if (!isValidNumber && !isValidString) throw new Error(`pracht({ budgets }) values must be a positive number of bytes or a size string like "120kb", got ${JSON.stringify(value)} for ${JSON.stringify(key)}.`);
1305
+ }
1306
+ }
980
1307
  //#endregion
981
1308
  //#region src/plugin-codegen.ts
1309
+ const ROUTE_MODULE_EXTENSIONS = new Set([
1310
+ ".ts",
1311
+ ".tsx",
1312
+ ".js",
1313
+ ".jsx",
1314
+ ".md",
1315
+ ".mdx",
1316
+ ".tsrx"
1317
+ ]);
1318
+ const NON_FULL_HYDRATION_RE = /hydration\s*:\s*["'](?:islands|none)["']/;
1319
+ const FULL_HYDRATION_RE = /hydration\s*:\s*["']full["']/;
1320
+ const PAGES_NON_FULL_HYDRATION_RE = /export\s+const\s+HYDRATION\s*=\s*["'](?:islands|none)["']/;
1321
+ function toPosixPath$1(path) {
1322
+ return path.replace(/\\/g, "/");
1323
+ }
1324
+ function findMatching(source, start, open, close) {
1325
+ let depth = 0;
1326
+ for (let i = start; i < source.length; i++) {
1327
+ const ch = source[i];
1328
+ if (ch === open) depth++;
1329
+ if (ch === close) {
1330
+ depth--;
1331
+ if (depth === 0) return i;
1332
+ }
1333
+ }
1334
+ return -1;
1335
+ }
1336
+ function scanFiles(dir, files) {
1337
+ let entries;
1338
+ try {
1339
+ entries = readdirSync(dir);
1340
+ } catch {
1341
+ return;
1342
+ }
1343
+ for (const entry of entries) {
1344
+ const abs = join(dir, entry);
1345
+ let stat;
1346
+ try {
1347
+ stat = statSync(abs);
1348
+ } catch {
1349
+ continue;
1350
+ }
1351
+ if (stat.isDirectory()) scanFiles(abs, files);
1352
+ else if (ROUTE_MODULE_EXTENSIONS.has(extname(entry))) files.push(abs);
1353
+ }
1354
+ }
1355
+ function createNonFullHydrationExcludes(resolved, root = process.cwd()) {
1356
+ const excludes = /* @__PURE__ */ new Set();
1357
+ if (resolved.pagesDir) {
1358
+ const files = [];
1359
+ scanFiles(resolve(root, resolved.pagesDir.replace(/^\//, "")), files);
1360
+ for (const file of files) try {
1361
+ if (PAGES_NON_FULL_HYDRATION_RE.test(readFileSync(file, "utf-8"))) excludes.add(`!/${toPosixPath$1(file).replace(toPosixPath$1(root).replace(/\/$/, "") + "/", "")}`);
1362
+ } catch {}
1363
+ return [...excludes];
1364
+ }
1365
+ const appFile = resolve(root, resolved.appFile.replace(/^\//, ""));
1366
+ let source;
1367
+ try {
1368
+ source = readFileSync(appFile, "utf-8");
1369
+ } catch {
1370
+ return [];
1371
+ }
1372
+ const groups = [];
1373
+ for (const match of source.matchAll(/\bgroup\s*\(/g)) {
1374
+ const parenStart = match.index + match[0].lastIndexOf("(");
1375
+ const parenEnd = findMatching(source, parenStart, "(", ")");
1376
+ if (parenEnd === -1) continue;
1377
+ const args = source.slice(parenStart + 1, parenEnd);
1378
+ const arrayStart = source.indexOf("[", parenStart);
1379
+ if (arrayStart === -1 || arrayStart > parenEnd) continue;
1380
+ const arrayEnd = findMatching(source, arrayStart, "[", "]");
1381
+ if (arrayEnd === -1) continue;
1382
+ groups.push({
1383
+ start: arrayStart,
1384
+ end: arrayEnd,
1385
+ nonFull: NON_FULL_HYDRATION_RE.test(args.split("[")[0] ?? "")
1386
+ });
1387
+ }
1388
+ const appDir = dirname(appFile);
1389
+ for (const match of source.matchAll(/\broute\s*\(\s*[^,]+,\s*(?:(?:\(\s*\)\s*=>\s*import\s*\(\s*)?["']([^"']+)["']\s*\)?|["']([^"']+)["'])/g)) {
1390
+ const fileRef = match[1] ?? match[2];
1391
+ const callStart = match.index;
1392
+ const parenStart = source.indexOf("(", callStart);
1393
+ const parenEnd = findMatching(source, parenStart, "(", ")");
1394
+ if (parenEnd === -1) continue;
1395
+ const callSource = source.slice(parenStart, parenEnd);
1396
+ const ownNonFull = NON_FULL_HYDRATION_RE.test(callSource);
1397
+ const ownFull = FULL_HYDRATION_RE.test(callSource);
1398
+ const inheritedNonFull = groups.filter((group) => group.start < callStart && callStart < group.end).sort((a, b) => b.start - a.start)[0]?.nonFull;
1399
+ if (ownFull || !ownNonFull && inheritedNonFull !== true) continue;
1400
+ const abs = resolve(appDir, fileRef);
1401
+ excludes.add(`!/${toPosixPath$1(abs).replace(toPosixPath$1(root).replace(/\/$/, "") + "/", "")}`);
1402
+ }
1403
+ return [...excludes];
1404
+ }
982
1405
  function createPrachtClientModuleSource(options = {}, buildOptions = {}) {
983
1406
  const resolved = resolveOptions(options);
984
1407
  const isPagesMode = !!resolved.pagesDir;
@@ -987,16 +1410,21 @@ function createPrachtClientModuleSource(options = {}, buildOptions = {}) {
987
1410
  const dirPrefix = isPagesMode ? resolved.pagesDir : resolved.routesDir;
988
1411
  const routeGlob = `${dirPrefix}/**/*.{ts,tsx,js,jsx,md,mdx}`;
989
1412
  const routeTsrxGlob = `${dirPrefix}/**/*.tsrx`;
1413
+ const routeExcludes = createNonFullHydrationExcludes(resolved, buildOptions.root);
1414
+ const routeGlobPattern = routeExcludes.length > 0 ? [routeGlob, ...routeExcludes] : routeGlob;
1415
+ const routeTsrxGlobPattern = routeExcludes.length > 0 ? [routeTsrxGlob, ...routeExcludes] : routeTsrxGlob;
990
1416
  const shellGlob = isPagesMode ? `${resolved.pagesDir}/**/_app.{ts,tsx,js,jsx}` : `${resolved.shellsDir}/**/*.{ts,tsx,js,jsx,md,mdx}`;
991
1417
  const shellTsrxGlob = isPagesMode ? `${resolved.pagesDir}/**/_app.tsrx` : `${resolved.shellsDir}/**/*.tsrx`;
1418
+ const appFilePosix = resolved.appFile.replace(/\\/g, "/").replace(/^\.\//, "");
1419
+ const appDir = (appFilePosix.startsWith("/") ? appFilePosix : `/${appFilePosix}`).replace(/\/[^/]*$/, "") || "/";
992
1420
  return [
993
1421
  "import { resolveApp, initClientRouter, readHydrationState } from \"@pracht/core/client\";",
994
1422
  appImport,
995
1423
  "",
996
1424
  `const routeLoaderHints = ${JSON.stringify(routeLoaderHints)};`,
997
1425
  `const routeModules = {`,
998
- ` ...import.meta.glob(${JSON.stringify(routeGlob)}, { query: ${JSON.stringify(PRACHT_CLIENT_MODULE_QUERY)} }),`,
999
- ` ...import.meta.glob(${JSON.stringify(routeTsrxGlob)}),`,
1426
+ ` ...import.meta.glob(${JSON.stringify(routeGlobPattern)}, { query: ${JSON.stringify(PRACHT_CLIENT_MODULE_QUERY)} }),`,
1427
+ ` ...import.meta.glob(${JSON.stringify(routeTsrxGlobPattern)}),`,
1000
1428
  `};`,
1001
1429
  `const shellModules = {`,
1002
1430
  ` ...import.meta.glob(${JSON.stringify(shellGlob)}, { query: ${JSON.stringify(PRACHT_CLIENT_MODULE_QUERY)} }),`,
@@ -1007,8 +1435,22 @@ function createPrachtClientModuleSource(options = {}, buildOptions = {}) {
1007
1435
  "applyRouteLoaderHints(resolvedApp, routeLoaderHints);",
1008
1436
  "",
1009
1437
  ...createApplyRouteLoaderHintsSource(),
1010
- "function normalizeModuleKey(key) {",
1011
- " return key.split(\"?\")[0].replace(/^\\.?\\//, \"\");",
1438
+ `const APP_DIR = ${JSON.stringify(appDir)};`,
1439
+ "",
1440
+ "// Manifest refs are written relative to the app manifest file",
1441
+ "// (\"./routes/home.tsx\") while import.meta.glob keys are root-absolute",
1442
+ "// (\"/src/routes/home.tsx\"). Both sides canonicalize against APP_DIR —",
1443
+ "// known at build time — replacing the previous runtime suffix index.",
1444
+ "function canonicalModuleKey(path) {",
1445
+ " const raw = path.split(\"?\")[0];",
1446
+ " const joined = raw.startsWith(\"/\") ? raw : APP_DIR + \"/\" + raw;",
1447
+ " const parts = [];",
1448
+ " for (const segment of joined.split(\"/\")) {",
1449
+ " if (!segment || segment === \".\") continue;",
1450
+ " if (segment === \"..\") parts.pop();",
1451
+ " else parts.push(segment);",
1452
+ " }",
1453
+ " return \"/\" + parts.join(\"/\");",
1012
1454
  "}",
1013
1455
  "",
1014
1456
  "const moduleKeyIndexes = new WeakMap();",
@@ -1016,22 +1458,34 @@ function createPrachtClientModuleSource(options = {}, buildOptions = {}) {
1016
1458
  " let index = moduleKeyIndexes.get(modules);",
1017
1459
  " if (index) return index;",
1018
1460
  " index = new Map();",
1019
- " for (const key of Object.keys(modules)) {",
1020
- " const normalized = normalizeModuleKey(key);",
1021
- " if (!normalized) continue;",
1022
- " if (!index.has(normalized)) index.set(normalized, key);",
1023
- " for (let i = normalized.indexOf(\"/\"); i !== -1; i = normalized.indexOf(\"/\", i + 1)) {",
1024
- " const suffix = normalized.slice(i + 1);",
1025
- " if (suffix && !index.has(suffix)) index.set(suffix, key);",
1026
- " }",
1027
- " }",
1461
+ " for (const key of Object.keys(modules)) index.set(canonicalModuleKey(key), key);",
1028
1462
  " moduleKeyIndexes.set(modules, index);",
1029
1463
  " return index;",
1030
1464
  "}",
1031
1465
  "",
1032
1466
  "function findModuleKey(modules, file) {",
1033
1467
  " if (file in modules) return file;",
1034
- " return getModuleKeyIndex(modules).get(normalizeModuleKey(file)) ?? null;",
1468
+ " const key = getModuleKeyIndex(modules).get(canonicalModuleKey(file));",
1469
+ " if (key != null) return key;",
1470
+ " if (import.meta.env?.DEV) {",
1471
+ " // Dev-only lenient fallback so refs that never canonicalize (written",
1472
+ " // relative to a file other than the app manifest) keep working while",
1473
+ " // the console error tells the author to fix them — production builds",
1474
+ " // resolve strictly and drop this branch.",
1475
+ " const suffix = \"/\" + file.split(\"?\")[0].replace(/^\\.?\\//, \"\");",
1476
+ " for (const candidate of Object.keys(modules)) {",
1477
+ " if (canonicalModuleKey(candidate).endsWith(suffix)) {",
1478
+ " console.error(",
1479
+ " `[pracht] Module ref ${JSON.stringify(file)} only resolved by suffix matching ` +",
1480
+ " `against ${JSON.stringify(candidate)}. Write manifest refs relative to the app ` +",
1481
+ " `manifest file (e.g. \"./routes/home.tsx\") — suffix matching is disabled in ` +",
1482
+ " `production builds.`,",
1483
+ " );",
1484
+ " return candidate;",
1485
+ " }",
1486
+ " }",
1487
+ " }",
1488
+ " return null;",
1035
1489
  "}",
1036
1490
  "",
1037
1491
  "const state = readHydrationState();",
@@ -1049,6 +1503,23 @@ function createPrachtClientModuleSource(options = {}, buildOptions = {}) {
1049
1503
  ""
1050
1504
  ].join("\n");
1051
1505
  }
1506
+ /**
1507
+ * Source of `virtual:pracht/islands-client` — the tiny bootstrap loaded by
1508
+ * `hydration: "islands"` routes. It deliberately does NOT import the app
1509
+ * manifest, the router, or the full client runtime: it only scans the DOM
1510
+ * for island markers and hydrates the islands present on the page.
1511
+ */
1512
+ function createPrachtIslandsClientModuleSource(options = {}) {
1513
+ const islandsGlob = `${resolveOptions(options).islandsDir}/**/*.{ts,tsx,js,jsx}`;
1514
+ return [
1515
+ "import { hydrateIslands } from \"@pracht/core/islands-client\";",
1516
+ "",
1517
+ `const islandModules = import.meta.glob(${JSON.stringify(islandsGlob)});`,
1518
+ "",
1519
+ "hydrateIslands({ modules: islandModules });",
1520
+ ""
1521
+ ].join("\n");
1522
+ }
1052
1523
  function createPrachtServerModuleSource(options = {}, buildOptions = {}) {
1053
1524
  const resolved = resolveOptions(options);
1054
1525
  const isPagesMode = !!resolved.pagesDir;
@@ -1056,26 +1527,41 @@ function createPrachtServerModuleSource(options = {}, buildOptions = {}) {
1056
1527
  const routeLoaderHints = createRouteLoaderHintsForVirtualModules(resolved, buildOptions.root);
1057
1528
  const clientBuild = buildOptions.isBuild ? readClientBuildAssets(buildOptions.root) : {
1058
1529
  clientEntryUrl: null,
1530
+ islandsEntryUrl: null,
1059
1531
  cssManifest: {},
1060
1532
  jsManifest: {}
1061
1533
  };
1062
1534
  const adapter = resolved.adapter;
1535
+ const prachtImports = adapter?.serverImports ? adapter.serverImports + "\nimport { prerenderApp } from \"@pracht/core/server\";" : "import { resolveApp, resolveApiRoutes, prerenderApp } from \"@pracht/core/server\";";
1536
+ const appImport = isPagesMode ? generatePagesAppInlineSource(resolved, buildOptions.root) : `import { app } from ${JSON.stringify(resolved.appFile)};`;
1537
+ const islandsEntryUrl = buildOptions.isBuild ? clientBuild.islandsEntryUrl : ISLANDS_CLIENT_BROWSER_PATH;
1538
+ const islandsGlob = `${resolved.islandsDir}/**/*.{ts,tsx,js,jsx}`;
1063
1539
  const source = [
1064
- adapter?.serverImports ? adapter.serverImports + "\nimport { prerenderApp } from \"@pracht/core/server\";" : "import { resolveApp, resolveApiRoutes, prerenderApp } from \"@pracht/core/server\";",
1065
- isPagesMode ? generatePagesAppInlineSource(resolved, buildOptions.root) : `import { app } from ${JSON.stringify(resolved.appFile)};`,
1540
+ prachtImports,
1541
+ "import { registerServerIslands, setIslandsClientEntryUrl } from \"@pracht/core/server\";",
1542
+ appImport,
1066
1543
  "",
1067
1544
  `const routeLoaderHints = ${JSON.stringify(routeLoaderHints)};`,
1068
1545
  ...createApplyRouteLoaderHintsSource(),
1069
1546
  registrySource,
1070
1547
  "",
1548
+ "// Islands are registered eagerly so the server renderer can detect their",
1549
+ "// vnodes during islands-mode renders.",
1550
+ `const islandModules = import.meta.glob(${JSON.stringify(islandsGlob)}, { eager: true });`,
1551
+ "registerServerIslands(islandModules);",
1552
+ `setIslandsClientEntryUrl(${JSON.stringify(islandsEntryUrl ?? void 0)});`,
1553
+ "export const islandFiles = Object.keys(islandModules);",
1554
+ "",
1071
1555
  "export const resolvedApp = resolveApp(app);",
1072
1556
  "applyRouteLoaderHints(resolvedApp, routeLoaderHints);",
1073
1557
  `export const apiRoutes = resolveApiRoutes(Object.keys(apiModules), ${JSON.stringify(resolved.apiDir)});`,
1074
1558
  `export const buildTarget = ${JSON.stringify(adapter?.id ?? "node")};`,
1075
1559
  `export const clientEntryUrl = ${JSON.stringify(clientBuild.clientEntryUrl ?? "/@pracht/client.js")};`,
1560
+ `export const islandsEntryUrl = ${JSON.stringify(islandsEntryUrl ?? null)};`,
1076
1561
  `export const cssManifest = ${JSON.stringify(clientBuild.cssManifest)};`,
1077
1562
  `export const jsManifest = ${JSON.stringify(clientBuild.jsManifest)};`,
1078
1563
  `export const prerenderConcurrency = ${JSON.stringify(resolved.prerenderConcurrency)};`,
1564
+ `export const budgets = ${JSON.stringify(resolved.budgets)};`,
1079
1565
  "export { prerenderApp };",
1080
1566
  ""
1081
1567
  ];
@@ -1167,19 +1653,36 @@ function generatePagesAppInlineSource(options, root = process.cwd()) {
1167
1653
  //#region src/plugin-dev-ssr.ts
1168
1654
  const BODYLESS_METHODS = new Set(["GET", "HEAD"]);
1169
1655
  const DEFAULT_MAX_BODY_SIZE = 1024 * 1024;
1656
+ const DEVTOOLS_JSON_PATH = "/_pracht.json";
1170
1657
  function createDevSSRMiddleware(server, options = {}) {
1171
1658
  const maxBodySize = options.maxBodySize ?? DEFAULT_MAX_BODY_SIZE;
1659
+ let warnedDevtoolsCollision = false;
1172
1660
  return async (req, res, next) => {
1173
1661
  const url = req.url ?? "/";
1174
1662
  const requestUrl = new URL(url, "http://localhost");
1175
1663
  try {
1176
1664
  const [framework, serverMod] = await Promise.all([server.ssrLoadModule("@pracht/core/server"), server.ssrLoadModule(PRACHT_SERVER_MODULE_ID)]);
1177
- if (shouldBypassDevSSR(requestUrl, req, {
1665
+ const routeMatchers = {
1178
1666
  app: serverMod.resolvedApp,
1179
1667
  apiRoutes: serverMod.apiRoutes,
1180
1668
  matchApiRoute: framework.matchApiRoute,
1181
1669
  matchAppRoute: framework.matchAppRoute
1182
- })) return next();
1670
+ };
1671
+ if (requestUrl.pathname === "/_pracht" || requestUrl.pathname === "/_pracht.json") {
1672
+ if (!warnedDevtoolsCollision && matchesResolvedRoute(requestUrl.pathname, routeMatchers)) {
1673
+ warnedDevtoolsCollision = true;
1674
+ 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.`);
1675
+ }
1676
+ await serveDevtools(server, res, {
1677
+ apiRoutes: serverMod.apiRoutes ?? [],
1678
+ app: serverMod.resolvedApp,
1679
+ url,
1680
+ wantsJson: requestUrl.pathname === DEVTOOLS_JSON_PATH
1681
+ });
1682
+ return;
1683
+ }
1684
+ if (shouldBypassDevSSR(requestUrl, req, routeMatchers)) return next();
1685
+ if (isDevNotFoundRequest(requestUrl, req, routeMatchers)) return serveDevNotFound(server, res, next, url, requestUrl.pathname, routeMatchers);
1183
1686
  let webRequest;
1184
1687
  try {
1185
1688
  webRequest = await nodeToWebRequest(req, maxBodySize);
@@ -1191,15 +1694,17 @@ function createDevSSRMiddleware(server, options = {}) {
1191
1694
  }
1192
1695
  throw err;
1193
1696
  }
1697
+ const timings = {};
1194
1698
  const response = await framework.handlePrachtRequest({
1195
1699
  app: serverMod.resolvedApp,
1196
1700
  registry: serverMod.registry,
1197
1701
  request: webRequest,
1198
1702
  debugErrors: true,
1199
1703
  clientEntryUrl: CLIENT_BROWSER_PATH,
1200
- apiRoutes: serverMod.apiRoutes
1704
+ apiRoutes: serverMod.apiRoutes,
1705
+ timings
1201
1706
  });
1202
- if (response.status === 404) return next();
1707
+ if (response.status === 404 && !routeMatchers.app?.notFound) return next();
1203
1708
  const contentType = response.headers.get("content-type") ?? "text/html";
1204
1709
  let body = await response.text();
1205
1710
  if (contentType.includes("text/html")) body = await server.transformIndexHtml(url, body);
@@ -1207,12 +1712,38 @@ function createDevSSRMiddleware(server, options = {}) {
1207
1712
  response.headers.forEach((value, key) => {
1208
1713
  res.setHeader(key, value);
1209
1714
  });
1715
+ const serverTiming = framework.formatServerTimingHeader(timings);
1716
+ if (serverTiming) res.setHeader("Server-Timing", serverTiming);
1210
1717
  res.end(body);
1211
1718
  } catch (error) {
1212
1719
  await handleDevError(server, req, res, next, url, error);
1213
1720
  }
1214
1721
  };
1215
1722
  }
1723
+ /**
1724
+ * Serve the dev-only `/_pracht` devtools page (or `/_pracht.json`) built from
1725
+ * the same resolved app graph that `pracht inspect` reports.
1726
+ */
1727
+ async function serveDevtools(server, res, options) {
1728
+ const devtools = await server.ssrLoadModule("@pracht/core/devtools");
1729
+ const graph = await devtools.buildAppGraph({
1730
+ apiRoutes: options.apiRoutes,
1731
+ app: options.app,
1732
+ loadModule: (file) => server.ssrLoadModule(file),
1733
+ readSource: (file) => readFileSync(resolve(server.config.root, `.${file}`), "utf-8")
1734
+ });
1735
+ if (options.wantsJson) {
1736
+ res.statusCode = 200;
1737
+ res.setHeader("content-type", "application/json; charset=utf-8");
1738
+ res.end(JSON.stringify(graph, null, 2));
1739
+ return;
1740
+ }
1741
+ let html = devtools.buildDevtoolsHtml(graph);
1742
+ html = await server.transformIndexHtml(options.url, html);
1743
+ res.statusCode = 200;
1744
+ res.setHeader("content-type", "text/html; charset=utf-8");
1745
+ res.end(html);
1746
+ }
1216
1747
  async function handleDevError(server, req, res, next, url, error) {
1217
1748
  if (error instanceof Error) server.ssrFixStacktrace(error);
1218
1749
  if (req.headers["x-pracht-route-state-request"] === "1") {
@@ -1229,7 +1760,8 @@ async function handleDevError(server, req, res, next, url, error) {
1229
1760
  const { buildErrorOverlayHtml } = await server.ssrLoadModule("@pracht/core/error-overlay");
1230
1761
  let html = buildErrorOverlayHtml({
1231
1762
  message: error instanceof Error ? error.message : String(error),
1232
- stack: error instanceof Error ? error.stack : void 0
1763
+ stack: error instanceof Error ? error.stack : void 0,
1764
+ root: server.config.root
1233
1765
  });
1234
1766
  html = await server.transformIndexHtml(url, html);
1235
1767
  res.statusCode = 500;
@@ -1239,6 +1771,44 @@ async function handleDevError(server, req, res, next, url, error) {
1239
1771
  next(error);
1240
1772
  }
1241
1773
  }
1774
+ /**
1775
+ * True when a GET/HEAD document request matches no page route and no API
1776
+ * route — the dev middleware then serves the rich dev-only 404 page instead
1777
+ * of falling through to Vite. Route-state (JSON) requests and non-document
1778
+ * fetches keep their existing 404 behavior.
1779
+ *
1780
+ * Apps that declare a `notFound` page own their 404s: dev renders that page
1781
+ * (exactly as production does) rather than the framework's route table.
1782
+ */
1783
+ function isDevNotFoundRequest(requestUrl, req, options = {}) {
1784
+ const url = typeof requestUrl === "string" ? new URL(requestUrl, "http://localhost") : requestUrl;
1785
+ if (options.app?.notFound) return false;
1786
+ if (isRouteStateRequest(url, req)) return false;
1787
+ const method = (req.method ?? "GET").toUpperCase();
1788
+ if (method !== "GET" && method !== "HEAD") return false;
1789
+ const accept = readRequestHeader(req.headers.accept).toLowerCase();
1790
+ if (!accept.includes("text/html") && !accept.includes("application/xhtml+xml")) return false;
1791
+ return !matchesResolvedRoute(url.pathname, options);
1792
+ }
1793
+ async function serveDevNotFound(server, res, next, url, pathname, options) {
1794
+ try {
1795
+ const { buildDevNotFoundHtml } = await server.ssrLoadModule("@pracht/core/dev-404");
1796
+ let html = buildDevNotFoundHtml({
1797
+ apiRoutes: options.apiRoutes.map((route) => ({ path: route.path })),
1798
+ requestedPath: pathname,
1799
+ routes: options.app.routes.map((route) => ({
1800
+ path: route.path,
1801
+ render: route.render ?? null
1802
+ }))
1803
+ });
1804
+ html = await server.transformIndexHtml(url, html);
1805
+ res.statusCode = 404;
1806
+ res.setHeader("content-type", "text/html; charset=utf-8");
1807
+ res.end(html);
1808
+ } catch {
1809
+ next();
1810
+ }
1811
+ }
1242
1812
  function shouldBypassDevSSR(requestUrl, req, options = {}) {
1243
1813
  const url = typeof requestUrl === "string" ? new URL(requestUrl, "http://localhost") : requestUrl;
1244
1814
  const pathname = url.pathname;
@@ -1274,7 +1844,7 @@ function hasKnownAssetExtension(pathname) {
1274
1844
  return DEV_ASSET_EXTENSIONS.has(extension);
1275
1845
  }
1276
1846
  function isReservedDevPath(pathname) {
1277
- return pathname === "/@pracht/client.js" || pathname === "/@vite/client" || pathname === "/@react-refresh" || pathname.startsWith("/@vite/") || pathname.startsWith("/@id/") || pathname.startsWith("/@fs/") || pathname.startsWith("/__vite_");
1847
+ return pathname === "/@pracht/client.js" || pathname === "/@pracht/islands.js" || pathname === "/@vite/client" || pathname === "/@react-refresh" || pathname.startsWith("/@vite/") || pathname.startsWith("/@id/") || pathname.startsWith("/@fs/") || pathname.startsWith("/__vite_");
1278
1848
  }
1279
1849
  const NON_DOCUMENT_FETCH_DESTINATIONS = new Set([
1280
1850
  "audio",
@@ -1361,12 +1931,24 @@ function pracht(options = {}) {
1361
1931
  config(_config, env) {
1362
1932
  const isEdge = resolved.adapter.edge === true;
1363
1933
  const isSSRBuild = env.isSsrBuild;
1934
+ const configRoot = _config.root ?? process.cwd();
1935
+ const wantsIslandsEntry = env.command === "build" && !isSSRBuild && existsSync(resolveConfigPath(configRoot, resolved.islandsDir));
1364
1936
  return {
1365
1937
  appType: "custom",
1366
- build: { rollupOptions: { output: { manualChunks(id) {
1367
- if (id.includes("node_modules/preact") || id.includes("node_modules/preact-suspense")) return "vendor";
1368
- } } } },
1369
- ...isEdge && isSSRBuild ? { ssr: { noExternal: true } } : {}
1938
+ envPrefix: ["VITE_", PUBLIC_ENV_PREFIX],
1939
+ ...isSSRBuild ? {} : { build: { rollupOptions: {
1940
+ ...wantsIslandsEntry ? { input: [PRACHT_ISLANDS_CLIENT_MODULE_ID] } : {},
1941
+ output: { manualChunks(id) {
1942
+ if (id.includes("node_modules/preact") || id.includes("node_modules/preact-suspense")) return "vendor";
1943
+ } }
1944
+ } } },
1945
+ ...isEdge && isSSRBuild ? {
1946
+ ssr: {
1947
+ noExternal: true,
1948
+ target: "webworker"
1949
+ },
1950
+ build: { rollupOptions: { external: [/^cloudflare:/] } }
1951
+ } : {}
1370
1952
  };
1371
1953
  },
1372
1954
  configResolved(config) {
@@ -1374,12 +1956,15 @@ function pracht(options = {}) {
1374
1956
  isBuild = config.command === "build";
1375
1957
  routeFileDirs = computeRouteFileDirs(root, resolved);
1376
1958
  },
1377
- resolveId(id) {
1959
+ resolveId(id, importer, resolveIdOptions) {
1960
+ if (isIslandsClientModule(id)) return PRACHT_ISLANDS_CLIENT_MODULE_ID;
1378
1961
  if (isClientModule(id)) return PRACHT_CLIENT_MODULE_ID;
1379
1962
  if (isServerModule(id)) return PRACHT_SERVER_MODULE_ID;
1963
+ if (id === "@pracht/core/env/server" && !resolveIdOptions?.ssr && !resolveIdOptions?.scan) throw new Error(`[pracht] ${JSON.stringify(SERVER_ENV_MODULE_ID)} was imported by ${JSON.stringify(importer ?? "unknown module")} in client code. serverEnv is server-only — read it inside loaders, middleware, or API routes, or use publicEnv (PRACHT_PUBLIC_-prefixed variables) from "@pracht/core" instead.`);
1380
1964
  return null;
1381
1965
  },
1382
1966
  load(id) {
1967
+ if (isIslandsClientModule(id)) return createPrachtIslandsClientModuleSource(resolved);
1383
1968
  if (isClientModule(id)) return createPrachtClientModuleSource(resolved, { root });
1384
1969
  if (isServerModule(id)) return createPrachtServerModuleSource(resolved, {
1385
1970
  root,
@@ -1422,7 +2007,8 @@ function pracht(options = {}) {
1422
2007
  resolved.shellsDir,
1423
2008
  resolved.middlewareDir,
1424
2009
  resolved.apiDir,
1425
- resolved.serverDir
2010
+ resolved.serverDir,
2011
+ resolved.islandsDir
1426
2012
  ].some((dir) => relative.startsWith(dir))) {
1427
2013
  const serverMod = server.moduleGraph.getModuleById(PRACHT_SERVER_MODULE_ID);
1428
2014
  if (serverMod) server.moduleGraph.invalidateModule(serverMod);
@@ -1430,6 +2016,10 @@ function pracht(options = {}) {
1430
2016
  const clientMod = server.moduleGraph.getModuleById(PRACHT_CLIENT_MODULE_ID);
1431
2017
  if (clientMod) server.moduleGraph.invalidateModule(clientMod);
1432
2018
  }
2019
+ if (relative.startsWith(resolved.islandsDir)) {
2020
+ const islandsMod = server.moduleGraph.getModuleById(PRACHT_ISLANDS_CLIENT_MODULE_ID);
2021
+ if (islandsMod) server.moduleGraph.invalidateModule(islandsMod);
2022
+ }
1433
2023
  }
1434
2024
  }
1435
2025
  };
@@ -1450,7 +2040,7 @@ function pracht(options = {}) {
1450
2040
  name: "pracht:optimize-deps-entries",
1451
2041
  enforce: "post",
1452
2042
  config(config) {
1453
- return withPrachtOptimizeDepsEntries(config, createPrachtOptimizeDepsEntries(resolved));
2043
+ return withPrachtOptimizeDepsEntries(config, createPrachtOptimizeDepsEntries(resolved), createPrachtOptimizeDepsInclude(config.root ?? process.cwd()));
1454
2044
  }
1455
2045
  };
1456
2046
  const precompilePlugin = resolved.precompileSsrJsx ? preactSsrPrecompile({
@@ -1461,7 +2051,8 @@ function pracht(options = {}) {
1461
2051
  ...precompilePlugin ? [precompilePlugin] : [],
1462
2052
  ...preact(),
1463
2053
  prachtPlugin,
1464
- clientModuleTransformPlugin
2054
+ clientModuleTransformPlugin,
2055
+ createEnvSafetyPlugin(resolved.envSafety)
1465
2056
  ];
1466
2057
  const adapterPlugins = resolved.adapter.vitePlugins?.();
1467
2058
  if (adapterPlugins?.length) plugins.push(...adapterPlugins);
@@ -1481,10 +2072,27 @@ function rewriteManifestCoreImports(code) {
1481
2072
  return `import ${typeKeyword ?? ""}{${specifiers}} from ${quote}@pracht/core/manifest${quote}`;
1482
2073
  });
1483
2074
  }
1484
- function withPrachtOptimizeDepsEntries(config, prachtEntries) {
2075
+ const PRACHT_OPTIMIZE_DEPS_INCLUDE = [
2076
+ "@pracht/core",
2077
+ "@pracht/core/client",
2078
+ "@pracht/core/islands-client",
2079
+ "@pracht/core/manifest"
2080
+ ];
2081
+ function createPrachtOptimizeDepsInclude(root) {
2082
+ try {
2083
+ if (!toPosixPath(createRequire(join(root, "package.json")).resolve("@pracht/core/package.json")).includes("/node_modules/")) return [];
2084
+ return PRACHT_OPTIMIZE_DEPS_INCLUDE;
2085
+ } catch {
2086
+ return [];
2087
+ }
2088
+ }
2089
+ function withPrachtOptimizeDepsEntries(config, prachtEntries, prachtInclude) {
1485
2090
  const environments = Object.fromEntries(Object.entries(config.environments ?? {}).map(([name, environment]) => [name, { optimizeDeps: { entries: mergeOptimizeDepsEntries(environment.optimizeDeps?.entries, prachtEntries) } }]));
1486
2091
  return {
1487
- optimizeDeps: { entries: mergeOptimizeDepsEntries(config.optimizeDeps?.entries, prachtEntries) },
2092
+ optimizeDeps: {
2093
+ entries: mergeOptimizeDepsEntries(config.optimizeDeps?.entries, prachtEntries),
2094
+ ...prachtInclude.length > 0 ? { include: mergeOptimizeDepsEntries(config.optimizeDeps?.include, prachtInclude) } : {}
2095
+ },
1488
2096
  ...Object.keys(environments).length > 0 ? { environments } : {}
1489
2097
  };
1490
2098
  }
@@ -1495,14 +2103,16 @@ function createPrachtOptimizeDepsEntries(resolved) {
1495
2103
  `${toOptimizeDepsEntry(resolved.pagesDir)}/**/*.${routeExtensions}`,
1496
2104
  `${toOptimizeDepsEntry(resolved.middlewareDir)}/**/*.${scriptExtensions}`,
1497
2105
  `${toOptimizeDepsEntry(resolved.apiDir)}/**/*.{ts,js,tsx,jsx}`,
1498
- `${toOptimizeDepsEntry(resolved.serverDir)}/**/*.{ts,js,tsx,jsx}`
2106
+ `${toOptimizeDepsEntry(resolved.serverDir)}/**/*.{ts,js,tsx,jsx}`,
2107
+ `${toOptimizeDepsEntry(resolved.islandsDir)}/**/*.${scriptExtensions}`
1499
2108
  ] : [
1500
2109
  toOptimizeDepsEntry(resolved.appFile),
1501
2110
  `${toOptimizeDepsEntry(resolved.routesDir)}/**/*.${routeExtensions}`,
1502
2111
  `${toOptimizeDepsEntry(resolved.shellsDir)}/**/*.${routeExtensions}`,
1503
2112
  `${toOptimizeDepsEntry(resolved.middlewareDir)}/**/*.${scriptExtensions}`,
1504
2113
  `${toOptimizeDepsEntry(resolved.apiDir)}/**/*.{ts,js,tsx,jsx}`,
1505
- `${toOptimizeDepsEntry(resolved.serverDir)}/**/*.{ts,js,tsx,jsx}`
2114
+ `${toOptimizeDepsEntry(resolved.serverDir)}/**/*.{ts,js,tsx,jsx}`,
2115
+ `${toOptimizeDepsEntry(resolved.islandsDir)}/**/*.${scriptExtensions}`
1506
2116
  ];
1507
2117
  return [...new Set(entries.filter(Boolean))];
1508
2118
  }
@@ -1570,4 +2180,4 @@ function withTrailingSep(p) {
1570
2180
  return p.endsWith("/") ? p : `${p}/`;
1571
2181
  }
1572
2182
  //#endregion
1573
- export { PRACHT_CLIENT_MODULE_ID, PRACHT_SERVER_MODULE_ID, createPrachtClientModuleSource, createPrachtRegistryModuleSource, createPrachtServerModuleSource, pracht };
2183
+ export { PRACHT_CLIENT_MODULE_ID, PRACHT_ISLANDS_CLIENT_MODULE_ID, PRACHT_SERVER_MODULE_ID, PUBLIC_ENV_PREFIX, VITE_BUILTIN_ENV_VARS, createEnvSafetyPlugin, createPrachtClientModuleSource, createPrachtIslandsClientModuleSource, createPrachtRegistryModuleSource, createPrachtServerModuleSource, formatEnvLeakError, pracht, scanCodeForEnvLeaks };
@@ -107,6 +107,7 @@ function scan(dir, root, pages) {
107
107
  const routePath = filePathToRoutePath(rel);
108
108
  const source = readFileSync(abs, "utf-8");
109
109
  const renderMode = extractRenderMode(source);
110
+ const hydrationMode = extractHydrationMode(source);
110
111
  const hasLoader = detectLoaderExport(source);
111
112
  pages.push({
112
113
  absolutePath: abs,
@@ -116,6 +117,7 @@ function scan(dir, root, pages) {
116
117
  isCatchAll: routePath.split("/").includes("*"),
117
118
  isDynamic: routePath.split("/").some((segment) => segment.startsWith(":")),
118
119
  renderMode,
120
+ hydrationMode,
119
121
  hasLoader
120
122
  });
121
123
  }
@@ -162,6 +164,11 @@ function extractRenderMode(source) {
162
164
  const match = RENDER_MODE_RE.exec(source);
163
165
  return match ? match[1] : void 0;
164
166
  }
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;
171
+ }
165
172
  function generatePagesManifestSource(pages, options) {
166
173
  const pagesDir = options.pagesDir;
167
174
  const defaultRender = options.pagesDefaultRender ?? "ssr";
@@ -170,13 +177,21 @@ function generatePagesManifestSource(pages, options) {
170
177
  const appFile = scanAllFiles(pagesDir).find((f) => basename(f, extname(f)) === "_app" && SHELL_EXTENSIONS.has(extname(f)));
171
178
  const lines = ["import { defineApp, group, route } from \"@pracht/core/manifest\";", ""];
172
179
  const routeEntries = [];
180
+ const notFoundPage = pages.find((page) => page.routePath === "/404");
173
181
  for (const page of pages) {
182
+ if (page === notFoundPage) continue;
174
183
  const render = page.renderMode ?? defaultRender;
175
184
  const filePath = prefix ? `${prefix}/${page.relativePath.replace(/\\/g, "/")}` : `./${page.relativePath.replace(/\\/g, "/")}`;
176
185
  const fileRef = useImport ? `() => import(${JSON.stringify(filePath)})` : JSON.stringify(filePath);
177
186
  const metaParts = [`render: ${JSON.stringify(render)}`, `hasLoader: ${page.hasLoader ? "true" : "false"}`];
187
+ if (page.hydrationMode) metaParts.push(`hydration: ${JSON.stringify(page.hydrationMode)}`);
178
188
  routeEntries.push(` route(${JSON.stringify(page.routePath)}, ${fileRef}, { ${metaParts.join(", ")} })`);
179
189
  }
190
+ const notFoundEntry = notFoundPage ? buildNotFoundEntry(notFoundPage, {
191
+ prefix,
192
+ useImport,
193
+ withShell: !!appFile
194
+ }) : null;
180
195
  if (appFile) {
181
196
  const appPath = prefix ? `${prefix}/_app.${extname(appFile).slice(1)}` : `./${relative(join(pagesDir, ".."), appFile).replace(/\\/g, "/")}`;
182
197
  const shellRef = useImport ? `() => import(${JSON.stringify(appPath)})` : JSON.stringify(appPath);
@@ -189,17 +204,26 @@ function generatePagesManifestSource(pages, options) {
189
204
  lines.push(routeEntries.join(",\n"));
190
205
  lines.push(" ]),");
191
206
  lines.push(" ],");
207
+ if (notFoundEntry) lines.push(notFoundEntry);
192
208
  lines.push("});");
193
209
  } else {
194
210
  lines.push("const app = defineApp({");
195
211
  lines.push(" routes: [");
196
212
  lines.push(routeEntries.join(",\n"));
197
213
  lines.push(" ],");
214
+ if (notFoundEntry) lines.push(notFoundEntry);
198
215
  lines.push("});");
199
216
  }
200
217
  lines.push("");
201
218
  return lines.join("\n");
202
219
  }
220
+ function buildNotFoundEntry(page, options) {
221
+ const filePath = options.prefix ? `${options.prefix}/${page.relativePath.replace(/\\/g, "/")}` : `./${page.relativePath.replace(/\\/g, "/")}`;
222
+ const configParts = [`component: ${options.useImport ? `() => import(${JSON.stringify(filePath)})` : JSON.stringify(filePath)}`];
223
+ if (options.withShell) configParts.push("shell: \"pages\"");
224
+ if (page.hydrationMode) configParts.push(`hydration: ${JSON.stringify(page.hydrationMode)}`);
225
+ return ` notFound: { ${configParts.join(", ")} },`;
226
+ }
203
227
  function scanAllFiles(dir) {
204
228
  const results = [];
205
229
  let entries;
@@ -7,6 +7,7 @@ interface ScannedPage {
7
7
  isCatchAll: boolean;
8
8
  isDynamic: boolean;
9
9
  renderMode?: string;
10
+ hydrationMode?: string;
10
11
  hasLoader?: boolean;
11
12
  }
12
13
  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-BN3V7ii7.mjs";
1
+ import { a as sortRoutes, i as scanPagesDirectory, n as generatePagesManifestSource, r as generateRoutesFile, t as filePathToRoutePath } from "./pages-router-BsVlzz-e.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.4.4",
3
+ "version": "0.6.0",
4
4
  "description": "Vite plugin for Pracht apps with virtual modules, dev SSR, prerendering, route inspection, and multi-adapter builds.",
5
5
  "keywords": [
6
6
  "pracht",
@@ -44,9 +44,9 @@
44
44
  "dependencies": {
45
45
  "@preact/preset-vite": "^2.10.5",
46
46
  "@prefresh/vite": "^2.0.0",
47
- "@pracht/adapter-node": "0.2.4",
48
- "@pracht/core": "0.8.1",
49
- "@pracht/preact-ssr-precompile": "0.1.1"
47
+ "@pracht/adapter-node": "0.3.0",
48
+ "@pracht/preact-ssr-precompile": "0.1.2",
49
+ "@pracht/core": "0.10.0"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "vite": "^8.0.0"