@lolyjs/core 0.2.0-alpha.26 → 0.2.0-alpha.28

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
@@ -342,6 +342,327 @@ Layouts can have their own server hooks that provide stable data across all page
342
342
  - Layout server hooks: `app/layout.server.hook.ts` (same directory as `layout.tsx`)
343
343
  - Page server hooks: `app/page.server.hook.ts` (preferred) or `app/server.hook.ts` (legacy, backward compatible)
344
344
 
345
+ ### 📦 Route Groups
346
+
347
+ Route groups allow you to organize routes without affecting the URL structure. Directories wrapped in parentheses like `(dashboard)` or `(landing)` are treated as route groups and don't appear in the URL.
348
+
349
+ **Key Features:**
350
+ - Route groups don't appear in URLs
351
+ - Each route group can have its own layout
352
+ - Route groups help organize large applications
353
+ - Layouts are applied in order: root → route group → nested → page
354
+
355
+ **Example Structure:**
356
+
357
+ ```
358
+ app/
359
+ ├── layout.tsx # Root layout (applies to all routes)
360
+ ├── layout.server.hook.ts # Root layout server hook
361
+ ├── (dashboard)/
362
+ │ ├── layout.tsx # Dashboard layout (applies to /settings, /profile)
363
+ │ ├── layout.server.hook.ts # Dashboard layout server hook
364
+ │ ├── settings/
365
+ │ │ └── page.tsx # → /settings (NOT /dashboard/settings)
366
+ │ └── profile/
367
+ │ └── page.tsx # → /profile (NOT /dashboard/profile)
368
+ ├── (landing)/
369
+ │ ├── layout.tsx # Landing layout (applies to /about, /contact)
370
+ │ ├── layout.server.hook.ts # Landing layout server hook
371
+ │ ├── about/
372
+ │ │ └── page.tsx # → /about (NOT /landing/about)
373
+ │ └── contact/
374
+ │ └── page.tsx # → /contact (NOT /landing/contact)
375
+ └── page.tsx # → / (root page)
376
+ ```
377
+
378
+ **Layout Order:**
379
+
380
+ For a page at `app/(dashboard)/settings/page.tsx`, layouts are applied in this order:
381
+
382
+ 1. `app/layout.tsx` (root layout)
383
+ 2. `app/(dashboard)/layout.tsx` (route group layout)
384
+ 3. `app/(dashboard)/settings/layout.tsx` (if exists, nested layout)
385
+
386
+ **Server Hooks:**
387
+
388
+ Server hooks work the same way with route groups. The execution order is:
389
+
390
+ 1. Root layout hook (`app/layout.server.hook.ts`)
391
+ 2. Route group layout hook (`app/(dashboard)/layout.server.hook.ts`)
392
+ 3. Nested layout hooks (if any)
393
+ 4. Page hook (`app/(dashboard)/settings/page.server.hook.ts`)
394
+
395
+ **Example:**
396
+
397
+ ```tsx
398
+ // app/(dashboard)/layout.tsx
399
+ export default function DashboardLayout({ children, user }) {
400
+ return (
401
+ <div className="dashboard">
402
+ <nav>Dashboard Navigation</nav>
403
+ {children}
404
+ </div>
405
+ );
406
+ }
407
+ ```
408
+
409
+ ```tsx
410
+ // app/(dashboard)/layout.server.hook.ts
411
+ import type { ServerLoader } from "@lolyjs/core";
412
+
413
+ export const getServerSideProps: ServerLoader = async (ctx) => {
414
+ const user = await getCurrentUser(ctx.req);
415
+ return {
416
+ props: {
417
+ user, // Available to all pages in (dashboard) group
418
+ },
419
+ };
420
+ };
421
+ ```
422
+
423
+ ```tsx
424
+ // app/(dashboard)/settings/page.tsx
425
+ export default function SettingsPage({ user, settings }) {
426
+ // user comes from (dashboard)/layout.server.hook.ts
427
+ // settings comes from page.server.hook.ts
428
+ return <div>Settings for {user.name}</div>;
429
+ }
430
+ ```
431
+
432
+ **Important Notes:**
433
+ - Route groups are purely organizational - they don't affect URLs
434
+ - You cannot have duplicate routes that would result from ignoring route groups
435
+ - ❌ Invalid: `app/(dashboard)/settings/page.tsx` and `app/settings/page.tsx` (both map to `/settings`)
436
+ - ✅ Valid: `app/(dashboard)/settings/page.tsx` and `app/(landing)/settings/page.tsx` (both map to `/settings` - conflict!)
437
+ - Route groups work seamlessly with SPA navigation and preloading
438
+
439
+ **Future: Parallel Routes**
440
+
441
+ The architecture is prepared for future parallel routes support (e.g., `(modal)`). Route groups can be extended to support special types that render in parallel slots.
442
+
443
+ ### 🔄 URL Rewrites
444
+
445
+ URL rewrites allow you to rewrite incoming request paths to different destination paths internally, without changing the URL visible in the browser. This is especially useful for multitenancy, API proxying, and other advanced routing scenarios.
446
+
447
+ **Key Features:**
448
+ - Rewrites happen internally - the URL in the browser doesn't change
449
+ - Support for dynamic parameters (`:param`, `*` catch-all)
450
+ - Conditional rewrites based on host, headers, cookies, or query parameters
451
+ - Async destination functions for dynamic rewrites
452
+ - High performance with pre-compiled regex patterns and caching
453
+
454
+ **Configuration:**
455
+
456
+ Create `rewrites.config.ts` in your project root:
457
+
458
+ ```typescript
459
+ import type { RewriteConfig } from "@lolyjs/core";
460
+
461
+ export default async function rewrites(): Promise<RewriteConfig> {
462
+ return [
463
+ // Static rewrite
464
+ {
465
+ source: "/old-path",
466
+ destination: "/new-path",
467
+ },
468
+
469
+ // Rewrite with parameters
470
+ {
471
+ source: "/tenant/:tenant*",
472
+ destination: "/app/:tenant*",
473
+ },
474
+
475
+ // Rewrite with async function (for dynamic logic)
476
+ {
477
+ source: "/api/proxy/:path*",
478
+ destination: async (params, req) => {
479
+ const tenant = extractTenantFromRequest(req);
480
+ return `/api/${tenant}/:path*`;
481
+ },
482
+ },
483
+
484
+ // Conditional rewrite based on host (multitenant by subdomain)
485
+ {
486
+ source: "/:path*",
487
+ has: [
488
+ { type: "host", value: ":tenant.example.com" },
489
+ ],
490
+ destination: "/project/:tenant/:path*",
491
+ },
492
+ ];
493
+ }
494
+ ```
495
+
496
+ **Multitenant by Subdomain (Main Use Case):**
497
+
498
+ The most common use case is multitenancy where each tenant has its own subdomain:
499
+
500
+ ```typescript
501
+ // rewrites.config.ts
502
+ import type { RewriteConfig } from "@lolyjs/core";
503
+
504
+ export default async function rewrites(): Promise<RewriteConfig> {
505
+ return [
506
+ // Multitenant by subdomain - catch-all pattern
507
+ // tenant1.example.com/* → /project/tenant1/*
508
+ // tenant2.example.com/* → /project/tenant2/*
509
+ // All routes under the tenant subdomain will be rewritten
510
+ // If a route doesn't exist in /project/[tenantId]/*, it will return 404
511
+ {
512
+ source: "/:path*",
513
+ has: [
514
+ {
515
+ type: "host",
516
+ value: ":tenant.example.com" // Captures tenant from subdomain
517
+ }
518
+ ],
519
+ destination: "/project/:tenant/:path*",
520
+ },
521
+ ];
522
+ }
523
+ ```
524
+
525
+ **How It Works:**
526
+ - User visits: `tenant1.example.com/dashboard`
527
+ - Internally rewrites to: `/project/tenant1/dashboard`
528
+ - URL visible in browser: `tenant1.example.com/dashboard` (unchanged)
529
+ - Route `/project/[tenantId]/dashboard` receives `params.tenantId = "tenant1"`
530
+
531
+ **Multitenant by Path:**
532
+
533
+ Alternatively, you can use path-based multitenancy:
534
+
535
+ ```typescript
536
+ // rewrites.config.ts
537
+ export default async function rewrites(): Promise<RewriteConfig> {
538
+ return [
539
+ // /tenant1/dashboard → /project/tenant1/dashboard
540
+ {
541
+ source: "/:tenant/:path*",
542
+ destination: "/project/:tenant/:path*",
543
+ },
544
+ ];
545
+ }
546
+ ```
547
+
548
+ **API Proxy Example:**
549
+
550
+ ```typescript
551
+ export default async function rewrites(): Promise<RewriteConfig> {
552
+ return [
553
+ // Proxy all /api/proxy/* requests to external API
554
+ {
555
+ source: "/api/proxy/:path*",
556
+ destination: async (params, req) => {
557
+ const externalApi = process.env.EXTERNAL_API_URL;
558
+ return `${externalApi}/${params.path}`;
559
+ },
560
+ },
561
+ ];
562
+ }
563
+ ```
564
+
565
+ **Conditional Rewrites:**
566
+
567
+ Rewrites can be conditional based on request properties:
568
+
569
+ ```typescript
570
+ export default async function rewrites(): Promise<RewriteConfig> {
571
+ return [
572
+ // Rewrite based on host
573
+ {
574
+ source: "/:path*",
575
+ has: [
576
+ { type: "host", value: "api.example.com" },
577
+ ],
578
+ destination: "/api/:path*",
579
+ },
580
+
581
+ // Rewrite based on header
582
+ {
583
+ source: "/admin/:path*",
584
+ has: [
585
+ { type: "header", key: "X-Admin-Key", value: "secret" },
586
+ ],
587
+ destination: "/admin-panel/:path*",
588
+ },
589
+
590
+ // Rewrite based on cookie
591
+ {
592
+ source: "/premium/:path*",
593
+ has: [
594
+ { type: "cookie", key: "premium", value: "true" },
595
+ ],
596
+ destination: "/premium-content/:path*",
597
+ },
598
+
599
+ // Rewrite based on query parameter
600
+ {
601
+ source: "/:path*",
602
+ has: [
603
+ { type: "query", key: "version", value: "v2" },
604
+ ],
605
+ destination: "/v2/:path*",
606
+ },
607
+ ];
608
+ }
609
+ ```
610
+
611
+ **Pattern Syntax:**
612
+
613
+ - `:param` - Named parameter (matches one segment)
614
+ - `:param*` - Named catch-all (matches remaining path)
615
+ - `*` - Anonymous catch-all (matches remaining path)
616
+
617
+ **Accessing Extracted Parameters:**
618
+
619
+ Parameters extracted from rewrites (including from host conditions) are automatically available in:
620
+ - `req.query` - Query parameters
621
+ - `req.locals` - Request locals (for server hooks)
622
+ - `ctx.params` - Route parameters (if the rewritten path matches a dynamic route)
623
+
624
+ ```typescript
625
+ // app/project/[tenantId]/dashboard/page.server.hook.ts
626
+ export const getServerSideProps: ServerLoader = async (ctx) => {
627
+ // tenantId comes from the rewrite: /project/:tenant/:path*
628
+ const tenantId = ctx.params.tenantId;
629
+
630
+ // Also available in req.query and req.locals
631
+ const tenantFromQuery = ctx.req.query.tenant;
632
+ const tenantFromLocals = ctx.req.locals?.tenant;
633
+
634
+ return { props: { tenantId } };
635
+ };
636
+ ```
637
+
638
+ **Performance & Caching:**
639
+
640
+ - Rewrites config is loaded once and cached
641
+ - Regex patterns are pre-compiled for performance
642
+ - In development: File tracking invalidates cache only when `rewrites.config.ts` changes
643
+ - In production: Rewrites are loaded from manifest (faster, no async functions)
644
+
645
+ **Important Notes:**
646
+
647
+ - Rewrites are applied **before** route matching
648
+ - The original URL is preserved in the browser (not a redirect)
649
+ - Query parameters are preserved and can be extended
650
+ - Rewrites work for both pages and API routes
651
+ - Functions in rewrite destinations cannot be serialized in production builds (only static rewrites are included in manifest)
652
+ - Rewrites are evaluated in order - the first match wins
653
+ - **Behavior**: Rewrites are applied ALWAYS if the source pattern matches, regardless of whether the destination route exists
654
+ - If a rewritten route doesn't exist, a 404 will be returned (strict behavior, no fallback to original route)
655
+ - Catch-all patterns (`/:path*`) are fully supported and recommended for multitenancy scenarios
656
+ - **API Routes**: Can be rewritten. If rewritten route starts with `/api/`, it's handled as API route. Otherwise, it's handled as a page route
657
+ - **WSS Routes**: Automatically excluded from rewrites (WebSocket handled separately by Socket.IO)
658
+ - System routes (`/static/*`, `/__fw/*`, `/favicon.ico`) are automatically excluded from rewrites
659
+
660
+ **Validation:**
661
+
662
+ The framework automatically validates rewrites to prevent:
663
+ - Infinite loops (warns if source and destination are identical)
664
+ - Duplicate source patterns (warns if multiple rewrites have the same source)
665
+
345
666
  ### 🚀 Hybrid Rendering
346
667
 
347
668
  Choose the best rendering strategy for each page: