@lolyjs/core 0.2.0-alpha.14 → 0.2.0-alpha.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,7 +37,7 @@ Loly is a full-stack React framework that combines the simplicity of file-based
37
37
 
38
38
  - 🔌 **Native WebSocket Support** - Built-in Socket.IO integration with automatic namespace routing
39
39
  - 🎯 **Route-Level Middlewares** - Define middlewares directly in your routes for pages and APIs
40
- - 📁 **Separation of Concerns** - Server logic in `server.hook.ts` separate from React components
40
+ - 📁 **Separation of Concerns** - Server logic in `page.server.hook.ts` and `layout.server.hook.ts` separate from React components
41
41
  - 🚀 **Hybrid Rendering** - SSR, SSG, and CSR with streaming support
42
42
  - 🛡️ **Security First** - Built-in rate limiting, validation, sanitization, and security headers
43
43
  - ⚡ **Performance** - Fast bundling with Rspack and optimized code splitting
@@ -66,7 +66,7 @@ export default function Home() {
66
66
  ### Add Server-Side Data
67
67
 
68
68
  ```tsx
69
- // app/page/server.hook.ts
69
+ // app/page.server.hook.ts (preferred) or app/server.hook.ts (legacy)
70
70
  import type { ServerLoader } from "@lolyjs/core";
71
71
 
72
72
  export const getServerSideProps: ServerLoader = async (ctx) => {
@@ -157,7 +157,7 @@ Define middlewares directly in your routes for fine-grained control:
157
157
  **For Pages:**
158
158
 
159
159
  ```tsx
160
- // app/dashboard/server.hook.ts
160
+ // app/dashboard/page.server.hook.ts (preferred) or app/dashboard/server.hook.ts (legacy)
161
161
  import type { RouteMiddleware, ServerLoader } from "@lolyjs/core";
162
162
 
163
163
  export const beforeServerData: RouteMiddleware[] = [
@@ -233,22 +233,37 @@ Routes are automatically created from your file structure:
233
233
 
234
234
  ```tsx
235
235
  // app/layout.tsx (Root layout)
236
- export default function RootLayout({ children }) {
236
+ export default function RootLayout({ children, appName, navigation }) {
237
237
  return (
238
238
  <div>
239
- <nav>Navigation</nav>
239
+ <nav>{navigation}</nav>
240
240
  {children}
241
- <footer>Footer</footer>
241
+ <footer>{appName}</footer>
242
242
  </div>
243
243
  );
244
244
  }
245
245
  ```
246
246
 
247
+ ```tsx
248
+ // app/layout.server.hook.ts (Root layout server hook - same directory as layout.tsx)
249
+ import type { ServerLoader } from "@lolyjs/core";
250
+
251
+ export const getServerSideProps: ServerLoader = async (ctx) => {
252
+ return {
253
+ props: {
254
+ appName: "My App",
255
+ navigation: ["Home", "About", "Blog"],
256
+ },
257
+ };
258
+ };
259
+ ```
260
+
247
261
  ```tsx
248
262
  // app/blog/layout.tsx (Nested layout)
249
- export default function BlogLayout({ children }) {
263
+ export default function BlogLayout({ children, sectionTitle }) {
250
264
  return (
251
265
  <div>
266
+ <h1>{sectionTitle}</h1>
252
267
  <aside>Sidebar</aside>
253
268
  <main>{children}</main>
254
269
  </div>
@@ -256,6 +271,31 @@ export default function BlogLayout({ children }) {
256
271
  }
257
272
  ```
258
273
 
274
+ ```tsx
275
+ // app/blog/layout.server.hook.ts (Nested layout server hook - same directory as layout.tsx)
276
+ import type { ServerLoader } from "@lolyjs/core";
277
+
278
+ export const getServerSideProps: ServerLoader = async (ctx) => {
279
+ return {
280
+ props: {
281
+ sectionTitle: "Blog Section",
282
+ },
283
+ };
284
+ };
285
+ ```
286
+
287
+ **Layout Server Hooks:**
288
+
289
+ Layouts can have their own server hooks that provide stable data across all pages. Props from layout server hooks are automatically merged with page props:
290
+
291
+ - **Layout props** (from `layout.server.hook.ts`) are stable and available to both the layout and all pages
292
+ - **Page props** (from `page.server.hook.ts`) are specific to each page and override layout props if there's a conflict
293
+ - **Combined props** are available to both layouts and pages
294
+
295
+ **File Convention:**
296
+ - Layout server hooks: `app/layout.server.hook.ts` (same directory as `layout.tsx`)
297
+ - Page server hooks: `app/page.server.hook.ts` (preferred) or `app/server.hook.ts` (legacy, backward compatible)
298
+
259
299
  ### 🚀 Hybrid Rendering
260
300
 
261
301
  Choose the best rendering strategy for each page:
@@ -263,7 +303,7 @@ Choose the best rendering strategy for each page:
263
303
  **SSR (Server-Side Rendering):**
264
304
 
265
305
  ```tsx
266
- // app/posts/server.hook.ts
306
+ // app/posts/page.server.hook.ts (preferred) or app/posts/server.hook.ts (legacy)
267
307
  export const dynamic = "force-dynamic" as const;
268
308
 
269
309
  export const getServerSideProps: ServerLoader = async (ctx) => {
@@ -275,7 +315,7 @@ export const getServerSideProps: ServerLoader = async (ctx) => {
275
315
  **SSG (Static Site Generation):**
276
316
 
277
317
  ```tsx
278
- // app/blog/[slug]/server.hook.ts
318
+ // app/blog/[slug]/page.server.hook.ts (preferred) or app/blog/[slug]/server.hook.ts (legacy)
279
319
  export const dynamic = "force-static" as const;
280
320
 
281
321
  export const generateStaticParams: GenerateStaticParams = async () => {
@@ -292,7 +332,7 @@ export const getServerSideProps: ServerLoader = async (ctx) => {
292
332
  **CSR (Client-Side Rendering):**
293
333
 
294
334
  ```tsx
295
- // app/dashboard/page.tsx (No server.hook.ts)
335
+ // app/dashboard/page.tsx (No page.server.hook.ts)
296
336
  import { useState, useEffect } from "react";
297
337
 
298
338
  export default function Dashboard() {
@@ -407,16 +447,18 @@ logger.error("Error occurred", error);
407
447
  your-app/
408
448
  ├── app/
409
449
  │ ├── layout.tsx # Root layout
450
+ │ ├── layout.server.hook.ts # Root layout server hook (stable props)
410
451
  │ ├── page.tsx # Home page (/)
411
- │ ├── server.hook.ts # Server logic for home
452
+ │ ├── page.server.hook.ts # Page server hook (preferred) or server.hook.ts (legacy)
412
453
  │ ├── _not-found.tsx # Custom 404
413
454
  │ ├── _error.tsx # Custom error page
414
455
  │ ├── blog/
415
456
  │ │ ├── layout.tsx # Blog layout
457
+ │ │ ├── layout.server.hook.ts # Blog layout server hook
416
458
  │ │ ├── page.tsx # /blog
417
459
  │ │ └── [slug]/
418
460
  │ │ ├── page.tsx # /blog/:slug
419
- │ │ └── server.hook.ts # Server logic
461
+ │ │ └── page.server.hook.ts # Page server hook
420
462
  │ ├── api/
421
463
  │ │ └── posts/
422
464
  │ │ └── route.ts # /api/posts
@@ -437,7 +479,10 @@ your-app/
437
479
 
438
480
  ### Server Loader
439
481
 
482
+ **Page Server Hook:**
483
+
440
484
  ```tsx
485
+ // app/page.server.hook.ts (preferred) or app/server.hook.ts (legacy)
441
486
  import type { ServerLoader } from "@lolyjs/core";
442
487
 
443
488
  export const getServerSideProps: ServerLoader = async (ctx) => {
@@ -468,6 +513,48 @@ export const getServerSideProps: ServerLoader = async (ctx) => {
468
513
  };
469
514
  ```
470
515
 
516
+ **Layout Server Hook:**
517
+
518
+ ```tsx
519
+ // app/layout.server.hook.ts (same directory as layout.tsx)
520
+ import type { ServerLoader } from "@lolyjs/core";
521
+
522
+ export const getServerSideProps: ServerLoader = async (ctx) => {
523
+ // Fetch stable data that persists across all pages
524
+ const user = await getCurrentUser();
525
+ const navigation = await getNavigation();
526
+
527
+ return {
528
+ props: {
529
+ user, // Available to layout and all pages
530
+ navigation, // Available to layout and all pages
531
+ },
532
+ };
533
+ };
534
+ ```
535
+
536
+ **Props Merging:**
537
+
538
+ - Layout props (from `layout.server.hook.ts`) are merged first
539
+ - Page props (from `page.server.hook.ts`) are merged second and override layout props
540
+ - Both layouts and pages receive the combined props
541
+
542
+ ```tsx
543
+ // app/layout.tsx
544
+ export default function Layout({ user, navigation, children }) {
545
+ // Receives: user, navigation (from layout.server.hook.ts)
546
+ // Also receives: any props from page.server.hook.ts
547
+ return <div>{/* ... */}</div>;
548
+ }
549
+
550
+ // app/page.tsx
551
+ export default function Page({ user, navigation, posts }) {
552
+ // Receives: user, navigation (from layout.server.hook.ts)
553
+ // Receives: posts (from page.server.hook.ts)
554
+ return <div>{/* ... */}</div>;
555
+ }
556
+ ```
557
+
471
558
  ### API Route Handler
472
559
 
473
560
  ```tsx
package/dist/cli.cjs CHANGED
@@ -169,7 +169,7 @@ function loadLayoutsForDir(pageDir, appDir) {
169
169
  };
170
170
  }
171
171
 
172
- // modules/router/loader.ts
172
+ // modules/router/server-hook.ts
173
173
  var import_fs2 = __toESM(require("fs"));
174
174
  var import_path2 = __toESM(require("path"));
175
175
  var NAMING = {
@@ -181,14 +181,16 @@ var NAMING = {
181
181
  // Files
182
182
  SERVER_HOOK: "server.hook"
183
183
  };
184
- function loadLoaderForDir(currentDir) {
185
- const loaderTs = import_path2.default.join(currentDir, `${NAMING.SERVER_HOOK}.ts`);
186
- const loaderJs = import_path2.default.join(currentDir, `${NAMING.SERVER_HOOK}.js`);
187
- const file = import_fs2.default.existsSync(loaderTs) ? loaderTs : import_fs2.default.existsSync(loaderJs) ? loaderJs : null;
184
+ function loadServerHookForDir(currentDir) {
185
+ const pageServerHookTs = import_path2.default.join(currentDir, `page.server.hook.ts`);
186
+ const pageServerHookJs = import_path2.default.join(currentDir, `page.server.hook.js`);
187
+ const serverHookTs = import_path2.default.join(currentDir, `${NAMING.SERVER_HOOK}.ts`);
188
+ const serverHookJs = import_path2.default.join(currentDir, `${NAMING.SERVER_HOOK}.js`);
189
+ const file = import_fs2.default.existsSync(pageServerHookTs) ? pageServerHookTs : import_fs2.default.existsSync(pageServerHookJs) ? pageServerHookJs : import_fs2.default.existsSync(serverHookTs) ? serverHookTs : import_fs2.default.existsSync(serverHookJs) ? serverHookJs : null;
188
190
  if (!file) {
189
191
  return {
190
192
  middlewares: [],
191
- loader: null,
193
+ serverHook: null,
192
194
  dynamic: "auto",
193
195
  generateStaticParams: null
194
196
  };
@@ -204,12 +206,12 @@ function loadLoaderForDir(currentDir) {
204
206
  mod = require(file);
205
207
  } catch (error) {
206
208
  console.error(
207
- `[framework][loader] Error loading server hook from ${file}:`,
209
+ `[framework][server-hook] Error loading server hook from ${file}:`,
208
210
  error
209
211
  );
210
212
  return {
211
213
  middlewares: [],
212
- loader: null,
214
+ serverHook: null,
213
215
  dynamic: "auto",
214
216
  generateStaticParams: null
215
217
  };
@@ -217,16 +219,43 @@ function loadLoaderForDir(currentDir) {
217
219
  const middlewares = Array.isArray(
218
220
  mod?.[NAMING.BEFORE_MIDDLEWARES]
219
221
  ) ? mod[NAMING.BEFORE_MIDDLEWARES] : [];
220
- const loader = typeof mod?.[NAMING.GET_SERVER_DATA_FN] === "function" ? mod[NAMING.GET_SERVER_DATA_FN] : null;
222
+ const serverHook = typeof mod?.[NAMING.GET_SERVER_DATA_FN] === "function" ? mod[NAMING.GET_SERVER_DATA_FN] : null;
221
223
  const dynamic = mod?.[NAMING.RENDER_TYPE_CONST] === "force-static" || mod?.[NAMING.RENDER_TYPE_CONST] === "force-dynamic" ? mod.dynamic : "auto";
222
224
  const generateStaticParams = typeof mod?.[NAMING.GENERATE_SSG_PARAMS] === "function" ? mod[NAMING.GENERATE_SSG_PARAMS] : null;
223
225
  return {
224
226
  middlewares,
225
- loader,
227
+ serverHook,
226
228
  dynamic,
227
229
  generateStaticParams
228
230
  };
229
231
  }
232
+ function loadLayoutServerHook(layoutFile) {
233
+ const layoutDir = import_path2.default.dirname(layoutFile);
234
+ const layoutBasename = import_path2.default.basename(layoutFile, import_path2.default.extname(layoutFile));
235
+ const serverHookTs = import_path2.default.join(layoutDir, `${layoutBasename}.server.hook.ts`);
236
+ const serverHookJs = import_path2.default.join(layoutDir, `${layoutBasename}.server.hook.js`);
237
+ const file = import_fs2.default.existsSync(serverHookTs) ? serverHookTs : import_fs2.default.existsSync(serverHookJs) ? serverHookJs : null;
238
+ if (!file) {
239
+ return null;
240
+ }
241
+ if (file.endsWith(".ts") || file.endsWith(".tsx")) {
242
+ try {
243
+ require("tsx/cjs");
244
+ } catch (e) {
245
+ }
246
+ }
247
+ try {
248
+ const mod = require(file);
249
+ const serverHook = typeof mod?.getServerSideProps === "function" ? mod.getServerSideProps : null;
250
+ return serverHook;
251
+ } catch (error) {
252
+ console.error(
253
+ `[framework][server-hook] Error loading layout server hook from ${file}:`,
254
+ error
255
+ );
256
+ return null;
257
+ }
258
+ }
230
259
 
231
260
  // modules/router/loader-pages.ts
232
261
  function loadRoutes(appDir) {
@@ -258,7 +287,12 @@ function loadRoutes(appDir) {
258
287
  currentDir,
259
288
  appDir
260
289
  );
261
- const { middlewares, loader, dynamic, generateStaticParams } = loadLoaderForDir(currentDir);
290
+ const layoutServerHooks = [];
291
+ for (const layoutFile of layoutFiles) {
292
+ const layoutServerHook = loadLayoutServerHook(layoutFile);
293
+ layoutServerHooks.push(layoutServerHook);
294
+ }
295
+ const { middlewares, serverHook, dynamic, generateStaticParams } = loadServerHookForDir(currentDir);
262
296
  routes.push({
263
297
  pattern: routePath,
264
298
  regex,
@@ -268,7 +302,10 @@ function loadRoutes(appDir) {
268
302
  pageFile: fullPath,
269
303
  layoutFiles,
270
304
  middlewares,
271
- loader,
305
+ loader: serverHook,
306
+ // Keep 'loader' field name for backward compatibility
307
+ layoutServerHooks,
308
+ // Server hooks for each layout (same order as layouts)
272
309
  dynamic,
273
310
  generateStaticParams
274
311
  });
@@ -787,7 +824,12 @@ function loadRoutesFromManifest(projectRoot) {
787
824
  (f) => import_path9.default.join(projectRoot, f)
788
825
  );
789
826
  const pageDir = import_path9.default.dirname(pageFile);
790
- const { middlewares, loader, dynamic, generateStaticParams } = loadLoaderForDir(pageDir);
827
+ const layoutServerHooks = [];
828
+ for (const layoutFile of layoutFiles) {
829
+ const layoutServerHook = loadLayoutServerHook(layoutFile);
830
+ layoutServerHooks.push(layoutServerHook);
831
+ }
832
+ const { middlewares, serverHook, dynamic, generateStaticParams } = loadServerHookForDir(pageDir);
791
833
  pageRoutes.push({
792
834
  pattern: entry.pattern,
793
835
  regex,
@@ -797,7 +839,10 @@ function loadRoutesFromManifest(projectRoot) {
797
839
  pageFile,
798
840
  layoutFiles,
799
841
  middlewares,
800
- loader,
842
+ loader: serverHook,
843
+ // Keep 'loader' field name for backward compatibility
844
+ layoutServerHooks,
845
+ // Server hooks for each layout (same order as layouts)
801
846
  dynamic: entry.dynamic ?? dynamic,
802
847
  generateStaticParams
803
848
  });
@@ -1051,7 +1096,12 @@ function loadNotFoundRouteFromFilesystem(appDir) {
1051
1096
  notFoundDir,
1052
1097
  appDir
1053
1098
  );
1054
- const { middlewares, loader, dynamic, generateStaticParams } = loadLoaderForDir(notFoundDir);
1099
+ const layoutServerHooks = [];
1100
+ for (const layoutFile of layoutFiles) {
1101
+ const layoutServerHook = loadLayoutServerHook(layoutFile);
1102
+ layoutServerHooks.push(layoutServerHook);
1103
+ }
1104
+ const { middlewares, serverHook, dynamic, generateStaticParams } = loadServerHookForDir(notFoundDir);
1055
1105
  return {
1056
1106
  pattern: NOT_FOUND_PATTERN,
1057
1107
  regex: new RegExp(`^${NOT_FOUND_PATTERN}/?$`),
@@ -1061,7 +1111,10 @@ function loadNotFoundRouteFromFilesystem(appDir) {
1061
1111
  pageFile: notFoundFile,
1062
1112
  layoutFiles,
1063
1113
  middlewares,
1064
- loader,
1114
+ loader: serverHook,
1115
+ // Keep 'loader' field name for backward compatibility
1116
+ layoutServerHooks,
1117
+ // Server hooks for each layout (same order as layouts)
1065
1118
  dynamic,
1066
1119
  generateStaticParams
1067
1120
  };
@@ -1092,7 +1145,12 @@ function loadErrorRouteFromFilesystem(appDir) {
1092
1145
  appDir,
1093
1146
  appDir
1094
1147
  );
1095
- const { middlewares, loader, dynamic, generateStaticParams } = loadLoaderForDir(appDir);
1148
+ const layoutServerHooks = [];
1149
+ for (const layoutFile of layoutFiles) {
1150
+ const layoutServerHook = loadLayoutServerHook(layoutFile);
1151
+ layoutServerHooks.push(layoutServerHook);
1152
+ }
1153
+ const { middlewares, serverHook, dynamic, generateStaticParams } = loadServerHookForDir(appDir);
1096
1154
  return {
1097
1155
  pattern: ERROR_PATTERN,
1098
1156
  regex: new RegExp(`^${ERROR_PATTERN}/?$`),
@@ -1102,7 +1160,10 @@ function loadErrorRouteFromFilesystem(appDir) {
1102
1160
  pageFile: errorFile,
1103
1161
  layoutFiles,
1104
1162
  middlewares,
1105
- loader,
1163
+ loader: serverHook,
1164
+ // Keep 'loader' field name for backward compatibility
1165
+ layoutServerHooks,
1166
+ // Server hooks for each layout (same order as layouts)
1106
1167
  dynamic,
1107
1168
  generateStaticParams
1108
1169
  };
@@ -4331,8 +4392,8 @@ async function runRouteMiddlewares(route, ctx) {
4331
4392
  }
4332
4393
  }
4333
4394
 
4334
- // modules/server/handlers/loader.ts
4335
- async function runRouteLoader(route, ctx) {
4395
+ // modules/server/handlers/server-hook.ts
4396
+ async function runRouteServerHook(route, ctx) {
4336
4397
  if (!route.loader) {
4337
4398
  return { props: {} };
4338
4399
  }
@@ -4489,11 +4550,39 @@ async function handlePageRequestInternal(options) {
4489
4550
  pathname: urlPath,
4490
4551
  locals: {}
4491
4552
  };
4492
- let loaderResult2 = await runRouteLoader(notFoundPage, ctx2);
4553
+ const layoutProps2 = {};
4554
+ if (notFoundPage.layoutServerHooks && notFoundPage.layoutServerHooks.length > 0) {
4555
+ for (let i = 0; i < notFoundPage.layoutServerHooks.length; i++) {
4556
+ const layoutServerHook = notFoundPage.layoutServerHooks[i];
4557
+ if (layoutServerHook) {
4558
+ try {
4559
+ const layoutResult = await layoutServerHook(ctx2);
4560
+ if (layoutResult.props) {
4561
+ Object.assign(layoutProps2, layoutResult.props);
4562
+ }
4563
+ } catch (error) {
4564
+ const reqLogger2 = getRequestLogger(req);
4565
+ reqLogger2.warn(`Layout server hook ${i} failed for not-found`, {
4566
+ error,
4567
+ layoutFile: notFoundPage.layoutFiles[i]
4568
+ });
4569
+ }
4570
+ }
4571
+ }
4572
+ }
4573
+ let loaderResult2 = await runRouteServerHook(notFoundPage, ctx2);
4493
4574
  if (!loaderResult2.theme) {
4494
4575
  loaderResult2.theme = theme;
4495
4576
  }
4496
- const initialData2 = buildInitialData(urlPath, {}, loaderResult2);
4577
+ const combinedProps2 = {
4578
+ ...layoutProps2,
4579
+ ...loaderResult2.props || {}
4580
+ };
4581
+ const combinedLoaderResult2 = {
4582
+ ...loaderResult2,
4583
+ props: combinedProps2
4584
+ };
4585
+ const initialData2 = buildInitialData(urlPath, {}, combinedLoaderResult2);
4497
4586
  const appTree2 = buildAppTree(notFoundPage, {}, initialData2.props);
4498
4587
  initialData2.notFound = true;
4499
4588
  const nonce2 = res.locals.nonce || void 0;
@@ -4501,7 +4590,7 @@ async function handlePageRequestInternal(options) {
4501
4590
  appTree: appTree2,
4502
4591
  initialData: initialData2,
4503
4592
  routerData,
4504
- meta: loaderResult2.metadata ?? null,
4593
+ meta: combinedLoaderResult2.metadata ?? null,
4505
4594
  titleFallback: "Not found",
4506
4595
  descriptionFallback: "Loly demo",
4507
4596
  chunkHref: null,
@@ -4520,8 +4609,8 @@ async function handlePageRequestInternal(options) {
4520
4609
  },
4521
4610
  onShellError(err) {
4522
4611
  didError2 = true;
4523
- const reqLogger = getRequestLogger(req);
4524
- reqLogger.error("SSR shell error", err, { route: "not-found" });
4612
+ const reqLogger2 = getRequestLogger(req);
4613
+ reqLogger2.error("SSR shell error", err, { route: "not-found" });
4525
4614
  if (!res.headersSent && errorPage) {
4526
4615
  renderErrorPageWithStream(errorPage, req, res, err, routeChunks, theme, projectRoot, env);
4527
4616
  } else if (!res.headersSent) {
@@ -4533,8 +4622,8 @@ async function handlePageRequestInternal(options) {
4533
4622
  },
4534
4623
  onError(err) {
4535
4624
  didError2 = true;
4536
- const reqLogger = getRequestLogger(req);
4537
- reqLogger.error("SSR error", err, { route: "not-found" });
4625
+ const reqLogger2 = getRequestLogger(req);
4626
+ reqLogger2.error("SSR error", err, { route: "not-found" });
4538
4627
  }
4539
4628
  });
4540
4629
  req.on("close", () => abort2());
@@ -4556,9 +4645,30 @@ async function handlePageRequestInternal(options) {
4556
4645
  if (res.headersSent) {
4557
4646
  return;
4558
4647
  }
4648
+ const layoutProps = {};
4649
+ const reqLogger = getRequestLogger(req);
4650
+ if (route.layoutServerHooks && route.layoutServerHooks.length > 0) {
4651
+ for (let i = 0; i < route.layoutServerHooks.length; i++) {
4652
+ const layoutServerHook = route.layoutServerHooks[i];
4653
+ if (layoutServerHook) {
4654
+ try {
4655
+ const layoutResult = await layoutServerHook(ctx);
4656
+ if (layoutResult.props) {
4657
+ Object.assign(layoutProps, layoutResult.props);
4658
+ }
4659
+ } catch (error) {
4660
+ reqLogger.warn(`Layout server hook ${i} failed`, {
4661
+ error,
4662
+ layoutFile: route.layoutFiles[i],
4663
+ route: route.pattern
4664
+ });
4665
+ }
4666
+ }
4667
+ }
4668
+ }
4559
4669
  let loaderResult;
4560
4670
  try {
4561
- loaderResult = await runRouteLoader(route, ctx);
4671
+ loaderResult = await runRouteServerHook(route, ctx);
4562
4672
  if (!loaderResult.theme) {
4563
4673
  loaderResult.theme = theme;
4564
4674
  }
@@ -4577,8 +4687,18 @@ async function handlePageRequestInternal(options) {
4577
4687
  }
4578
4688
  }
4579
4689
  }
4690
+ const combinedProps = {
4691
+ ...layoutProps,
4692
+ // Props from layouts (stable)
4693
+ ...loaderResult.props || {}
4694
+ // Props from page (overrides layout)
4695
+ };
4696
+ const combinedLoaderResult = {
4697
+ ...loaderResult,
4698
+ props: combinedProps
4699
+ };
4580
4700
  if (isDataReq) {
4581
- handleDataResponse(res, loaderResult, theme);
4701
+ handleDataResponse(res, combinedLoaderResult, theme);
4582
4702
  return;
4583
4703
  }
4584
4704
  if (loaderResult.redirect) {
@@ -4593,7 +4713,7 @@ async function handlePageRequestInternal(options) {
4593
4713
  }
4594
4714
  return;
4595
4715
  }
4596
- const initialData = buildInitialData(urlPath, params, loaderResult);
4716
+ const initialData = buildInitialData(urlPath, params, combinedLoaderResult);
4597
4717
  const appTree = buildAppTree(route, params, initialData.props);
4598
4718
  const chunkName = routeChunks[route.pattern];
4599
4719
  let chunkHref = null;
@@ -4609,7 +4729,7 @@ async function handlePageRequestInternal(options) {
4609
4729
  appTree,
4610
4730
  initialData,
4611
4731
  routerData,
4612
- meta: loaderResult.metadata,
4732
+ meta: combinedLoaderResult.metadata,
4613
4733
  titleFallback: "Loly framework",
4614
4734
  descriptionFallback: "Loly demo",
4615
4735
  chunkHref,
@@ -4630,8 +4750,8 @@ async function handlePageRequestInternal(options) {
4630
4750
  },
4631
4751
  onShellError(err) {
4632
4752
  didError = true;
4633
- const reqLogger = getRequestLogger(req);
4634
- reqLogger.error("SSR shell error", err, { route: matched?.route?.pattern || "unknown" });
4753
+ const reqLogger2 = getRequestLogger(req);
4754
+ reqLogger2.error("SSR shell error", err, { route: matched?.route?.pattern || "unknown" });
4635
4755
  if (!res.headersSent && errorPage) {
4636
4756
  renderErrorPageWithStream(errorPage, req, res, err, routeChunks, theme, projectRoot, env);
4637
4757
  } else if (!res.headersSent) {
@@ -4643,8 +4763,8 @@ async function handlePageRequestInternal(options) {
4643
4763
  },
4644
4764
  onError(err) {
4645
4765
  didError = true;
4646
- const reqLogger = getRequestLogger(req);
4647
- reqLogger.error("SSR error", err, { route: matched?.route?.pattern || "unknown" });
4766
+ const reqLogger2 = getRequestLogger(req);
4767
+ reqLogger2.error("SSR error", err, { route: matched?.route?.pattern || "unknown" });
4648
4768
  }
4649
4769
  });
4650
4770
  req.on("close", () => {
@@ -4661,11 +4781,39 @@ async function renderErrorPageWithStream(errorPage, req, res, error, routeChunks
4661
4781
  pathname: req.path,
4662
4782
  locals: { error }
4663
4783
  };
4664
- let loaderResult = await runRouteLoader(errorPage, ctx);
4784
+ const layoutProps = {};
4785
+ const reqLogger = getRequestLogger(req);
4786
+ if (errorPage.layoutServerHooks && errorPage.layoutServerHooks.length > 0) {
4787
+ for (let i = 0; i < errorPage.layoutServerHooks.length; i++) {
4788
+ const layoutServerHook = errorPage.layoutServerHooks[i];
4789
+ if (layoutServerHook) {
4790
+ try {
4791
+ const layoutResult = await layoutServerHook(ctx);
4792
+ if (layoutResult.props) {
4793
+ Object.assign(layoutProps, layoutResult.props);
4794
+ }
4795
+ } catch (err) {
4796
+ reqLogger.warn(`Layout server hook ${i} failed for error page`, {
4797
+ error: err,
4798
+ layoutFile: errorPage.layoutFiles[i]
4799
+ });
4800
+ }
4801
+ }
4802
+ }
4803
+ }
4804
+ let loaderResult = await runRouteServerHook(errorPage, ctx);
4665
4805
  if (!loaderResult.theme && theme) {
4666
4806
  loaderResult.theme = theme;
4667
4807
  }
4668
- const initialData = buildInitialData(req.path, { error: String(error) }, loaderResult);
4808
+ const combinedProps = {
4809
+ ...layoutProps,
4810
+ ...loaderResult.props || {}
4811
+ };
4812
+ const combinedLoaderResult = {
4813
+ ...loaderResult,
4814
+ props: combinedProps
4815
+ };
4816
+ const initialData = buildInitialData(req.path, { error: String(error) }, combinedLoaderResult);
4669
4817
  const routerData = buildRouterData(req);
4670
4818
  initialData.error = true;
4671
4819
  if (isDataReq) {
@@ -4675,8 +4823,8 @@ async function renderErrorPageWithStream(errorPage, req, res, error, routeChunks
4675
4823
  error: true,
4676
4824
  message: String(error),
4677
4825
  props: initialData.props,
4678
- metadata: loaderResult.metadata ?? null,
4679
- theme: loaderResult.theme ?? theme ?? null
4826
+ metadata: combinedLoaderResult.metadata ?? null,
4827
+ theme: combinedLoaderResult.theme ?? theme ?? null
4680
4828
  }));
4681
4829
  return;
4682
4830
  }
@@ -4698,7 +4846,7 @@ async function renderErrorPageWithStream(errorPage, req, res, error, routeChunks
4698
4846
  appTree,
4699
4847
  initialData,
4700
4848
  routerData,
4701
- meta: loaderResult.metadata ?? null,
4849
+ meta: combinedLoaderResult.metadata ?? null,
4702
4850
  titleFallback: "Error",
4703
4851
  descriptionFallback: "An error occurred",
4704
4852
  chunkHref,
@@ -4719,8 +4867,8 @@ async function renderErrorPageWithStream(errorPage, req, res, error, routeChunks
4719
4867
  },
4720
4868
  onShellError(err) {
4721
4869
  didError = true;
4722
- const reqLogger = getRequestLogger(req);
4723
- reqLogger.error("Error rendering error page", err, { type: "shellError" });
4870
+ const reqLogger2 = getRequestLogger(req);
4871
+ reqLogger2.error("Error rendering error page", err, { type: "shellError" });
4724
4872
  if (!res.headersSent) {
4725
4873
  res.statusCode = 500;
4726
4874
  res.setHeader("Content-Type", "text/html; charset=utf-8");
@@ -4730,8 +4878,8 @@ async function renderErrorPageWithStream(errorPage, req, res, error, routeChunks
4730
4878
  },
4731
4879
  onError(err) {
4732
4880
  didError = true;
4733
- const reqLogger = getRequestLogger(req);
4734
- reqLogger.error("Error in error page", err);
4881
+ const reqLogger2 = getRequestLogger(req);
4882
+ reqLogger2.error("Error in error page", err);
4735
4883
  }
4736
4884
  });
4737
4885
  req.on("close", () => {