UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

1,637 lines 60.8 kB
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