alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
661 lines (611 loc) • 20.2 kB
text/typescript
import {
$inject,
type Async,
createPrimitive,
KIND,
type Middleware,
OPTIONS,
Primitive,
type Static,
type TSchema,
} from "alepha";
import { $cache } from "alepha/cache";
import type { Head } from "alepha/react/head";
import type { ServerRequest } from "alepha/server";
import type { FC, ReactNode } from "react";
import { PAGE_PRELOAD_KEY } from "../constants/PAGE_PRELOAD_KEY.ts";
import type { Redirection } from "../errors/Redirection.ts";
import type { ReactRouterState } from "../providers/ReactPageProvider.ts";
import { ReactPageService } from "../services/ReactPageService.ts";
/**
* Main primitive for defining a React route in the application.
*
* The $page primitive is the core building block for creating type-safe, SSR-enabled React routes.
* It provides a declarative way to define pages with powerful features:
*
* **Routing & Navigation**
* - URL pattern matching with parameters (e.g., `/users/:id`)
* - Nested routing with parent-child relationships
* - Type-safe URL parameter and query string validation
*
* **Data Loading**
* - Server-side data fetching with the `loader` function
* - Automatic serialization and hydration for SSR
* - Access to request context, URL params, and parent data
*
* **Component Loading**
* - Direct component rendering or lazy loading for code splitting
* - Client-only rendering when browser APIs are needed
* - Automatic fallback handling during hydration
*
* **Performance Optimization**
* - Static generation for pre-rendered pages at build time
* - Server-side caching with configurable TTL and providers
* - Code splitting through lazy component loading
*
* **Error Handling**
* - Custom error handlers with support for redirects
* - Hierarchical error handling (child → parent)
* - HTTP status code handling (404, 401, etc.)
*
* @example Simple page with data fetching
* ```typescript
* const userProfile = $page({
* path: "/users/:id",
* schema: {
* params: z.object({ id: z.integer() }),
* query: z.object({ tab: z.text().optional() })
* },
* loader: async ({ params }) => {
* const user = await userApi.getUser(params.id);
* return { user };
* },
* lazy: () => import("./UserProfile.tsx")
* });
* ```
*
* @example Nested routing with error handling
* ```typescript
* const projectSection = $page({
* path: "/projects/:id",
* children: () => [projectBoard, projectSettings],
* loader: async ({ params }) => {
* const project = await projectApi.get(params.id);
* return { project };
* },
* errorHandler: (error) => {
* if (HttpError.is(error, 404)) {
* return <ProjectNotFound />;
* }
* }
* });
* ```
*
* @example Static generation with caching
* ```typescript
* const blogPost = $page({
* path: "/blog/:slug",
* static: {
* entries: posts.map(p => ({ params: { slug: p.slug } }))
* },
* loader: async ({ params }) => {
* const post = await loadPost(params.slug);
* return { post };
* }
* });
* ```
*/
export const $page = <
TConfig extends PageConfigSchema = PageConfigSchema,
TProps extends object = TPropsDefault,
TPropsParent extends object = TPropsParentDefault,
>(
options: PagePrimitiveOptions<TConfig, TProps, TPropsParent>,
): PagePrimitive<TConfig, TProps, TPropsParent> => {
return createPrimitive(PagePrimitive<TConfig, TProps, TPropsParent>, options);
};
// ---------------------------------------------------------------------------------------------------------------------
export interface PagePrimitiveOptions<
TConfig extends PageConfigSchema = PageConfigSchema,
TProps extends object = TPropsDefault,
TPropsParent extends object = TPropsParentDefault,
> {
/**
* Identifier name for the page. Must be unique.
*
* @default Primitive key
*/
name?: string;
/**
* Add a pathname to the page.
*
* Pathname can contain parameters, like `/post/:slug`.
*
* @default ""
*/
path?: string;
/**
* Add an input schema to define:
* - `params`: parameters from the pathname.
* - `query`: query parameters from the URL.
*/
schema?: TConfig;
/**
* Middleware to apply to the loader function.
* Works the same as `use` on `$action` and `$job`.
*
* @example
* ```ts
* dashboard = $page({
* use: [$cache({ ttl: [5, "minutes"] })],
* loader: async ({ params }) => this.dashboardService.getData(),
* lazy: () => import("./Dashboard.tsx"),
* });
* ```
*/
use?: Middleware[];
/**
* Load data before rendering the page.
*
* This function receives
* - the request context (params, query, etc.)
* - the parent props (if page has a parent)
*
* > In SSR, the returned data will be serialized and sent to the client, then reused during the client-side hydration.
*
* Loader can be stopped by throwing an error, which will be handled by the `errorHandler` function.
* It's common to throw a `NotFoundError` to display a 404 page.
*
* RedirectError can be thrown to redirect the user to another page.
*/
loader?: (context: PageLoader<TConfig, TPropsParent>) => Async<TProps>;
/**
* Default props to pass to the component when rendering the page.
*
* Resolved props from the `resolve` function will override these default props.
*/
props?: () => Partial<TProps>;
/**
* The component to render when the page is loaded.
*
* If `lazy` is defined, this will be ignored.
* Prefer using `lazy` to improve the initial loading time.
*/
component?: FC<TProps & TPropsParent>;
/**
* Lazy load the component when the page is loaded.
*
* It's recommended to use this for components to improve the initial loading time
* and enable code-splitting.
*/
lazy?: () => Promise<{ default: FC<TProps & TPropsParent> }>;
/**
* Attach child pages to create nested routes, adopting them as children of
* this page.
*
* Use this when you want a parent to own children it cannot modify — most
* notably pages that come from an injected router in another package, whose
* `$page` definitions are frozen and cannot declare `parent` themselves.
*
* ```ts
* layout = $page({
* path: "/app",
* children: () => [
* this.productRouter.catalogPage, // from $inject(ProductRouter)
* this.productRouter.checkoutPage,
* ],
* });
* ```
*
* Use a thunk (`() => [...]`) when the children are defined later in the
* same class.
*
* **Declare each edge from one side only.** If a child already sets
* `parent: thisPage`, do NOT also add it to `children` — the link is
* already established, and declaring it on both sides creates a TypeScript
* circular dependency between the two class fields (each references the
* other before it is initialised).
*/
children?: Array<PagePrimitive> | (() => Array<PagePrimitive>);
/**
* Define a parent page for nested routing.
*
* Use this when you own the child page and can edit its definition — it is
* the simplest way to nest routes and reads top-down. For pages you do NOT
* own (e.g. pages exposed by an injected router from another package), let
* the parent adopt them via its `children` option instead.
*
* **Declare each edge from one side only.** If you set `parent` here, do
* NOT also add this page to the parent's `children` array — the link is
* already established, and declaring it on both sides creates a TypeScript
* circular dependency between the two class fields.
*/
parent?: PagePrimitive<PageConfigSchema, TPropsParent, any>;
/**
* UI-affordance predicate for this page's navigation entry — **NOT security**.
* For real access control, gate the route with `use: [$secure({ permissions })]`
* (server-enforced); `can` is only consulted by navigation surfaces (sidebar,
* breadcrumbs, command palette), never by the router.
*
* Receives a `{ has }` context (a permission probe equivalent to
* `useAuth().has`) so it can express arbitrary access logic — including
* OR-of-permissions, which `nav.permission` (AND) cannot.
*
* - `true` (or omitted) → nav entry visible and enabled.
* - `"disabled"` → visible but disabled (greyed — e.g. insufficient role / paywalled).
* - `false` → hidden.
*
* For the common "needs these permissions" case, prefer the declarative
* {@link PageNav.permission} instead.
*/
can?: (ctx: PageCanContext) => boolean | "disabled";
/**
* Navigation metadata — declares this page's presence in navigation
* surfaces: the sidebar, the breadcrumb trail, and a command palette
* (Spotlight). A page WITHOUT `nav` is route-only (reachable by URL but not
* listed).
*
* `$page` is pure React, so `label` / `icon` / `description` / `badge` accept
* `ReactNode` (pass `<Users />` directly).
*
* Visibility is UI-only (NOT security — gate the route with `use: [$secure(...)]`):
* an entry is hidden when `nav.hidden`, when `nav.permission` is set and not
* fully granted, or when `can()` returns `false`; it renders disabled when
* `can()` returns `"disabled"`.
*/
nav?: PageNav;
/**
* Catch any error from the `loader` function or during `rendering`.
*
* Expected to return one of the following:
* - a ReactNode to render an error page
* - a Redirection to redirect the user
* - undefined to let the error propagate
*
* If not defined, the error will be thrown and handled by the server or client error handler.
* If a leaf $page does not define an error handler, the error can be caught by parent pages.
*
* @example Catch a 404 from API and render a custom not found component:
* ```ts
* loader: async ({ params, query }) => {
* api.fetch("/api/resource", { params, query });
* },
* errorHandler: (error, context) => {
* if (HttpError.is(error, 404)) {
* return <ResourceNotFound />;
* }
* }
* ```
*
* @example Catch an 401 error and redirect the user to the login page:
* ```ts
* loader: async ({ params, query }) => {
* // but the user is not authenticated
* api.fetch("/api/resource", { params, query });
* },
* errorHandler: (error, context) => {
* if (HttpError.is(error, 401)) {
* // throwing a Redirection is also valid!
* return new Redirection("/login");
* }
* }
* ```
*/
errorHandler?: ErrorHandler;
/**
* If true, the page will be considered as a static page, immutable and cacheable.
* Replace boolean by an object to define static entries. (e.g. list of params/query)
*
* Browser-side: it only works with the build pipeline, which can pre-render the page at build time.
*
* Server-side: It will act as timeless cached page. You can use `cache` to configure the cache behavior.
*/
static?:
| boolean
| {
entries?: Array<Partial<PageRequestConfig<TConfig>>>;
};
/**
* Enable or disable server-side rendering for this page.
*
* - `true` (default): the page component is rendered on the server and
* hydrated on the client.
* - `false`: the loader still runs on the server (so data is preloaded and
* serialized for hydration), but the component is rendered only on the
* client. The server emits no HTML for this page.
*
* **Decided at the leaf, inherited as default by descendants.**
*
* The effective value is determined by the matched leaf page: walk up the
* parent chain and use the nearest explicit `ssr` value. Setting
* `ssr: false` on a parent therefore acts as the default for its children;
* a child can override with `ssr: true`.
*
* Skipping rendering while keeping the loader is the recommended strategy
* for CPU-constrained server environments (e.g. Cloudflare Workers) and
* heavy admin/dashboard views where SSR provides little SEO value.
*
* @example
* ```ts
* root = $page({ ssr: false }); // default for children
* home = $page({ parent: root, ssr: true }); // overrides → SSR
* about = $page({ parent: root }); // inherits → no SSR
* ```
*
* @default true
*/
ssr?: boolean;
/**
* Called before the server response is sent to the client. (server only)
*/
onServerResponse?: (request: ServerRequest) => unknown;
/**
* Called when user enters the page. (browser only)
*
* Useful for browser-only side effects like analytics, scroll management,
* or focus handling that don't need to return data to the component.
*
* @example
* ```ts
* onEnter: () => {
* analytics.trackPageView("/dashboard");
* window.scrollTo(0, 0);
* }
* ```
*/
onEnter?: () => void;
/**
* Called when user leaves the page. (browser only)
*/
onLeave?: () => void;
/**
* @experimental
*
* Add a css animation when the page is loaded or unloaded.
* It uses CSS animations, so you need to define the keyframes in your CSS.
*
* @example Simple animation name
* ```ts
* animation: "fadeIn"
* ```
*
* CSS example:
* ```css
* @keyframes fadeIn {
* from { opacity: 0; }
* to { opacity: 1; }
* }
* ```
*
* @example Detailed animation
* ```ts
* animation: {
* enter: { name: "fadeIn", duration: 300 },
* exit: { name: "fadeOut", duration: 200, timing: "ease-in-out" },
* }
* ```
*
* @example Only exit animation
* ```ts
* animation: {
* exit: "fadeOut"
* }
* ```
*
* @example With custom timing function
* ```ts
* animation: {
* enter: { name: "fadeIn", duration: 300, timing: "cubic-bezier(0.4, 0, 0.2, 1)" },
* exit: { name: "fadeOut", duration: 200, timing: "ease-in-out" },
* }
* ```
*/
animation?: PageAnimation;
/**
* Head configuration for the page (title, meta tags, etc.).
*
* Can be a static object or a function that receives resolved props.
*
* @example Static head
* ```ts
* head: {
* title: "My Page",
* description: "Page description",
* }
* ```
*
* @example Dynamic head based on props
* ```ts
* head: (props) => ({
* title: props.user.name,
* description: `Profile of ${props.user.name}`,
* })
* ```
*/
head?: Head | ((props: TProps, previous?: Head) => Head);
/**
* Redirect to another path when this page is matched.
*
* This is a shorthand for throwing a `Redirection` in the loader.
* The redirect is performed before any loader or component rendering.
*
* @example
* ```ts
* home = $page({
* path: "/",
* redirect: "/dashboard",
* });
* ```
*/
redirect?: string;
/**
* Label for the page, used for navigation menus or breadcrumbs.
*
* This is optional and can be used by the application to display a user-friendly name for the page.
* It has no functional impact on routing or rendering.
*/
label?: string;
/**
* Source path for SSR module preloading.
*
* This is automatically injected by the viteAlephaPreload plugin.
* It maps to the source file path used in Vite's SSR manifest.
*
* @internal
*/
[PAGE_PRELOAD_KEY]?: string;
}
// ---------------------------------------------------------------------------------------------------------------------
export class PagePrimitive<
TConfig extends PageConfigSchema = PageConfigSchema,
TProps extends object = TPropsDefault,
TPropsParent extends object = TPropsParentDefault,
> extends Primitive<PagePrimitiveOptions<TConfig, TProps, TPropsParent>> {
protected readonly reactPageService = $inject(ReactPageService);
protected onInit() {
if (this.options.static) {
this.options.use ??= [];
if (!this.options.use.some((m) => m[OPTIONS]?.name === "$cache")) {
this.options.use.push(
$cache({
name: `page:${this.name}`,
provider: "memory",
ttl: [1, "week"],
}),
);
}
}
}
public get name(): string {
return this.options.name ?? this.config.propertyKey;
}
/**
* For testing or build purposes.
*
* This will render the page (HTML layout included or not) and return the HTML + context.
* Only valid for server-side rendering, it will throw an error if called on the client-side.
*/
public async render(
options?: PagePrimitiveRenderOptions,
): Promise<PagePrimitiveRenderResult> {
return this.reactPageService.render(this.name, options);
}
public async fetch(options?: PagePrimitiveRenderOptions): Promise<{
html: string;
response: Response;
}> {
return this.reactPageService.fetch(this.options.path || "", options);
}
}
$page[KIND] = PagePrimitive;
// ---------------------------------------------------------------------------------------------------------------------
/**
* Context passed to {@link PagePrimitiveOptions.can} — a permission probe
* (equivalent to `useAuth().has`) supplied by the navigation builder.
*/
export interface PageCanContext {
has: (permission: string) => boolean;
}
/**
* Navigation metadata for a page — see {@link PagePrimitiveOptions.nav}.
* Consumed by the sidebar, breadcrumbs and the command palette, all derived
* from the route tree (no separate nav list).
*/
export interface PageNav {
/**
* Label for nav surfaces. Falls back to the page's `label`, then `head.title`.
*/
label?: ReactNode;
/**
* Leading icon — a React element, e.g. `icon: <Users />`.
*/
icon?: ReactNode;
/**
* Section the entry belongs to: sidebar group heading + command-palette section.
*/
group?: string;
/**
* Sort order within the group (ascending).
*/
order?: number;
/**
* Permission(s) required to SEE the entry — a single string or an array
* (ALL required, matching `$secure({ permissions })`). UI gate only; the
* real route gate is `use: [$secure(...)]`. For OR / custom logic use `can`.
*/
permission?: string | string[];
/**
* Extra search terms for the command palette (synonyms / aliases beyond the label).
*/
keywords?: string[];
/**
* Short description — command-palette subtitle / sidebar tooltip.
*/
description?: ReactNode;
/**
* Trailing badge (unread count, "New", …) for the sidebar entry.
*/
badge?: ReactNode;
/**
* Keep the route but exclude it from nav surfaces (e.g. a `/users/:id` detail page).
*/
hidden?: boolean;
}
export type ErrorHandler = (
error: Error,
state: ReactRouterState,
) => ReactNode | Redirection | undefined;
export interface PageConfigSchema {
query?: TSchema;
params?: TSchema;
}
export type TPropsDefault = any;
export type TPropsParentDefault = {};
export interface PagePrimitiveRenderOptions {
params?: Record<string, string>;
query?: Record<string, string>;
/**
* If true, the HTML layout will be included in the response.
* If false, only the page content will be returned.
*
* @default true
*/
html?: boolean;
hydration?: boolean;
}
export interface PagePrimitiveRenderResult {
html: string;
state: ReactRouterState;
redirect?: string;
}
export interface PageRequestConfig<
TConfig extends PageConfigSchema = PageConfigSchema,
> {
params: TConfig["params"] extends TSchema
? Static<TConfig["params"]>
: Record<string, string>;
query: TConfig["query"] extends TSchema
? Static<TConfig["query"]>
: Record<string, string>;
}
export type PageLoader<
TConfig extends PageConfigSchema = PageConfigSchema,
TPropsParent extends object = TPropsParentDefault,
> = PageRequestConfig<TConfig> &
TPropsParent &
Omit<ReactRouterState, "layers" | "onError">;
export type PageAnimation =
| PageAnimationObject
| ((state: ReactRouterState) => PageAnimationObject | undefined);
type PageAnimationObject =
| CssAnimationName
| {
enter?: CssAnimation | CssAnimationName;
exit?: CssAnimation | CssAnimationName;
};
type CssAnimationName = string;
type CssAnimation = {
name: string;
duration?: number;
timing?: string;
};