@pracht/vite-plugin 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -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;
@@ -78,12 +118,26 @@ interface PrachtPluginOptions {
78
118
  * Client bundles keep the normal Preact JSX transform for hydration.
79
119
  */
80
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;
81
128
  }
82
129
  //#endregion
83
130
  //#region src/plugin-codegen.d.ts
84
131
  declare function createPrachtClientModuleSource(options?: PrachtPluginOptions, buildOptions?: {
85
132
  root?: string;
86
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;
87
141
  declare function createPrachtServerModuleSource(options?: PrachtPluginOptions, buildOptions?: {
88
142
  root?: string;
89
143
  isBuild?: boolean;
@@ -93,4 +147,4 @@ declare function createPrachtRegistryModuleSource(options?: PrachtPluginOptions)
93
147
  //#region src/index.d.ts
94
148
  declare function pracht(options?: PrachtPluginOptions): Plugin[];
95
149
  //#endregion
96
- 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,10 +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
2
  import { createRequire } from "node:module";
3
3
  import { preactSsrPrecompile } from "@pracht/preact-ssr-precompile";
4
4
  import preact from "@preact/preset-vite";
5
- import { dirname, join, resolve } from "node:path";
5
+ import { dirname, extname, join, resolve } from "node:path";
6
6
  import { parseAst } from "vite";
7
- import { existsSync, readFileSync } from "node:fs";
7
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
8
8
  import { createNodeServerEntryModule } from "@pracht/adapter-node";
9
9
  //#region src/client-module-query.ts
10
10
  const CLIENT_MODULE_QUERY = "pracht-client";
@@ -888,20 +888,323 @@ function enqueueDependencies(target, dependencies) {
888
888
  for (const name of dependencies) target.add(name);
889
889
  }
890
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
891
1190
  //#region src/plugin-assets.ts
892
1191
  const PRACHT_CLIENT_MODULE_ID = "virtual:pracht/client";
893
1192
  const PRACHT_SERVER_MODULE_ID = "virtual:pracht/server";
1193
+ const PRACHT_ISLANDS_CLIENT_MODULE_ID = "virtual:pracht/islands-client";
894
1194
  const CLIENT_BROWSER_PATH = "/@pracht/client.js";
1195
+ const ISLANDS_CLIENT_BROWSER_PATH = "/@pracht/islands.js";
895
1196
  function readClientBuildAssets(root = process.cwd()) {
896
1197
  const manifestPath = ["dist/client/.vite/manifest.json", "dist/.vite/manifest.json"].map((candidate) => resolve(root, candidate)).find((candidate) => existsSync(candidate));
897
1198
  if (!manifestPath) return {
898
1199
  clientEntryUrl: null,
1200
+ islandsEntryUrl: null,
899
1201
  cssManifest: {},
900
1202
  jsManifest: {}
901
1203
  };
902
1204
  const rawManifest = readFileSync(manifestPath, "utf-8");
903
1205
  const manifest = JSON.parse(rawManifest);
904
1206
  const clientEntry = manifest[PRACHT_CLIENT_MODULE_ID];
1207
+ const islandsEntry = manifest[PRACHT_ISLANDS_CLIENT_MODULE_ID];
905
1208
  const cssManifest = {};
906
1209
  const jsManifest = {};
907
1210
  for (const [key, entry] of Object.entries(manifest)) {
@@ -911,12 +1214,20 @@ function readClientBuildAssets(root = process.cwd()) {
911
1214
  if (deps.css.length > 0) cssManifest[manifestKey] = deps.css.map((f) => `/${f}`);
912
1215
  if (deps.js.length > 0) jsManifest[manifestKey] = deps.js.map((f) => `/${f}`);
913
1216
  }
1217
+ addEntryDeps(manifest, jsManifest, PRACHT_CLIENT_MODULE_ID, clientEntry);
1218
+ addEntryDeps(manifest, jsManifest, PRACHT_ISLANDS_CLIENT_MODULE_ID, islandsEntry);
914
1219
  return {
915
1220
  clientEntryUrl: clientEntry ? `/${clientEntry.file}` : null,
1221
+ islandsEntryUrl: islandsEntry ? `/${islandsEntry.file}` : null,
916
1222
  cssManifest,
917
1223
  jsManifest
918
1224
  };
919
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
+ }
920
1231
  function collectTransitiveDeps(manifest, key) {
921
1232
  const css = /* @__PURE__ */ new Set();
922
1233
  const js = /* @__PURE__ */ new Set();
@@ -942,6 +1253,9 @@ function isClientModule(id) {
942
1253
  function isServerModule(id) {
943
1254
  return id === "virtual:pracht/server" || id.endsWith("virtual:pracht/server");
944
1255
  }
1256
+ function isIslandsClientModule(id) {
1257
+ return id === "virtual:pracht/islands-client" || id === "/@pracht/islands.js" || id.endsWith("virtual:pracht/islands-client");
1258
+ }
945
1259
  //#endregion
946
1260
  //#region src/plugin-adapter.ts
947
1261
  function createDefaultNodeAdapter() {
@@ -962,13 +1276,15 @@ const DEFAULTS = {
962
1276
  shellsDir: "/src/shells",
963
1277
  apiDir: "/src/api",
964
1278
  serverDir: "/src/server",
1279
+ islandsDir: "/src/islands",
965
1280
  adapter: createDefaultNodeAdapter(),
966
1281
  pagesDir: "",
967
1282
  pagesDefaultRender: "ssr",
968
1283
  prerenderConcurrency: 10,
969
1284
  maxBodySize: 1024 * 1024,
970
1285
  budgets: {},
971
- precompileSsrJsx: false
1286
+ precompileSsrJsx: false,
1287
+ envSafety: {}
972
1288
  };
973
1289
  function resolveOptions(options) {
974
1290
  const resolved = {
@@ -990,6 +1306,102 @@ function validateBudgets(budgets) {
990
1306
  }
991
1307
  //#endregion
992
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
+ }
993
1405
  function createPrachtClientModuleSource(options = {}, buildOptions = {}) {
994
1406
  const resolved = resolveOptions(options);
995
1407
  const isPagesMode = !!resolved.pagesDir;
@@ -998,16 +1410,21 @@ function createPrachtClientModuleSource(options = {}, buildOptions = {}) {
998
1410
  const dirPrefix = isPagesMode ? resolved.pagesDir : resolved.routesDir;
999
1411
  const routeGlob = `${dirPrefix}/**/*.{ts,tsx,js,jsx,md,mdx}`;
1000
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;
1001
1416
  const shellGlob = isPagesMode ? `${resolved.pagesDir}/**/_app.{ts,tsx,js,jsx}` : `${resolved.shellsDir}/**/*.{ts,tsx,js,jsx,md,mdx}`;
1002
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(/\/[^/]*$/, "") || "/";
1003
1420
  return [
1004
1421
  "import { resolveApp, initClientRouter, readHydrationState } from \"@pracht/core/client\";",
1005
1422
  appImport,
1006
1423
  "",
1007
1424
  `const routeLoaderHints = ${JSON.stringify(routeLoaderHints)};`,
1008
1425
  `const routeModules = {`,
1009
- ` ...import.meta.glob(${JSON.stringify(routeGlob)}, { query: ${JSON.stringify(PRACHT_CLIENT_MODULE_QUERY)} }),`,
1010
- ` ...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)}),`,
1011
1428
  `};`,
1012
1429
  `const shellModules = {`,
1013
1430
  ` ...import.meta.glob(${JSON.stringify(shellGlob)}, { query: ${JSON.stringify(PRACHT_CLIENT_MODULE_QUERY)} }),`,
@@ -1018,8 +1435,22 @@ function createPrachtClientModuleSource(options = {}, buildOptions = {}) {
1018
1435
  "applyRouteLoaderHints(resolvedApp, routeLoaderHints);",
1019
1436
  "",
1020
1437
  ...createApplyRouteLoaderHintsSource(),
1021
- "function normalizeModuleKey(key) {",
1022
- " 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(\"/\");",
1023
1454
  "}",
1024
1455
  "",
1025
1456
  "const moduleKeyIndexes = new WeakMap();",
@@ -1027,22 +1458,34 @@ function createPrachtClientModuleSource(options = {}, buildOptions = {}) {
1027
1458
  " let index = moduleKeyIndexes.get(modules);",
1028
1459
  " if (index) return index;",
1029
1460
  " index = new Map();",
1030
- " for (const key of Object.keys(modules)) {",
1031
- " const normalized = normalizeModuleKey(key);",
1032
- " if (!normalized) continue;",
1033
- " if (!index.has(normalized)) index.set(normalized, key);",
1034
- " for (let i = normalized.indexOf(\"/\"); i !== -1; i = normalized.indexOf(\"/\", i + 1)) {",
1035
- " const suffix = normalized.slice(i + 1);",
1036
- " if (suffix && !index.has(suffix)) index.set(suffix, key);",
1037
- " }",
1038
- " }",
1461
+ " for (const key of Object.keys(modules)) index.set(canonicalModuleKey(key), key);",
1039
1462
  " moduleKeyIndexes.set(modules, index);",
1040
1463
  " return index;",
1041
1464
  "}",
1042
1465
  "",
1043
1466
  "function findModuleKey(modules, file) {",
1044
1467
  " if (file in modules) return file;",
1045
- " 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;",
1046
1489
  "}",
1047
1490
  "",
1048
1491
  "const state = readHydrationState();",
@@ -1060,6 +1503,23 @@ function createPrachtClientModuleSource(options = {}, buildOptions = {}) {
1060
1503
  ""
1061
1504
  ].join("\n");
1062
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
+ }
1063
1523
  function createPrachtServerModuleSource(options = {}, buildOptions = {}) {
1064
1524
  const resolved = resolveOptions(options);
1065
1525
  const isPagesMode = !!resolved.pagesDir;
@@ -1067,23 +1527,37 @@ function createPrachtServerModuleSource(options = {}, buildOptions = {}) {
1067
1527
  const routeLoaderHints = createRouteLoaderHintsForVirtualModules(resolved, buildOptions.root);
1068
1528
  const clientBuild = buildOptions.isBuild ? readClientBuildAssets(buildOptions.root) : {
1069
1529
  clientEntryUrl: null,
1530
+ islandsEntryUrl: null,
1070
1531
  cssManifest: {},
1071
1532
  jsManifest: {}
1072
1533
  };
1073
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}`;
1074
1539
  const source = [
1075
- adapter?.serverImports ? adapter.serverImports + "\nimport { prerenderApp } from \"@pracht/core/server\";" : "import { resolveApp, resolveApiRoutes, prerenderApp } from \"@pracht/core/server\";",
1076
- isPagesMode ? generatePagesAppInlineSource(resolved, buildOptions.root) : `import { app } from ${JSON.stringify(resolved.appFile)};`,
1540
+ prachtImports,
1541
+ "import { registerServerIslands, setIslandsClientEntryUrl } from \"@pracht/core/server\";",
1542
+ appImport,
1077
1543
  "",
1078
1544
  `const routeLoaderHints = ${JSON.stringify(routeLoaderHints)};`,
1079
1545
  ...createApplyRouteLoaderHintsSource(),
1080
1546
  registrySource,
1081
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
+ "",
1082
1555
  "export const resolvedApp = resolveApp(app);",
1083
1556
  "applyRouteLoaderHints(resolvedApp, routeLoaderHints);",
1084
1557
  `export const apiRoutes = resolveApiRoutes(Object.keys(apiModules), ${JSON.stringify(resolved.apiDir)});`,
1085
1558
  `export const buildTarget = ${JSON.stringify(adapter?.id ?? "node")};`,
1086
1559
  `export const clientEntryUrl = ${JSON.stringify(clientBuild.clientEntryUrl ?? "/@pracht/client.js")};`,
1560
+ `export const islandsEntryUrl = ${JSON.stringify(islandsEntryUrl ?? null)};`,
1087
1561
  `export const cssManifest = ${JSON.stringify(clientBuild.cssManifest)};`,
1088
1562
  `export const jsManifest = ${JSON.stringify(clientBuild.jsManifest)};`,
1089
1563
  `export const prerenderConcurrency = ${JSON.stringify(resolved.prerenderConcurrency)};`,
@@ -1230,7 +1704,7 @@ function createDevSSRMiddleware(server, options = {}) {
1230
1704
  apiRoutes: serverMod.apiRoutes,
1231
1705
  timings
1232
1706
  });
1233
- if (response.status === 404) return next();
1707
+ if (response.status === 404 && !routeMatchers.app?.notFound) return next();
1234
1708
  const contentType = response.headers.get("content-type") ?? "text/html";
1235
1709
  let body = await response.text();
1236
1710
  if (contentType.includes("text/html")) body = await server.transformIndexHtml(url, body);
@@ -1302,9 +1776,13 @@ async function handleDevError(server, req, res, next, url, error) {
1302
1776
  * route — the dev middleware then serves the rich dev-only 404 page instead
1303
1777
  * of falling through to Vite. Route-state (JSON) requests and non-document
1304
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.
1305
1782
  */
1306
1783
  function isDevNotFoundRequest(requestUrl, req, options = {}) {
1307
1784
  const url = typeof requestUrl === "string" ? new URL(requestUrl, "http://localhost") : requestUrl;
1785
+ if (options.app?.notFound) return false;
1308
1786
  if (isRouteStateRequest(url, req)) return false;
1309
1787
  const method = (req.method ?? "GET").toUpperCase();
1310
1788
  if (method !== "GET" && method !== "HEAD") return false;
@@ -1366,7 +1844,7 @@ function hasKnownAssetExtension(pathname) {
1366
1844
  return DEV_ASSET_EXTENSIONS.has(extension);
1367
1845
  }
1368
1846
  function isReservedDevPath(pathname) {
1369
- 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_");
1370
1848
  }
1371
1849
  const NON_DOCUMENT_FETCH_DESTINATIONS = new Set([
1372
1850
  "audio",
@@ -1453,11 +1931,17 @@ function pracht(options = {}) {
1453
1931
  config(_config, env) {
1454
1932
  const isEdge = resolved.adapter.edge === true;
1455
1933
  const isSSRBuild = env.isSsrBuild;
1934
+ const configRoot = _config.root ?? process.cwd();
1935
+ const wantsIslandsEntry = env.command === "build" && !isSSRBuild && existsSync(resolveConfigPath(configRoot, resolved.islandsDir));
1456
1936
  return {
1457
1937
  appType: "custom",
1458
- ...isSSRBuild ? {} : { build: { rollupOptions: { output: { manualChunks(id) {
1459
- if (id.includes("node_modules/preact") || id.includes("node_modules/preact-suspense")) return "vendor";
1460
- } } } } },
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
+ } } },
1461
1945
  ...isEdge && isSSRBuild ? {
1462
1946
  ssr: {
1463
1947
  noExternal: true,
@@ -1472,12 +1956,15 @@ function pracht(options = {}) {
1472
1956
  isBuild = config.command === "build";
1473
1957
  routeFileDirs = computeRouteFileDirs(root, resolved);
1474
1958
  },
1475
- resolveId(id) {
1959
+ resolveId(id, importer, resolveIdOptions) {
1960
+ if (isIslandsClientModule(id)) return PRACHT_ISLANDS_CLIENT_MODULE_ID;
1476
1961
  if (isClientModule(id)) return PRACHT_CLIENT_MODULE_ID;
1477
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.`);
1478
1964
  return null;
1479
1965
  },
1480
1966
  load(id) {
1967
+ if (isIslandsClientModule(id)) return createPrachtIslandsClientModuleSource(resolved);
1481
1968
  if (isClientModule(id)) return createPrachtClientModuleSource(resolved, { root });
1482
1969
  if (isServerModule(id)) return createPrachtServerModuleSource(resolved, {
1483
1970
  root,
@@ -1520,7 +2007,8 @@ function pracht(options = {}) {
1520
2007
  resolved.shellsDir,
1521
2008
  resolved.middlewareDir,
1522
2009
  resolved.apiDir,
1523
- resolved.serverDir
2010
+ resolved.serverDir,
2011
+ resolved.islandsDir
1524
2012
  ].some((dir) => relative.startsWith(dir))) {
1525
2013
  const serverMod = server.moduleGraph.getModuleById(PRACHT_SERVER_MODULE_ID);
1526
2014
  if (serverMod) server.moduleGraph.invalidateModule(serverMod);
@@ -1528,6 +2016,10 @@ function pracht(options = {}) {
1528
2016
  const clientMod = server.moduleGraph.getModuleById(PRACHT_CLIENT_MODULE_ID);
1529
2017
  if (clientMod) server.moduleGraph.invalidateModule(clientMod);
1530
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
+ }
1531
2023
  }
1532
2024
  }
1533
2025
  };
@@ -1559,7 +2051,8 @@ function pracht(options = {}) {
1559
2051
  ...precompilePlugin ? [precompilePlugin] : [],
1560
2052
  ...preact(),
1561
2053
  prachtPlugin,
1562
- clientModuleTransformPlugin
2054
+ clientModuleTransformPlugin,
2055
+ createEnvSafetyPlugin(resolved.envSafety)
1563
2056
  ];
1564
2057
  const adapterPlugins = resolved.adapter.vitePlugins?.();
1565
2058
  if (adapterPlugins?.length) plugins.push(...adapterPlugins);
@@ -1582,6 +2075,7 @@ function rewriteManifestCoreImports(code) {
1582
2075
  const PRACHT_OPTIMIZE_DEPS_INCLUDE = [
1583
2076
  "@pracht/core",
1584
2077
  "@pracht/core/client",
2078
+ "@pracht/core/islands-client",
1585
2079
  "@pracht/core/manifest"
1586
2080
  ];
1587
2081
  function createPrachtOptimizeDepsInclude(root) {
@@ -1609,14 +2103,16 @@ function createPrachtOptimizeDepsEntries(resolved) {
1609
2103
  `${toOptimizeDepsEntry(resolved.pagesDir)}/**/*.${routeExtensions}`,
1610
2104
  `${toOptimizeDepsEntry(resolved.middlewareDir)}/**/*.${scriptExtensions}`,
1611
2105
  `${toOptimizeDepsEntry(resolved.apiDir)}/**/*.{ts,js,tsx,jsx}`,
1612
- `${toOptimizeDepsEntry(resolved.serverDir)}/**/*.{ts,js,tsx,jsx}`
2106
+ `${toOptimizeDepsEntry(resolved.serverDir)}/**/*.{ts,js,tsx,jsx}`,
2107
+ `${toOptimizeDepsEntry(resolved.islandsDir)}/**/*.${scriptExtensions}`
1613
2108
  ] : [
1614
2109
  toOptimizeDepsEntry(resolved.appFile),
1615
2110
  `${toOptimizeDepsEntry(resolved.routesDir)}/**/*.${routeExtensions}`,
1616
2111
  `${toOptimizeDepsEntry(resolved.shellsDir)}/**/*.${routeExtensions}`,
1617
2112
  `${toOptimizeDepsEntry(resolved.middlewareDir)}/**/*.${scriptExtensions}`,
1618
2113
  `${toOptimizeDepsEntry(resolved.apiDir)}/**/*.{ts,js,tsx,jsx}`,
1619
- `${toOptimizeDepsEntry(resolved.serverDir)}/**/*.{ts,js,tsx,jsx}`
2114
+ `${toOptimizeDepsEntry(resolved.serverDir)}/**/*.{ts,js,tsx,jsx}`,
2115
+ `${toOptimizeDepsEntry(resolved.islandsDir)}/**/*.${scriptExtensions}`
1620
2116
  ];
1621
2117
  return [...new Set(entries.filter(Boolean))];
1622
2118
  }
@@ -1684,4 +2180,4 @@ function withTrailingSep(p) {
1684
2180
  return p.endsWith("/") ? p : `${p}/`;
1685
2181
  }
1686
2182
  //#endregion
1687
- 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.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Vite plugin for Pracht apps with virtual modules, dev SSR, prerendering, route inspection, and multi-adapter builds.",
5
5
  "keywords": [
6
6
  "pracht",
@@ -44,8 +44,8 @@
44
44
  "dependencies": {
45
45
  "@preact/preset-vite": "^2.10.5",
46
46
  "@prefresh/vite": "^2.0.0",
47
- "@pracht/adapter-node": "0.2.5",
48
- "@pracht/core": "0.9.0",
47
+ "@pracht/adapter-node": "0.3.1",
48
+ "@pracht/core": "0.10.1",
49
49
  "@pracht/preact-ssr-precompile": "0.1.2"
50
50
  },
51
51
  "peerDependencies": {