UNPKG

alepha

Version:

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

1,654 lines 97.4 kB
import { $atom, $env, $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, ServerHeadProvider } from "alepha/react/head"; import { AlephaServer, ServerProvider, ServerRouterProvider } from "alepha/server"; import { AlephaServerEtag } from "alepha/server/etag"; import { AlephaServerLinks, LinkProvider, ServerLinksProvider } from "alepha/server/links"; import { $cache } from "alepha/cache"; import { $logger } from "alepha/logger"; import { currentUserAtom } from "alepha/security"; import { StrictMode, createContext, createElement, memo, use, useEffect, useRef, useState } from "react"; import { Fragment, jsx, jsxs } from "react/jsx-runtime"; import { join } from "node:path"; import { ServerStaticProvider } from "alepha/server/static"; import { FileSystemProvider } from "alepha/system"; import { renderToReadableStream, renderToString } from "react-dom/server"; import { RouterProvider } from "alepha/router"; //#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/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/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/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/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/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"; }; /** * SSR Manifest atom containing all manifest data for SSR module preloading. * * This atom is populated at build time by embedding manifest data into the * generated index.js. This approach is optimal for serverless deployments * as it eliminates filesystem reads at runtime. * * The manifest includes: * - preload: Maps short hash keys to source paths (from viteAlephaSsrPreload) * - client: Maps source files to their output info (file, imports, css) */ const ssrManifestAtom = $atom({ name: "alepha.react.ssr.manifest", description: "SSR manifest for module preloading", schema: z.object({ /** * Base path for assets (from Vite's base config). * Used to prefix asset URLs when serving from a subpath. * @example "/devtools" or "/" */ base: z.string().optional(), /** * Preload manifest mapping short keys to source paths. * Generated by viteAlephaSsrPreload plugin at build time. */ preload: z.record(z.string(), z.string()).optional(), /** * Client manifest mapping source files to their output information. * Only includes fields actually used for preloading. */ client: z.record(z.string(), z.object({ file: z.string(), isEntry: z.boolean().optional(), imports: z.array(z.string()).optional(), css: z.array(z.string()).optional() })).optional(), /** * Dev mode head content. * Contains pre-transformed scripts injected by Vite and plugins (React, etc.). * Only set in dev mode via ViteDevServerProvider. */ devHead: z.string().optional(), /** * Auto-detected favicon path and MIME type. * Format: "type:path" (e.g., "image/svg+xml:/favicon.svg"). * Set at build/dev time by scanning the public directory. */ favicon: z.string().optional() }), default: {} }); //#endregion //#region ../../src/react/router/providers/SSRManifestProvider.ts /** * Provider for SSR manifest data used for module preloading. * * The manifest is populated at build time by embedding data into the * generated index.js via the ssrManifestAtom. This eliminates filesystem * reads at runtime, making it optimal for serverless deployments. * * Manifest files are generated during `vite build`: * - manifest.json (client manifest) * - preload-manifest.json (from viteAlephaSsrPreload plugin) */ var SSRManifestProvider = class { alepha = $inject(Alepha); /** * Get the manifest from the store at runtime. * This ensures the manifest is available even when set after module load. */ get manifest() { return this.alepha.store.get(ssrManifestAtom) ?? {}; } /** * Get the full manifest object. */ getManifest() { return this.manifest; } /** * Get the base path for assets (from Vite's base config). * Returns empty string if base is "/" (default), otherwise returns the base path. */ get base() { return this.manifest.base ?? ""; } /** * Get the preload manifest. */ get preloadManifest() { return this.manifest.preload; } /** * Get the client manifest. */ get clientManifest() { return this.manifest.client; } /** * Resolve a preload key to its source path. * * The key is a short hash injected by viteAlephaSsrPreload plugin, * which maps to the full source path in the preload manifest. * * @param key - Short hash key (e.g., "a1b2c3d4") * @returns Source path (e.g., "src/pages/UserDetail.tsx") or undefined */ resolvePreloadKey(key) { return this.preloadManifest?.[key]; } /** * Get all chunks required for a source file, including transitive dependencies. * * Uses the client manifest to recursively resolve all imported chunks. * * @param sourcePath - Source file path (e.g., "src/pages/Home.tsx") * @returns Array of chunk URLs to preload, or empty array if not found */ getChunks(sourcePath) { if (!this.clientManifest) return []; if (!this.findManifestEntry(sourcePath)) return []; const chunks = /* @__PURE__ */ new Set(); const visited = /* @__PURE__ */ new Set(); this.collectChunksRecursive(sourcePath, chunks, visited); return Array.from(chunks); } /** * Find manifest entry for a source path, trying different extensions. */ findManifestEntry(sourcePath) { if (!this.clientManifest) return void 0; if (this.clientManifest[sourcePath]) return this.clientManifest[sourcePath]; const basePath = sourcePath.replace(/\.[^.]+$/, ""); for (const ext of [ ".tsx", ".ts", ".jsx", ".js" ]) { const pathWithExt = basePath + ext; if (this.clientManifest[pathWithExt]) return this.clientManifest[pathWithExt]; } } /** * Recursively collect all chunk URLs for a manifest entry. */ collectChunksRecursive(key, chunks, visited) { if (visited.has(key)) return; visited.add(key); if (!this.clientManifest) return; const entry = this.clientManifest[key]; if (!entry) return; const base = this.base; if (entry.file) chunks.add(`${base}/${entry.file}`); if (entry.css) for (const css of entry.css) chunks.add(`${base}/${css}`); if (entry.imports) for (const imp of entry.imports) { if (imp === "index.html" || imp.endsWith(".html")) continue; this.collectChunksRecursive(imp, chunks, visited); } } /** * Collect modulepreload links for a route and its parent chain. */ collectPreloadLinks(route) { if (!this.isAvailable()) return []; const preloadPaths = []; let current = route; while (current) { const preloadKey = current[PAGE_PRELOAD_KEY]; if (preloadKey) { const sourcePath = this.resolvePreloadKey(preloadKey); if (sourcePath) preloadPaths.push(sourcePath); } current = current.parent; } if (preloadPaths.length === 0) return []; return this.getChunksForMultiple(preloadPaths).map((href) => { if (href.endsWith(".css")) return { rel: "preload", href, as: "style", crossorigin: "" }; return { rel: "modulepreload", href }; }); } /** * Get all chunks for multiple source files. * * @param sourcePaths - Array of source file paths * @returns Deduplicated array of chunk URLs */ getChunksForMultiple(sourcePaths) { const allChunks = /* @__PURE__ */ new Set(); for (const path of sourcePaths) { const chunks = this.getChunks(path); for (const chunk of chunks) allChunks.add(chunk); } return Array.from(allChunks); } /** * Check if manifest is loaded and available. */ isAvailable() { return this.clientManifest !== void 0; } /** * Cached entry assets - computed once at first access. */ cachedEntryAssets = null; /** * Get the entry point assets (main entry.js and associated CSS files). * * These assets are always required for all pages and can be preloaded * before page-specific loaders run. * * @returns Entry assets with js and css paths, or null if manifest unavailable */ getEntryAssets() { if (this.cachedEntryAssets) return this.cachedEntryAssets; if (!this.clientManifest) return null; const base = this.base; for (const [key, entry] of Object.entries(this.clientManifest)) if (entry.isEntry) { this.cachedEntryAssets = { js: `${base}/${entry.file}`, css: entry.css?.map((css) => `${base}/${css}`) ?? [] }; return this.cachedEntryAssets; } return null; } /** * Build preload link tags for entry assets. * * @returns Array of link objects ready to be rendered */ getEntryPreloadLinks() { const assets = this.getEntryAssets(); if (!assets) return []; const links = []; for (const css of assets.css) links.push({ rel: "stylesheet", href: css, crossorigin: "" }); if (assets.js) links.push({ rel: "modulepreload", href: assets.js }); return links; } }; //#endregion //#region ../../src/react/router/providers/ReactPreloadProvider.ts /** * Adds HTTP Link headers for preloading entry assets. * * Benefits: * - Early Hints (103): Servers can send preload hints before the full response * - CDN optimization: Many CDNs use Link headers to optimize asset delivery * - Browser prefetching: Browsers can start fetching resources earlier * * The Link header is computed once at first request and cached for reuse. */ var ReactPreloadProvider = class { alepha = $inject(Alepha); ssrManifest = $inject(SSRManifestProvider); /** * Cached Link header value - computed once, reused for all requests. */ cachedLinkHeader; /** * Build the Link header string from entry assets. * * Format: <url>; rel=preload; as=type, <url>; rel=modulepreload * * @returns Link header string or null if no assets */ buildLinkHeader() { const assets = this.ssrManifest.getEntryAssets(); if (!assets) return null; const links = []; for (const css of assets.css) links.push(`<${css}>; rel=preload; as=style`); if (assets.js) links.push(`<${assets.js}>; rel=modulepreload`); return links.length > 0 ? links.join(", ") : null; } /** * Get the cached Link header, computing it on first access. */ getLinkHeader() { if (this.cachedLinkHeader === void 0) this.cachedLinkHeader = this.buildLinkHeader(); return this.cachedLinkHeader; } /** * Add Link header to HTML responses for asset preloading. */ onResponse = $hook({ on: "server:onResponse", priority: "first", handler: ({ response }) => { if (!response.headers["content-type"]?.includes("text/html")) return; const linkHeader = this.getLinkHeader(); if (!linkHeader) return; if (response.headers.link) response.headers.link = `${response.headers.link}, ${linkHeader}`; else response.headers.link = linkHeader; } }); }; //#endregion //#region ../../src/react/router/providers/ReactServerTemplateProvider.ts /** * Handles HTML streaming for SSR. * * Uses hardcoded HTML structure - all customization via $head primitive. * Pre-encodes static parts as Uint8Array for zero-copy streaming. */ var ReactServerTemplateProvider = class { log = $logger(); alepha = $inject(Alepha); /** * Shared TextEncoder - reused across all requests. */ encoder = new TextEncoder(); /** * Pre-encoded static HTML parts for zero-copy streaming. */ SLOTS = { DOCTYPE: this.encoder.encode("<!DOCTYPE html>\n"), HTML_OPEN: this.encoder.encode("<html"), HTML_CLOSE: this.encoder.encode(">\n"), HEAD_OPEN: this.encoder.encode("<head>"), HEAD_CLOSE: this.encoder.encode("</head>\n"), BODY_OPEN: this.encoder.encode("<body"), BODY_CLOSE: this.encoder.encode(">\n"), ROOT_OPEN: this.encoder.encode("<div id=\"root\">"), ROOT_CLOSE: this.encoder.encode("</div>\n"), BODY_HTML_CLOSE: this.encoder.encode("</body>\n</html>"), HYDRATION_PREFIX: this.encoder.encode("<script id=\"__ssr\" type=\"application/json\">"), HYDRATION_SUFFIX: this.encoder.encode("<\/script>") }; /** * Early head content (charset, viewport, entry assets). * Set once during configuration, reused for all requests. */ earlyHeadContent = ""; /** * Root element ID for React mounting. */ rootId = "root"; /** * Regex for extracting root div content from HTML. */ rootDivRegex = new RegExp(`<div[^>]*\\s+id=["']${this.rootId}["'][^>]*>([\\s\\S]*?)<\\/div>`, "i"); /** * Extract content inside the root div from HTML. */ extractRootContent(html) { return html.match(this.rootDivRegex)?.[1]; } /** * Set early head content (charset, viewport, entry assets). * Called once during server configuration. */ setEarlyHeadContent(entryAssets, globalHead) { const charset = globalHead?.charset ?? "UTF-8"; const viewport = globalHead?.viewport ?? "width=device-width, initial-scale=1"; this.earlyHeadContent = `<meta charset="${this.escapeHtml(charset)}">\n<meta name="viewport" content="${this.escapeHtml(viewport)}">\n` + entryAssets; } /** * Render attributes record to HTML string. */ renderAttributes(attrs) { if (!attrs) return ""; const entries = Object.entries(attrs); if (entries.lengt