@pracht/core 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
@@ -203,6 +203,7 @@ interface RouteModule<TContext = any, TLoader extends LoaderLike = undefined> {
203
203
  interface ShellModule<TContext = any> {
204
204
  Shell: FunctionComponent<ShellProps>;
205
205
  Loading?: FunctionComponent;
206
+ ErrorBoundary?: FunctionComponent<ErrorBoundaryProps>;
206
207
  head?: (args: BaseRouteArgs<TContext>) => MaybePromise<HeadMetadata>;
207
208
  headers?: (args: BaseRouteArgs<TContext>) => MaybePromise<HeadersInit>;
208
209
  }
package/dist/index.mjs CHANGED
@@ -154,6 +154,7 @@ function parseRouteSegments(path) {
154
154
  type: "param",
155
155
  name: segment.slice(1)
156
156
  };
157
+ assertSafeStaticRouteSegment(segment);
157
158
  return {
158
159
  type: "static",
159
160
  value: segment
@@ -163,6 +164,10 @@ function parseRouteSegments(path) {
163
164
  function splitPathSegments(path) {
164
165
  return normalizeRoutePath(path).split("/").filter(Boolean);
165
166
  }
167
+ function assertSafeStaticRouteSegment(segment) {
168
+ if (segment === "." || segment === "..") throw new Error(`Unsafe static route segment "${segment}" is not allowed.`);
169
+ if (segment.includes("\0") || /[\r\n\\]/.test(segment)) throw new Error(`Unsafe static route segment "${segment}" contains a forbidden character.`);
170
+ }
166
171
  function mergeRoutePaths(prefix, path) {
167
172
  if (!path) return normalizeRoutePath(prefix);
168
173
  const normalizedPrefix = normalizeRoutePath(prefix);
@@ -179,19 +184,18 @@ function normalizeRoutePath(path) {
179
184
  function buildPathFromSegments(segments, params) {
180
185
  return normalizeRoutePath("/" + segments.map((segment) => {
181
186
  if (segment.type === "static") return segment.value;
182
- if (segment.type === "param") return encodeURIComponent(params[segment.name] ?? "");
183
- return (params[segment.name] ?? params["*"] ?? "").split("/").map((part) => encodeCatchAllSegment(part)).join("/");
187
+ if (segment.type === "param") return encodeDynamicPathSegment(params[segment.name] ?? "");
188
+ return (params[segment.name] ?? params["*"] ?? "").split("/").map((part) => encodeDynamicPathSegment(part)).join("/");
184
189
  }).join("/"));
185
190
  }
186
191
  /**
187
- * Encode a single path segment for a catch-all route. `encodeURIComponent`
188
- * leaves unreserved characters (including `.`) intact, so `..` would
189
- * round-trip unchanged and still resolve as parent-dir in `path.join`.
190
- * Explicitly percent-encode `.` / `..` segments to neutralise them.
192
+ * Encode one dynamic URL path segment for SSG/ISG output. `encodeURIComponent`
193
+ * leaves unreserved characters (including `.`) intact, and even percent-encoded
194
+ * dot segments are normalized by URL parsers. Reject exact `.` / `..` segments
195
+ * instead of allowing them to reach filesystem output path construction.
191
196
  */
192
- function encodeCatchAllSegment(part) {
193
- if (part === ".") return "%2E";
194
- if (part === "..") return "%2E%2E";
197
+ function encodeDynamicPathSegment(part) {
198
+ if (part === "." || part === "..") throw new Error(`Unsafe dynamic route param segment "${part}" is not allowed.`);
195
199
  return encodeURIComponent(part);
196
200
  }
197
201
  /**
@@ -1170,25 +1174,26 @@ async function renderRouteErrorResponse(options) {
1170
1174
  status: routeError.status
1171
1175
  })
1172
1176
  } : routeError;
1173
- if (!options.routeModule?.ErrorBoundary) {
1174
- if (options.isRouteStateRequest) return jsonErrorResponse(routeErrorWithDiagnostics, { isRouteStateRequest: true });
1175
- const message = routeErrorWithDiagnostics.status >= 500 ? "Internal Server Error" : routeErrorWithDiagnostics.message;
1176
- return withDefaultSecurityHeaders(new Response(message, {
1177
+ if (options.isRouteStateRequest) return jsonErrorResponse(routeErrorWithDiagnostics, { isRouteStateRequest: true });
1178
+ const shellModule = options.shellModule ?? (options.shellFile ? await resolveRegistryModule(options.options.registry?.shellModules, options.shellFile) : void 0);
1179
+ const ErrorBoundary = options.routeModule?.ErrorBoundary ?? shellModule?.ErrorBoundary;
1180
+ if (!ErrorBoundary) {
1181
+ const message = routeErrorWithDiagnostics.status >= 500 && !exposeDetails ? "Internal Server Error" : routeErrorWithDiagnostics.message;
1182
+ const diagnostics = exposeDetails && routeErrorWithDiagnostics.diagnostics ? `\n\n${JSON.stringify(routeErrorWithDiagnostics.diagnostics, null, 2)}` : "";
1183
+ return withDefaultSecurityHeaders(new Response(`${message}${diagnostics}`, {
1177
1184
  status: routeErrorWithDiagnostics.status,
1178
1185
  headers: { "content-type": "text/plain; charset=utf-8" }
1179
1186
  }));
1180
1187
  }
1181
- if (options.isRouteStateRequest) return jsonErrorResponse(routeErrorWithDiagnostics, { isRouteStateRequest: true });
1182
- const shellModule = options.shellModule ?? (options.shellFile ? await resolveRegistryModule(options.options.registry?.shellModules, options.shellFile) : void 0);
1183
1188
  const head = shellModule?.head ? await shellModule.head(options.routeArgs) : {};
1184
1189
  const documentHeaders = await mergeDocumentHeaders(shellModule, void 0, options.routeArgs, void 0);
1185
1190
  const cssUrls = resolvePageCssUrls(options.options.cssManifest, options.shellFile, options.routeArgs.route.file);
1186
1191
  const modulePreloadUrls = resolvePageJsUrls(options.options.jsManifest, options.shellFile, options.routeArgs.route.file);
1187
1192
  const renderToString = await getRenderToStringAsync();
1188
- const ErrorBoundary = options.routeModule.ErrorBoundary;
1193
+ const Boundary = ErrorBoundary;
1189
1194
  const Shell = shellModule?.Shell;
1190
1195
  const errorValue = deserializeRouteError(routeErrorWithDiagnostics);
1191
- const componentTree = Shell ? h(Shell, null, h(ErrorBoundary, { error: errorValue })) : h(ErrorBoundary, { error: errorValue });
1196
+ const componentTree = Shell ? h(Shell, null, h(Boundary, { error: errorValue })) : h(Boundary, { error: errorValue });
1192
1197
  return htmlResponse(buildHtmlDocument({
1193
1198
  head,
1194
1199
  body: await renderToString(h(PrachtRuntimeProvider, {
@@ -1721,7 +1726,8 @@ async function initClientRouter(options) {
1721
1726
  const resolvedShell = await (shellModPromise ?? startShellImport(match));
1722
1727
  if (resolvedShell) Shell = resolvedShell.Shell;
1723
1728
  const DefaultComponent = typeof routeMod.default === "function" ? routeMod.default : void 0;
1724
- const Component = state.error ? routeMod.ErrorBoundary : routeMod.Component ?? DefaultComponent;
1729
+ const ErrorBoundary = routeMod.ErrorBoundary ?? resolvedShell?.ErrorBoundary;
1730
+ const Component = state.error ? ErrorBoundary : routeMod.Component ?? DefaultComponent;
1725
1731
  if (!Component) return null;
1726
1732
  const componentProps = state.error ? { error: deserializeRouteError(state.error) } : {
1727
1733
  data: state.data,
package/package.json CHANGED
@@ -1,6 +1,19 @@
1
1
  {
2
2
  "name": "@pracht/core",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
+ "description": "Core runtime for Pracht, a full-stack Preact framework with per-route SSR, SSG, ISG, SPA, loaders, and API routes.",
5
+ "keywords": [
6
+ "pracht",
7
+ "preact",
8
+ "framework",
9
+ "full-stack",
10
+ "ssr",
11
+ "ssg",
12
+ "isg",
13
+ "routing",
14
+ "loaders",
15
+ "api-routes"
16
+ ],
4
17
  "license": "MIT",
5
18
  "homepage": "https://github.com/JoviDeCroock/pracht/tree/main/packages/framework",
6
19
  "bugs": {