alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
1,637 lines • 60.8 kB
JavaScript
import { $atom, $hook, $inject, $module, $state, Alepha, AlephaError, KIND, OPTIONS, PipelineHandler, Primitive, SchemaValidator, coerceObject, createPrimitive, z } from "alepha";
import { AlephaDateTime, DateTimeProvider } from "alepha/datetime";
import { AlephaContext, AlephaReact, ClientOnly, ErrorBoundary, useAlepha, useEvents, useInject, useStore } from "alepha/react";
import { AlephaReactHead, BrowserHeadProvider } from "alepha/react/head";
import { AlephaServer } from "alepha/server";
import { AlephaServerLinks, LinkProvider } from "alepha/server/links";
import { $cache } from "alepha/cache";
import { $logger } from "alepha/logger";
import { RouterProvider } from "alepha/router";
import { StrictMode, createContext, createElement, memo, use, useEffect, useRef, useState } from "react";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { currentUserAtom } from "alepha/security";
import { createRoot, hydrateRoot } from "react-dom/client";
//#region ../../src/react/router/constants/PAGE_PRELOAD_KEY.ts
/**
* Symbol key for SSR module preloading path.
* Using Symbol.for() allows the Vite plugin to inject this at build time.
* @internal
*/
const PAGE_PRELOAD_KEY = Symbol.for("alepha.page.preload");
//#endregion
//#region ../../src/react/router/services/ReactPageService.ts
/**
* $page methods interface.
*/
var ReactPageService = class {
fetch(pathname, options = {}) {
throw new AlephaError("Fetch is not available for this environment.");
}
render(name, options = {}) {
throw new AlephaError("Render is not available for this environment.");
}
};
//#endregion
//#region ../../src/react/router/primitives/$page.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 };
* }
* });
* ```
*/
const $page = (options) => {
return createPrimitive(PagePrimitive, options);
};
var PagePrimitive = class extends Primitive {
reactPageService = $inject(ReactPageService);
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"]
}));
}
}
get name() {
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.
*/
async render(options) {
return this.reactPageService.render(this.name, options);
}
async fetch(options) {
return this.reactPageService.fetch(this.options.path || "", options);
}
};
$page[KIND] = PagePrimitive;
//#endregion
//#region ../../src/react/router/components/NotFound.tsx
/**
* Default 404 Not Found page component.
*/
const NotFound = (props) => /* @__PURE__ */ jsxs("div", {
style: {
width: "100%",
minHeight: "90vh",
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
textAlign: "center",
fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif",
padding: "2rem",
...props.style
},
children: [/* @__PURE__ */ jsx("div", {
style: {
fontSize: "6rem",
fontWeight: 200,
lineHeight: 1
},
children: "404"
}), /* @__PURE__ */ jsx("div", {
style: {
fontSize: "0.875rem",
marginTop: "1rem",
opacity: .6
},
children: "Page not found"
})]
});
//#endregion
//#region ../../src/react/router/errors/Redirection.ts
/**
* Used for Redirection during the page loading.
*
* Depends on the context, it can be thrown or just returned.
*
* @example
* ```ts
* import { Redirection } from "alepha/react";
*
* const MyPage = $page({
* loader: async () => {
* if (needRedirect) {
* throw new Redirection("/new-path");
* }
* },
* });
* ```
*/
var Redirection = class extends AlephaError {
redirect;
constructor(redirect) {
super("Redirection");
this.redirect = redirect;
}
};
//#endregion
//#region ../../src/react/router/hooks/useRouterState.ts
const useRouterState = () => {
const [state] = useStore("alepha.react.router.state");
if (!state) throw new AlephaError("Missing react router state");
return state;
};
//#endregion
//#region ../../src/react/router/components/ErrorViewer.tsx
const mono = "ui-monospace, \"JetBrains Mono\", SFMono-Regular, Menlo, Monaco, Consolas, monospace";
const ErrorViewer = (props) => {
if (props.alepha.isProduction()) return /* @__PURE__ */ jsx(ProdErrorPage, { error: props.error });
return /* @__PURE__ */ jsx(DevErrorPage, { ...props });
};
const DevErrorPage = (props) => {
const [copied, setCopied] = useState(false);
const pathname = useRouterState().url.pathname;
const handleCopy = () => {
const text = buildErrorText(props.error);
if (typeof navigator !== "undefined" && navigator.clipboard) {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2e3);
}
};
const status = getHttpStatus(props.error);
return /* @__PURE__ */ jsx("div", {
style: dev.overlay,
children: /* @__PURE__ */ jsxs("div", {
style: dev.card,
children: [
/* @__PURE__ */ jsxs("div", {
style: dev.header,
children: [/* @__PURE__ */ jsxs("div", {
style: dev.headerLeft,
children: [/* @__PURE__ */ jsx("div", {
style: dev.icon,
children: "!"
}), /* @__PURE__ */ jsx("div", {
style: dev.title,
children: props.error.name || "Error"
})]
}), /* @__PURE__ */ jsxs("div", {
style: dev.headerRight,
children: [props.onRetry && /* @__PURE__ */ jsx("button", {
type: "button",
style: dev.retryBtn,
onClick: props.onRetry,
children: "Retry"
}), /* @__PURE__ */ jsx("button", {
type: "button",
style: dev.copyBtn,
onClick: handleCopy,
children: copied ? "Copied" : "Copy"
})]
})]
}),
/* @__PURE__ */ jsx(ErrorBlock, { error: props.error }),
/* @__PURE__ */ jsxs("div", {
style: dev.meta,
children: [
pathname && /* @__PURE__ */ jsx("span", {
style: dev.metaItem,
children: pathname
}),
/* @__PURE__ */ jsx("span", {
style: dev.metaItem,
children: (/* @__PURE__ */ new Date()).toLocaleTimeString()
}),
status && /* @__PURE__ */ jsx("span", {
style: dev.statusBadge,
children: status
})
]
})
]
})
});
};
const ErrorBlock = (props) => {
const { error, depth = 0 } = props;
return /* @__PURE__ */ jsxs(Fragment, { children: [
depth > 0 && /* @__PURE__ */ jsx("div", {
style: dev.causedBy,
children: "Caused by:"
}),
/* @__PURE__ */ jsxs("pre", {
style: dev.messageBlock,
children: [
error.name,
": ",
error.message
]
}),
error.stack && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
style: dev.stackLabel,
children: "STACK TRACE"
}), /* @__PURE__ */ jsx("pre", {
style: dev.stackBlock,
children: cleanStack(error.stack)
})] }),
error.cause instanceof Error && /* @__PURE__ */ jsx(ErrorBlock, {
error: error.cause,
depth: depth + 1
})
] });
};
const ProdErrorPage = (props) => {
const requestId = props.error.requestId;
const status = getHttpStatus(props.error);
const handleReload = () => {
if (typeof window !== "undefined") window.location.reload();
};
const { heading, subtitle } = status === 401 ? {
heading: "Sign in required",
subtitle: "Please sign in to view this page."
} : status === 403 ? {
heading: "Access denied",
subtitle: "You do not have permission to view this page."
} : {
heading: "Something went wrong",
subtitle: "We're having trouble processing your request."
};
const canReload = status !== 401 && status !== 403;
return /* @__PURE__ */ jsx("div", {
style: prod.page,
children: /* @__PURE__ */ jsxs("div", {
style: prod.card,
children: [
/* @__PURE__ */ jsx("div", {
style: prod.heading,
children: heading
}),
/* @__PURE__ */ jsx("div", {
style: prod.subtitle,
children: subtitle
}),
requestId && /* @__PURE__ */ jsxs("div", {
style: prod.refText,
children: ["Reference: ", requestId]
}),
/* @__PURE__ */ jsxs("div", {
style: prod.actions,
children: [canReload && /* @__PURE__ */ jsx("button", {
type: "button",
style: prod.reloadBtn,
onClick: handleReload,
children: "Reload page"
}), /* @__PURE__ */ jsx("a", {
href: "/",
style: prod.homeLink,
children: "Go home"
})]
})
]
})
});
};
function cleanStack(stack) {
return stack.split("\n").slice(1).map((l) => l.trim()).join("\n");
}
function buildErrorText(error) {
const parts = [];
const append = (err, depth) => {
if (depth > 0) parts.push(`\nCaused by:`);
parts.push(`${err.name}: ${err.message}`);
if (err.stack) parts.push(cleanStack(err.stack));
if (err.cause instanceof Error) append(err.cause, depth + 1);
};
append(error, 0);
return parts.join("\n");
}
function getHttpStatus(error) {
if ("status" in error && typeof error.status === "number") return error.status;
}
const dev = {
overlay: {
position: "fixed",
inset: 0,
zIndex: 2147483647,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
backgroundColor: "rgba(0, 0, 0, 0.75)",
backdropFilter: "blur(4px)",
fontFamily: mono,
fontSize: "13px",
color: "#e5e7eb",
boxSizing: "border-box",
overflow: "auto"
},
card: {
width: "100%",
maxWidth: "900px",
maxHeight: "90vh",
overflow: "auto",
padding: "24px",
backgroundColor: "#111",
borderLeft: "4px solid #ef4444",
borderRadius: "8px",
boxShadow: "0 25px 50px rgba(0, 0, 0, 0.5)"
},
header: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "20px",
flexWrap: "wrap",
gap: "12px"
},
headerLeft: {
display: "flex",
alignItems: "center",
gap: "12px",
minWidth: 0
},
headerRight: {
display: "flex",
gap: "8px",
flexShrink: 0
},
icon: {
width: "28px",
height: "28px",
borderRadius: "50%",
backgroundColor: "#ef4444",
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 700,
fontSize: "16px",
flexShrink: 0
},
title: {
color: "#f87171",
fontSize: "18px",
fontWeight: 700
},
retryBtn: {
padding: "6px 16px",
backgroundColor: "#2563eb",
color: "#fff",
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontFamily: mono,
fontSize: "12px",
fontWeight: 600
},
copyBtn: {
padding: "6px 16px",
backgroundColor: "transparent",
color: "#9ca3af",
border: "1px solid #374151",
borderRadius: "6px",
cursor: "pointer",
fontFamily: mono,
fontSize: "12px",
fontWeight: 600
},
messageBlock: {
margin: "0 0 12px",
padding: "12px",
backgroundColor: "#1f2937",
borderRadius: "6px",
color: "#f9fafb",
fontSize: "13px",
lineHeight: 1.6,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflow: "auto"
},
stackLabel: {
fontSize: "10px",
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "1.5px",
marginBottom: "6px",
fontWeight: 600
},
stackBlock: {
margin: "0 0 20px",
padding: "12px",
backgroundColor: "#0f172a",
borderRadius: "6px",
color: "#94a3b8",
fontSize: "12px",
lineHeight: 1.6,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
overflow: "auto"
},
causedBy: {
fontSize: "10px",
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "1.5px",
marginBottom: "8px",
paddingTop: "12px",
borderTop: "1px solid #374151",
fontWeight: 600
},
meta: {
display: "flex",
gap: "16px",
alignItems: "center",
paddingTop: "12px",
borderTop: "1px solid #1f2937",
flexWrap: "wrap"
},
metaItem: {
fontSize: "11px",
color: "#6b7280"
},
statusBadge: {
fontSize: "11px",
color: "#fbbf24",
backgroundColor: "#422006",
padding: "2px 8px",
borderRadius: "4px",
fontWeight: 600
}
};
const prod = {
page: {
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#0f172a",
fontFamily: "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif",
padding: "24px"
},
card: {
textAlign: "center",
maxWidth: "440px"
},
heading: {
fontSize: "28px",
fontWeight: 700,
color: "#f1f5f9",
marginBottom: "12px"
},
subtitle: {
fontSize: "16px",
color: "#94a3b8",
marginBottom: "24px",
lineHeight: 1.5
},
refText: {
fontSize: "13px",
color: "#64748b",
marginBottom: "24px",
fontFamily: mono
},
actions: {
display: "flex",
gap: "12px",
justifyContent: "center",
alignItems: "center"
},
reloadBtn: {
padding: "10px 24px",
backgroundColor: "#2563eb",
color: "#fff",
border: "none",
borderRadius: "8px",
cursor: "pointer",
fontSize: "14px",
fontWeight: 600
},
homeLink: {
padding: "10px 24px",
color: "#94a3b8",
textDecoration: "none",
fontSize: "14px"
}
};
//#endregion
//#region ../../src/react/router/contexts/RouterLayerContext.ts
const RouterLayerContext = createContext(void 0);
//#endregion
//#region ../../src/react/router/components/NestedView.tsx
/**
* A component that renders the current view of the nested router layer.
*
* To be simple, it renders the `element` of the current child page of a parent page.
*
* @example
* ```tsx
* import { NestedView } from "alepha/react";
*
* class App {
* parent = $page({
* component: () => <NestedView />,
* });
*
* child = $page({
* parent: this.root,
* component: () => <div>Child Page</div>,
* });
* }
* ```
*/
const NestedView = (props) => {
const routerLayer = use(RouterLayerContext);
const index = routerLayer?.index ?? 0;
const onError = routerLayer?.onError;
const state = useRouterState();
const alepha = useAlepha();
const boundaryResetKeys = [state.url.pathname];
const [view, setView] = useState(state.layers[index]?.element);
const [animation, setAnimation] = useState("");
const animationExitDuration = useRef(0);
const animationExitNow = useRef(0);
useEvents({
"react:transition:begin": async ({ previous, state }) => {
const layer = previous.layers[index];
if (!layer) return;
if (`${state.url.pathname}/`.startsWith(`${layer.path}/`)) return;
const animationExit = parseAnimation(layer.route?.animation, state, "exit");
if (animationExit) {
const duration = animationExit.duration || 200;
animationExitNow.current = Date.now();
animationExitDuration.current = duration;
setAnimation(animationExit.animation);
} else {
animationExitNow.current = 0;
animationExitDuration.current = 0;
setAnimation("");
}
},
"react:transition:end": async ({ state }) => {
const layer = state.layers[index];
if (animationExitNow.current) {
const duration = animationExitDuration.current;
const diff = Date.now() - animationExitNow.current;
if (diff < duration) await new Promise((resolve) => setTimeout(resolve, duration - diff));
}
if (!layer?.cache) {
setView(layer?.element);
const animationEnter = parseAnimation(layer?.route?.animation, state, "enter");
if (animationEnter) setAnimation(animationEnter.animation);
else setAnimation("");
}
}
}, []);
let element = view ?? props.children ?? null;
if (animation) element = /* @__PURE__ */ jsx("div", {
style: {
display: "flex",
flex: 1,
height: "100%",
width: "100%",
position: "relative",
overflow: "hidden"
},
children: /* @__PURE__ */ jsx("div", {
style: {
height: "100%",
width: "100%",
display: "flex",
animation
},
children: element
})
});
if (props.errorBoundary === false) return /* @__PURE__ */ jsx(Fragment, { children: element });
if (props.errorBoundary) return /* @__PURE__ */ jsx(ErrorBoundary, {
fallback: props.errorBoundary,
resetKeys: boundaryResetKeys,
children: element
});
const fallback = (error) => {
const result = onError?.(error, state) ?? /* @__PURE__ */ jsx(ErrorViewer, {
error,
alepha
});
if (result instanceof Redirection) return "Redirection inside ErrorBoundary is not allowed.";
return result;
};
return /* @__PURE__ */ jsx(ErrorBoundary, {
fallback,
resetKeys: boundaryResetKeys,
children: element
});
};
var NestedView_default = memo(NestedView);
function parseAnimation(animationLike, state, type = "enter") {
if (!animationLike) return;
const DEFAULT_DURATION = 300;
const animation = typeof animationLike === "function" ? animationLike(state) : animationLike;
if (typeof animation === "string") {
if (type === "exit") return;
return {
duration: DEFAULT_DURATION,
animation: `${DEFAULT_DURATION}ms ${animation}`
};
}
if (typeof animation === "object") {
const anim = animation[type];
const duration = typeof anim === "object" ? anim.duration ?? DEFAULT_DURATION : DEFAULT_DURATION;
const name = typeof anim === "object" ? anim.name : anim;
if (type === "exit") return {
duration,
animation: `${duration}ms ${typeof anim === "object" ? anim.timing ?? "" : ""} ${name}`
};
return {
duration,
animation: `${duration}ms ${typeof anim === "object" ? anim.timing ?? "" : ""} ${name}`
};
}
}
//#endregion
//#region ../../src/react/router/providers/RootComponentsProvider.ts
/**
* Extension point letting any module contribute root-level React nodes that
* render on every page (siblings of the page view, inside AlephaContext).
*
* A module pushes into `rootComponents` from its `register` hook; the array
* is rendered by `ReactPageProvider.root()`. SSR-safe (same element feeds
* server render + client hydrate).
*/
var RootComponentsProvider = class {
rootComponents = [];
};
//#endregion
//#region ../../src/react/router/providers/RouterLocaleProvider.ts
/**
* Generic locale path-prefix mechanism for the router.
*
* This provider knows nothing about i18n — it only deals with URL path
* segments. It is configured by the i18n module (`I18nProvider`) when
* `routing: "prefix"` is enabled, which keeps the dependency one-directional
* (`i18n → router`) and avoids a module cycle.
*
* The default locale is served WITHOUT a prefix (`/about` = default,
* `/fr/about` = French). The active locale is derived from the current
* request/navigation and stored under `alepha.react.router.locale`, so every
* URL the router builds (`pathname()`) automatically carries the right prefix.
*/
var RouterLocaleProvider = class {
alepha = $inject(Alepha);
/**
* Whether locale path-prefixing is active. Off by default — opt-in via the
* i18n module.
*/
enabled = false;
/**
* The default locale, served without a path prefix (e.g. `"en"` → `/about`).
*/
defaultLocale = "";
/**
* All known locales, including the default one.
*/
locales = [];
/**
* Configure the provider. Called by the i18n module before the SSR routes
* are registered.
*/
configure(options) {
if (options.enabled !== void 0) this.enabled = options.enabled;
if (options.defaultLocale !== void 0) this.defaultLocale = options.defaultLocale;
if (options.locales !== void 0) this.locales = options.locales;
}
/**
* Locales that carry a URL prefix — every known locale except the default.
*/
get prefixedLocales() {
return this.locales.filter((locale) => locale !== this.defaultLocale);
}
/**
* Splits a leading locale segment off a pathname.
*
* - `/fr/about` → `{ locale: "fr", pathname: "/about" }` when `fr` is a
* prefixed locale.
* - `/about` → `{ locale: defaultLocale, pathname: "/about" }`.
*
* When prefixing is disabled the pathname is returned untouched.
*/
detect(pathname) {
if (this.enabled) {
const first = pathname.split("/")[1];
if (first && this.prefixedLocales.includes(first)) {
const rest = pathname.slice(first.length + 1);
return {
locale: first,
pathname: this.normalize(rest)
};
}
}
return {
locale: this.defaultLocale,
pathname
};
}
/**
* Prepends the locale prefix to a pathname when needed. The default locale
* (and any unknown/disabled case) returns the pathname unchanged.
*/
withPrefix(pathname, locale = this.current) {
if (!this.enabled || !locale || locale === this.defaultLocale || !this.prefixedLocales.includes(locale)) return pathname;
return `/${locale}${pathname === "/" ? "" : pathname}`;
}
/**
* The active locale, derived from the current request/navigation. Falls back
* to the default locale when nothing has been detected.
*/
get current() {
return this.alepha.store.get("alepha.react.router.locale") || this.defaultLocale;
}
set current(locale) {
this.alepha.store.set("alepha.react.router.locale", locale);
}
/**
* Normalizes a stripped pathname so it always starts with a single slash and
* carries no trailing slash (except the root `/`).
*/
normalize(pathname) {
if (!pathname || pathname === "/") return "/";
const withLeading = pathname.startsWith("/") ? pathname : `/${pathname}`;
return withLeading.length > 1 && withLeading.endsWith("/") ? withLeading.slice(0, -1) : withLeading;
}
};
//#endregion
//#region ../../src/react/router/providers/ReactPageProvider.ts
const reactPageOptions = $atom({
name: "alepha.react.page.options",
description: "Configuration options for the React page provider.",
schema: z.object({
/**
* Enable React StrictMode wrapper.
*/
strictMode: z.boolean().default(true),
/**
* RegExp pattern (as string) to detect file-like URLs (e.g. /hello.txt, /wp-login.php).
* When a request hits the catch-all wildcard route and matches this pattern,
* SSR is skipped and a plain 404 response is returned instead.
*
* Set to empty string to disable this behavior.
*
* @default "\\.[a-zA-Z0-9]{1,10}$"
*/
staticFilePattern: z.string()
}),
default: {
strictMode: true,
staticFilePattern: "\\.[a-zA-Z0-9]{1,10}$"
}
});
/**
* Handle page routes for React applications. (Browser and Server)
*/
var ReactPageProvider = class {
dateTimeProvider = $inject(DateTimeProvider);
log = $logger();
options = $state(reactPageOptions);
alepha = $inject(Alepha);
rootComponentsProvider = $inject(RootComponentsProvider);
localeProvider = $inject(RouterLocaleProvider);
pages = [];
nextIdCursor = 0;
configure = $hook({
on: "configure",
handler: () => {
let hasNotFoundHandler = false;
const pages = this.alepha.primitives($page);
const hasParent = (it) => {
if (it.options.parent) return true;
for (const page of pages) if ((page.options.children ? Array.isArray(page.options.children) ? page.options.children : page.options.children() : []).includes(it)) return true;
};
for (const page of pages) {
if (page.options.path === "/*") hasNotFoundHandler = true;
if (hasParent(page)) continue;
this.add(this.map(pages, page));
}
if (!hasNotFoundHandler && pages.length > 0) this.add({
path: "/*",
name: "notFound",
component: NotFound,
onServerResponse: ({ reply }) => {
reply.status = 404;
}
});
}
});
getPages() {
return this.pages;
}
getConcretePages() {
const pages = [];
for (const page of this.pages) {
if (page.children && page.children.length > 0) continue;
const fullPath = this.pathname(page.name);
if (fullPath.includes(":") || fullPath.includes("*")) {
if (typeof page.static === "object") {
const entries = page.static.entries;
if (entries && entries.length > 0) for (const entry of entries) {
const params = entry.params;
const path = this.compile(page.path ?? "", params);
if (!path.includes(":") && !path.includes("*")) pages.push({
...page,
name: params[Object.keys(params)[0]],
staticName: page.name,
path,
...entry
});
}
}
continue;
}
pages.push(page);
}
return pages;
}
page(name) {
for (const page of this.pages) if (page.name === name) return page;
throw new AlephaError(`Page '${name}' not found`);
}
/**
* Find a route by name anywhere in the tree (including nested children).
* Returns undefined if no page with that name exists.
*/
findRoute(name, routes = this.pages) {
for (const route of routes) {
if (route.name === name) return route;
if (route.children?.length) {
const found = this.findRoute(name, route.children);
if (found) return found;
}
}
}
pathname(name, options = {}) {
const page = this.page(name);
if (!page) throw new AlephaError(`Page ${name} not found`);
let url = page.path ?? "";
let parent = page.parent;
while (parent) {
url = `${parent.path ?? ""}/${url}`;
parent = parent.parent;
}
url = this.compile(url, options.params ?? {});
url = url.replace(/\/\/+/g, "/") || "/";
url = this.localeProvider.withPrefix(url);
if (options.query) {
const query = new URLSearchParams(options.query);
if (query.toString()) url += `?${query.toString()}`;
}
return url;
}
url(name, options = {}) {
return new URL(this.pathname(name, options), options.host ?? `http://localhost`);
}
root(state) {
const root = createElement(AlephaContext.Provider, { value: this.alepha }, createElement(NestedView_default, {}, state.layers[0]?.element), ...this.rootComponentsProvider.rootComponents);
if (this.options.strictMode) return createElement(StrictMode, {}, root);
return root;
}
convertStringObjectToObject = (schema, value) => {
if (z.schema.isObject(schema) && typeof value === "object") for (const key in schema.properties) {
const propSchema = z.schema.unwrap(schema.properties[key]);
if (z.schema.isObject(propSchema) && typeof value[key] === "string") try {
value[key] = this.alepha.codec.decode(propSchema, decodeURIComponent(value[key]));
} catch (e) {}
}
return value;
};
/**
* Create a new RouterState based on a given route and request.
* This method resolves the layers for the route, applying any query and params schemas defined in the route.
* It also handles errors and redirects.
*/
async createLayers(route, state, previous = []) {
let context = {};
const stack = [{ route }];
let parent = route.parent;
while (parent) {
stack.unshift({ route: parent });
parent = parent.parent;
}
let forceRefresh = false;
for (let i = 0; i < stack.length; i++) {
const it = stack[i];
const route = it.route;
const config = {};
try {
this.convertStringObjectToObject(route.schema?.query, state.query);
config.query = route.schema?.query ? this.alepha.codec.decode(route.schema.query, coerceObject(route.schema.query, state.query)) : {};
} catch (e) {
it.error = e instanceof Error ? e : new Error(String(e));
break;
}
try {
config.params = route.schema?.params ? this.alepha.codec.decode(route.schema.params, coerceObject(route.schema.params, state.params)) : {};
} catch (e) {
it.error = e instanceof Error ? e : new Error(String(e));
break;
}
it.config = { ...config };
if (previous?.[i] && !forceRefresh && previous[i].name === route.name) {
const url = (str) => str ? str.replace(/\/\/+/g, "/") : "/";
if (JSON.stringify({
part: url(previous[i].part),
params: previous[i].config?.params ?? {}
}) === JSON.stringify({
part: url(route.path),
params: config.params ?? {}
})) {
it.props = previous[i].props;
it.error = previous[i].error;
it.cache = true;
context = {
...context,
...it.props
};
continue;
}
forceRefresh = true;
}
if (route.redirect) return { redirect: route.redirect };
const middleware = this.alepha.isBrowser() ? (route.use ?? []).filter((m) => m[OPTIONS]?.name !== "$cache") : [];
if (!route.loader && middleware.length === 0) continue;
try {
const args = Object.create(state);
Object.assign(args, config, context);
let reached = false;
const terminal = async (a) => {
reached = true;
return await route.loader?.(a) ?? {};
};
const props = middleware.length ? await new PipelineHandler(terminal, middleware).run(args) ?? {} : await terminal(args);
if (!reached) {
const user = this.alepha.store.get(currentUserAtom);
if (!user) {
const login = this.findRoute("login");
if (login?.match && !/[:*]/.test(login.match)) {
const back = encodeURIComponent(state.url.pathname + state.url.search);
return { redirect: `${login.match}?redirect=${back}` };
}
}
const denied = new AlephaError(user ? "You do not have permission to access this page." : "Authentication required.");
denied.status = user ? 403 : 401;
throw denied;
}
it.props = { ...props };
context = {
...context,
...props
};
} catch (e) {
if (e instanceof Redirection) return { redirect: e.redirect };
this.log.error("Page loader has failed", e);
it.error = e instanceof Error ? e : new Error(String(e));
break;
}
}
let acc = "";
for (let i = 0; i < stack.length; i++) {
const it = stack[i];
const props = it.props ?? {};
const params = { ...it.config?.params };
for (const key of Object.keys(params)) params[key] = String(params[key]);
acc += "/";
acc += it.route.path ? this.compile(it.route.path, params) : "";
const path = acc.replace(/\/+/, "/");
const localErrorHandler = this.getErrorHandler(it.route);
if (localErrorHandler) {
const onErrorParent = state.onError;
state.onError = (error, context) => {
const result = localErrorHandler(error, context);
if (result === void 0) return onErrorParent(error, context);
return result;
};
}
if (!it.error) try {
const element = await this.createElement(it.route, {
...it.route.props ? it.route.props() : {},
...props,
...context
}, state.url);
state.layers.push({
name: it.route.name,
props,
part: it.route.path,
config: it.config,
element: this.renderView(i + 1, path, element, it.route),
index: i + 1,
path,
route: it.route,
cache: it.cache
});
} catch (e) {
it.error = e instanceof Error ? e : new Error(String(e));
}
if (it.error) try {
let element = await state.onError(it.error, state);
if (element === void 0) throw it.error;
if (element instanceof Redirection) return { redirect: element.redirect };
if (element === null) element = this.renderError(it.error);
state.layers.push({
props,
error: it.error,
name: it.route.name,
part: it.route.path,
config: it.config,
element: this.renderView(i + 1, path, element, it.route),
index: i + 1,
path,
route: it.route,
cache: it.cache
});
break;
} catch (e) {
if (e instanceof Redirection) return { redirect: e.redirect };
throw e;
}
}
if (state.layers.length > 0 && !this.isSSR(route)) {
const rootLayer = state.layers[0];
rootLayer.element = createElement(ClientOnly, {}, rootLayer.element);
}
return { state };
}
getErrorHandler(route) {
if (route.errorHandler) return route.errorHandler;
let parent = route.parent;
while (parent) {
if (parent.errorHandler) return parent.errorHandler;
parent = parent.parent;
}
}
async createElement(page, props, targetUrl) {
if (page.lazy && page.component) this.log.warn(`Page ${page.name} has both lazy and component options, lazy will be used`);
if (page.lazy) try {
return createElement((await page.lazy()).default, props);
} catch (error) {
if (this.alepha.isBrowser() && this.isChunkLoadError(error)) {
if (this.reloadAfterChunkError(targetUrl)) return;
}
throw error;
}
if (page.component) return createElement(page.component, props);
}
/**
* Detect chunk load errors caused by stale dynamic imports after a deployment.
* When new assets are deployed with different hashes, old chunk URLs return 404.
*/
isChunkLoadError(error) {
if (!(error instanceof Error)) return false;
const msg = error.message;
return /Failed to fetch dynamically imported module/.test(msg) || /error loading dynamically imported module/i.test(msg) || /Unable to preload CSS/.test(msg) || /Importing a module script failed/.test(msg);
}
/**
* Navigate to the target URL to fetch updated assets after a chunk load failure.
* Uses sessionStorage to prevent infinite reload loops.
* Returns true if navigation was initiated.
*/
reloadAfterChunkError(url) {
const key = "alepha:chunk-reload";
const lastReload = sessionStorage.getItem(key);
const now = this.dateTimeProvider.nowMillis();
if (lastReload && now - Number(lastReload) < 1e4) {
this.log.error("Chunk load failed after recent reload, not retrying to avoid loop");
return false;
}
this.log.warn("Chunk load failed after deployment, reloading page");
sessionStorage.setItem(key, String(now));
window.location.assign(url ? url.pathname + url.search : window.location.href);
return true;
}
renderError(error) {
return createElement(ErrorViewer, {
error,
alepha: this.alepha
});
}
renderEmptyView() {
return createElement(NestedView_default, {});
}
href(page, params = {}) {
const found = this.pages.find((it) => it.name === page.options.name);
if (!found) throw new AlephaError(`Page ${page.options.name} not found`);
let url = found.path ?? "";
let parent = found.parent;
while (parent) {
url = `${parent.path ?? ""}/${url}`;
parent = parent.parent;
}
url = this.compile(url, params);
return url.replace(/\/\/+/g, "/") || "/";
}
compile(path, params = {}) {
for (const [key, value] of Object.entries(params)) path = path.replace(`:${key}`, value);
return path;
}
renderView(index, path, view, page) {
view ??= this.renderEmptyView();
return createElement(RouterLayerContext.Provider, { value: {
index,
path,
onError: this.getErrorHandler(page) ?? ((error) => this.renderError(error))
} }, view);
}
/**
* Resolve the effective `ssr` value for a route by walking up the parent
* chain. Returns the nearest explicit `ssr` value, defaulting to `true`.
*
* The decision is made at the leaf: a parent's `ssr` only acts as a default
* for descendants that did not set their own value.
*/
isSSR(route) {
let current = route;
while (current) {
if (typeof current.ssr === "boolean") return current.ssr;
current = current.parent;
}
return true;
}
map(pages, target) {
const children = target.options.children ? Array.isArray(target.options.children) ? target.options.children : target.options.children() : [];
const getChildrenFromParent = (it) => {
const children = [];
for (const page of pages) if (page.options.parent === it) children.push(page);
return children;
};
children.push(...getChildrenFromParent(target));
return {
...target.options,
name: target.name,
parent: void 0,
children: children.map((it) => this.map(pages, it))
};
}
add(entry) {
if (this.alepha.isReady()) throw new AlephaError("Router is already initialized");
entry.name ??= this.nextId();
const page = entry;
page.match = this.createMatch(page);
this.pages.push(page);
if (page.children) for (const child of page.children) {
child.parent = page;
this.add(child);
}
}
createMatch(page) {
let url = page.path ?? "/";
let target = page.parent;
while (target) {
url = `${target.path ?? ""}/${url}`;
target = target.parent;
}
let path = url.replace(/\/\/+/g, "/");
if (path.endsWith("/") && path !== "/") path = path.slice(0, -1);
return path;
}
nextId() {
this.nextIdCursor += 1;
return `P${this.nextIdCursor}`;
}
};
const isPageRoute = (it) => {
return it && typeof it === "object" && typeof it.path === "string" && typeof it.page === "object";
};
//#endregion
//#region ../../src/react/router/providers/ReactBrowserRouterProvider.ts
/**
* Implementation of AlephaRouter for React in browser environment.
*/
var ReactBrowserRouterProvider = class extends RouterProvider {
log = $logger();
alepha = $inject(Alepha);
pageApi = $inject(ReactPageProvider);
browserHeadProvider = $inject(BrowserHeadProvider);
localeProvider = $inject(RouterLocaleProvider);
add(entry) {
this.pageApi.add(entry);
}
configure = $hook({
on: "configure",
handler: async () => {
for (const page of this.pageApi.getPages()) if (page.component || page.lazy || page.redirect) this.push({
path: page.match,
page
});
}
});
async transition(url, previous = [], meta = {}, isStale = () => false) {
const { pathname, search } = url;
const state = {
url,
query: {},
params: {},
layers: [],
onError: () => null,
meta
};
await this.alepha.events.emit("react:action:begin", { type: "transition" });
await this.alepha.events.emit("react:transition:begin", {
previous: this.alepha.store.get("alepha.react.router.state"),
state
});
try {
let matchPathname = pathname;
if (this.localeProvider.enabled) {
const detected = this.localeProvider.detect(pathname);
this.localeProvider.current = detected.locale;
matchPathname = detected.pathname;
}
const { route, params } = this.match(matchPathname);
const query = {};
if (search) for (const [key, value] of new URLSearchParams(search).entries()) query[key] = String(value);
state.name = route?.page.name;
state.query = query;
state.params = params ?? {};
if (isPageRoute(route)) {
const { redirect } = await this.pageApi.createLayers(route.page, state, previous);
if (isStale()) return;
if (redirect) return redirect;
}
if (state.layers.length === 0) state.layers.push({
name: "not-found",
element: createElement(NotFound),
index: 0,
path: "/"
});
await this.alepha.events.emit("react:action:success", { type: "transition" });
await this.alepha.events.emit("react:transition:success", { state });
} catch (e) {
if (isStale()) return;
this.log.error("Transition has failed", e);
let element;
try {
const result = state.onError(e, state);
if (result != null && !(result instanceof Redirection)) element = result;
} catch {}
state.layers = [{
name: "error",
element: element ?? this.pageApi.renderError(e),
index: 0,
path: "/"
}];
await this.alepha.events.emit("react:action:error", {
type: "transition",
error: e
});
await this.alepha.events.emit("react:transition:error", {
error: e,
state
});
}
if (isStale()) return;
if (previous) for (let i = 0; i < previous.length; i++) {
const layer = previous[i];
if (state.layers[i]?.name !== layer.name && layer.name !== "error") this.pageApi.page(layer.name)?.onLeave?.();
}
for (let i = 0; i < state.layers.length; i++) {
const layer = state.layers[i];
if (previous?.[i]?.name !== layer.name && layer.name !== "error") this.pageApi.page(layer.name)?.onEnter?.();
}
this.alepha.store.set("alepha.react.router.state", state);
await this.alepha.events.emit("react:action:end", { type: "transition" });
await this.alepha.events.emit("react:transition:end", { state });
this.browserHeadProvider.fillAndRenderHead(state);
}
root(state) {
return this.pageApi.root(state);
}
};
//#endregion
//#region ../../src/react/router/providers/ReactBrowserProvider.ts
/**
* React browser renderer configuration atom
*/
const reactBrowserOptions = $atom({
name: "alepha.react.browser.options",
schema: z.object({
scrollRestoration: z.enum(["top", "manual"]),
/**
* Intercept clicks on plain `<a href="/...">` anchors and route them
* through the SPA router, so authors don't need `<Link>` everywhere
* (notably for SSR/Markdown HTML rendered as raw markup).
*
* Skips: modifier keys, non-primary mouse buttons, `target` other than
* `_self`, `download`, `data-no-router`, non-http(s) schemes, hash-only
* hrefs, external origins, and clicks already `defaultPrevented`.
*/
interceptAnchorClicks: z.boolean().default(true)
}),
default: {
scrollRestoration: "top",
interceptAnchorClicks: true
}
});
var ReactBrowserProvider = class {
log = $logger();
client = $inject(LinkProvider);
alepha = $inject(Alepha);
router = $inject(ReactBrowserRouterProvider);
dateTimeProvider = $inject(DateTimeProvider);
browserHeadProvider = $inject(BrowserHeadProvider);
validator = $inject(SchemaValidator);
options = $state(reactBrowserOptions);
get rootId() {
return "root";
}
getRootElement() {
const root = this.document.getElementById(this.rootId);
if (root) return root;
const div = this.document.createElement("div");
div.id = this.rootId;
this.document.body.prepend(div);
return div;
}
transitioning;
/**
* Monotonic counter used to detect stale (superseded) transitions.
*
* Each call to `render()` captures `++this.transitionId` and any
* subsequent `render()` invalidates older in-flight transitions.
* This prevents a slow page from racing past a newer navigation
* (e.g. user clicks /pageA which has a 2s loader, then clicks /pageB
* — pageB must remain the committed page).
*/
transitionId = 0;
get state() {
return this.alepha.store.get("alepha.react.router.state");
}
/**
* Accessor for Document DOM API.
*/
get document() {
return window.document;
}
/**
* Accessor for History DOM API.
*/
get history() {
return window.history;
}
/**
* Accessor for Location DOM API.
*/
get location() {
return window.location;
}
get base() {
const base = import.meta.env?.BASE_URL;
if (!base || base === "/") return "";
return base;
}
get url() {
const url = this.location.pathname + this.location.search + this.location.hash;
if (this.base) return url.replace(this.base, "");
return url;
}
pushState(path, replace) {
const url = this.base + path;
if (replace) this.history.replaceState({}, "", url);
else this.history.pushState({}, "", url);
}
async invalidate(props) {
const previous = [];
this.log.trace("Invalidating layers");
if (props) {
const [key] = Object.keys(props);
const value = props[key];
for (const layer of this.state.layers) {
if (layer.props?.[key]) {
previous.push({
...layer,
props: {
...layer.props,
[key]: value
}
});
break;
}
previous.push(layer);
}
}
await this.render({ previous });
}
async push(url, options = {}) {
this.log.trace(`Going to ${url}`, {
url,
options
});
const myTransitionId = ++this.transitionId;
await this.render({
url,
previous: options.force ? [] : this.state.layers,
meta: options.meta,
transitionId: myTransitionId
});
if (myTransitionId !== this.transitionId) return;
const committed = this.state.url.pathname + this.state.url.search + this.state.url.hash;
if (committed !== url) {
this.pushState(committed);
return;
}
this.pushState(url, options.replace);
}
async render(options = {}) {
const myTransitionId = options.transitionId ?? ++this.transitionId;
const previous = options.previous ?? this.state.layers;
const url = options.url ?? this.url;
const start = this.dateTimeProvider.now();
this.transitioning = {
to: url,
from: this.state?.url.pathname
};
this.log.debug("Transitioning...", { to: url });
const isStale = () => this.transitionId !== myTransitionId;
const redirect = await this.router.transition(new URL(`http://localhost${url}`), previous, options.meta, isStale);
if (isStale()) {
this.log.debug("Transition superseded — discarding stale result", { to: url });
return;
}
if (redirect) {
this.log.info("Redirecting to", { redirect });
if (redirect.startsWith("http")) window.location.href = redirect;
else return await this.render({
url: redirect,
transitionId: myTransitionId
});
}
const ms = this.dateTimeProvider.now().diff(start);
this.log.info(`Transition OK [${ms}ms]`, this.transitioning);
this.transitioning = void 0;
}
/**
* Get embedded layers from the server.
*/
getHydrationState() {
try {
const el = this.document.getElementById("__ssr");
if (el?.textContent) return JSON.parse(el.textContent);
} catch (error) {
console.error(error);
}
}
/**
* Apply the SSR hydration payload (the `#__ssr` script tag) to the atom
* store.
*
* Every key except `alepha.react.router.layers` is treated as an atom
* value. A registered atom's value is explicitly schema-validated: an
* invalid value is dropped (warn + keep the atom's default) instead of
* being trusted, so a tampered payload can't smuggle bad data into a
* validated atom. Atoms not registered yet fall through to
* `Alepha.set()`, which lets `StateManager.register()` decode them
* against the schema the moment they first get used.
*
* `alepha.react.router.layers` is deliberately skipped by this loop: it
* carries render instructions (`part`, `name`, `config`, `props`,
* `error`), not atom values. Those are NOT hardened here and are trusted
* as-is from the SSR payload — a tampered payload can still influence
* rendering through this key. Validating router layers is separate,
* future work; this method only guarantees atom values.
*/
applyHydration(hydration) {
for (const [key, value] of Object.entries(hydration)) {
if (key === "alepha.react.router.layers") continue;
const atom = this.alepha.store.getAtom(key);
if (atom) {
const result = this.validator.safeValidate(atom.schema, value);
if (!result.success) {
this.log.warn(`Hydrated value for atom "${key}" failed schema validation, keeping default`);
continue;
}
this.alepha.store.set(key, result.data, { skipValidation: true });
} else this.alepha.set(key, value);
}
}
onTransitionEnd = $hook({
on: "react:transition:end",
handler: () => {
if (this.options.scrollRestoration === "top" && typeof window !== "undefined" && !this.alepha.isTest()) {
this.log.trace("Restoring scroll position to top");
window.scrollTo(0, 0);
}
}
});
ready = $hook({
on: "ready",
handler: async () => {
const hydration = this.getHydrationState();
const previous = hydration?.["alepha.react.router.layers"] ?? [];
if (hydration) this.applyHydration(hydration);
await this.render({ previous });
const element = this.router.root(this.state);
await this.alepha.events.emit("react:browser:render", {
element,
root: this.getRootElement(),
hydration,
state: this.state
});
window.addEventListener("popstate", () => {
if (this.base + this.state.url.pathname === this.location.pathname && (this.state.url.search ?? "") === (this.location.search ?? "")) return;
this.log.debug("Popstate event triggered - rendering new state", { url: this.location.pathname + this.location.search });
this.render();
});
this.attachAnchorInterceptor();
}
});
/**
* Attach a delegated click listener that routes plain `<a href="/...">`
* clicks through the SPA router. Returns a detach function (used in tests).
*
* Bails out on modifier keys, non-primary mouse buttons, `target`, `download`,
* `data-no-router`, hash-only/external/non-http hrefs, and already-prevented
* events. Honors the runtime `interceptAnchorClicks` flag.
*/
attachAnchorInterceptor() {
const onClick = (ev) => {
if (!this.options.interceptAnchorClicks) return;
if (ev.defaultPrevented) return;
if (ev.button !== 0) return;
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
const a = ev.target?.closest?.("a");
if (!a) return;
if (a.hasAttribute("download")) return;
if (a.hasAttribute("data-no-router")) return;
const target = a.getAttribute("target");
if (target && target !== "_self") return;
const href = a.getAttribute("href");
if (!href) return;
if (href.sta