UNPKG

alepha

Version:

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

936 lines (814 loc) 26.5 kB
import { $atom, $hook, $inject, $state, Alepha, AlephaError, coerceObject, OPTIONS, PipelineHandler, type TSchema, z, } from "alepha"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { AlephaContext, ClientOnly } from "alepha/react"; import type { Head } from "alepha/react/head"; import { currentUserAtom } from "alepha/security"; import { createElement, type ReactNode, StrictMode } from "react"; import ErrorViewer from "../components/ErrorViewer.tsx"; import NestedView from "../components/NestedView.tsx"; import NotFoundPage from "../components/NotFound.tsx"; import { RouterLayerContext } from "../contexts/RouterLayerContext.ts"; import { Redirection } from "../errors/Redirection.ts"; import { $page, type ErrorHandler, type PagePrimitive, type PagePrimitiveOptions, } from "../primitives/$page.ts"; import { RootComponentsProvider } from "./RootComponentsProvider.ts"; import { RouterLocaleProvider } from "./RouterLocaleProvider.ts"; // ------------------------------------------------------------------------------------------------------------------- export 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) */ export class ReactPageProvider { protected readonly dateTimeProvider = $inject(DateTimeProvider); protected readonly log = $logger(); protected readonly options = $state(reactPageOptions); protected readonly alepha = $inject(Alepha); protected readonly rootComponentsProvider = $inject(RootComponentsProvider); protected readonly localeProvider = $inject(RouterLocaleProvider); protected readonly pages: PageRoute[] = []; protected nextIdCursor = 0; protected readonly configure = $hook({ on: "configure", handler: () => { let hasNotFoundHandler = false; const pages = this.alepha.primitives($page); const hasParent = (it: PagePrimitive) => { if (it.options.parent) { return true; } for (const page of pages) { const children = page.options.children ? Array.isArray(page.options.children) ? page.options.children : page.options.children() : []; if (children.includes(it)) { return true; } } }; for (const page of pages) { if (page.options.path === "/*") { hasNotFoundHandler = true; } // skip children, we only want root pages if (hasParent(page)) { continue; } this.add(this.map(pages, page)); } if (!hasNotFoundHandler && pages.length > 0) { // add a default 404 page if not already defined this.add({ path: "/*", name: "notFound", component: NotFoundPage, onServerResponse: ({ reply }) => { reply.status = 404; }, }); } }, }); // ------------------------------------------------------------------------------------------------------------------- public getPages(): PageRoute[] { return this.pages; } public getConcretePages(): ConcretePageRoute[] { const pages: ConcretePageRoute[] = []; for (const page of this.pages) { if (page.children && page.children.length > 0) { continue; } // check if the page has dynamic params 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 as Record<string, string>; 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; } public page(name: string): PageRoute { 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. */ protected findRoute( name: string, routes: PageRouteEntry[] = this.pages, ): PageRoute | undefined { for (const route of routes as PageRoute[]) { if (route.name === name) { return route; } if (route.children?.length) { const found = this.findRoute(name, route.children); if (found) { return found; } } } return undefined; } public pathname( name: string, options: { params?: Record<string, string>; query?: Record<string, string>; } = {}, ) { 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, "/") || "/"; // Apply the active locale prefix (e.g. `/about` → `/fr/about`) to the path // portion only, before any query string is appended. A no-op unless // `routing: "prefix"` is enabled on the i18n module. url = this.localeProvider.withPrefix(url); if (options.query) { const query = new URLSearchParams(options.query); if (query.toString()) { url += `?${query.toString()}`; } } return url; } public url( name: string, options: { params?: Record<string, string>; host?: string } = {}, ): URL { return new URL( this.pathname(name, options), // use provided base or default to http://localhost options.host ?? `http://localhost`, ); } public root(state: ReactRouterState): ReactNode { const root = createElement( AlephaContext.Provider, { value: this.alepha }, createElement(NestedView, {}, state.layers[0]?.element), ...this.rootComponentsProvider.rootComponents, ); if (this.options.strictMode) { return createElement(StrictMode, {}, root); } return root; } protected convertStringObjectToObject = ( schema?: TSchema, value?: any, ): any => { if (z.schema.isObject(schema) && typeof value === "object") { for (const key in schema.properties) { // Peel optional/nullable/default wrappers so a field declared as // `z.object(...).optional()` is still recognised as an object whose // JSON-encoded query value needs parsing. 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) { // ignore } } } } 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. */ public async createLayers( route: PageRoute, state: ReactRouterState, previous: PreviousLayerData[] = [], ): Promise<CreateLayersResult> { let context: Record<string, any> = {}; // all props const stack: Array<RouterStackItem> = [{ route }]; // stack of routes 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: Record<string, any> = {}; try { this.convertStringObjectToObject(route.schema?.query, state.query); config.query = route.schema?.query ? this.alepha.codec.decode( route.schema.query, // Query params arrive as strings from the URL; coerce declared // scalar fields (number/integer/boolean) to their schema type // before strict validation, mirroring the HTTP server boundary. 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, // URL path params arrive as strings; coerce declared scalar // fields (number/integer/boolean) to their schema type before // strict validation, mirroring `query` above and the HTTP server. coerceObject(route.schema.params, state.params), ) : {}; } catch (e) { it.error = e instanceof Error ? e : new Error(String(e)); break; } // save config it.config = { ...config, }; // check if previous layer is the same, reuse if possible if (previous?.[i] && !forceRefresh && previous[i].name === route.name) { const url = (str?: string) => (str ? str.replace(/\/\/+/g, "/") : "/"); const prev = JSON.stringify({ part: url(previous[i].part), params: previous[i].config?.params ?? {}, }); const curr = JSON.stringify({ part: url(route.path), params: config.params ?? {}, }); if (prev === curr) { // part is the same, reuse previous layer it.props = previous[i].props; it.error = previous[i].error; it.cache = true; context = { ...context, ...it.props, }; continue; } // part is different, force refresh of next layers forceRefresh = true; } // redirect shorthand if (route.redirect) { return { redirect: route.redirect }; } // Run this layer's `use` middleware (e.g. $secure) around its loader. // Without this, client-side navigation would skip page guards entirely // and render protected pages for anyone. // // Browser-only: on the server, ReactServerProvider already wraps the // page handler with the collected middleware chain — running it here // too would double-execute it. `$cache` is excluded: like on the // server it is handled separately, not as a loader-wrapping middleware. const middleware = this.alepha.isBrowser() ? (route.use ?? []).filter((m) => m[OPTIONS]?.name !== "$cache") : []; // Nothing to run for this layer — render a basic view by default. if (!route.loader && middleware.length === 0) { continue; } try { const args = Object.create(state); Object.assign(args, config, context); // Terminal handler = the loader (or a no-op when the page has none). // `reached` stays false if a guard middleware short-circuits without // calling `next` — that is how $secure denies access in the browser. let reached = false; const terminal = async (a: any) => { reached = true; return (await route.loader?.(a)) ?? {}; }; const props = middleware.length ? ((await new PipelineHandler(terminal, middleware).run(args)) ?? {}) : await terminal(args); if (!reached) { // A page guard (e.g. $secure) denied access. Distinguish the two // cases so the flow is right: // - not authenticated (401) → redirect to the login page, // resolved by the conventional `name: "login"`, carrying the // blocked URL as `?redirect=` so login can return the user. // - authenticated but not allowed (403) → a forbidden error; // redirecting a logged-in user to login would just loop. 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 as { status?: number }).status = user ? 403 : 401; throw denied; } // save props it.props = { ...props, }; // add props to context context = { ...context, ...props, }; } catch (e) { // check if we need to redirect 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 nothing happen, call the parent if (result === undefined) { return onErrorParent(error, context); } return result; }; } // normal use case if (!it.error) { try { const element = await this.createElement( it.route, { // default props attached to page ...(it.route.props ? it.route.props() : {}), // resolved props ...props, // context props (from previous layers) ...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)); } } // handler has thrown an error, render an error view if (it.error) { try { let element: ReactNode | Redirection | undefined = await state.onError(it.error, state); if (element === undefined) { 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 the matched leaf opts out of SSR (own value or inherited from // parents), wrap the root layer in ClientOnly so the server emits no // HTML for the route chain. Loaders have already run above. if (state.layers.length > 0 && !this.isSSR(route)) { const rootLayer = state.layers[0]; rootLayer.element = createElement(ClientOnly, {}, rootLayer.element); } return { state }; } protected getErrorHandler(route: PageRoute): ErrorHandler | undefined { if (route.errorHandler) return route.errorHandler; let parent = route.parent; while (parent) { if (parent.errorHandler) return parent.errorHandler; parent = parent.parent; } } protected async createElement( page: PageRoute, props: Record<string, any>, targetUrl?: URL, ): Promise<ReactNode> { 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 { const component = await page.lazy(); return createElement(component.default, props); } catch (error) { if (this.alepha.isBrowser() && this.isChunkLoadError(error)) { if (this.reloadAfterChunkError(targetUrl)) { return undefined; } } throw error; } } if (page.component) { return createElement(page.component, props); } return undefined; } /** * 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. */ protected isChunkLoadError(error: unknown): boolean { 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. */ protected reloadAfterChunkError(url?: URL): boolean { const key = "alepha:chunk-reload"; const lastReload = sessionStorage.getItem(key); const now = this.dateTimeProvider.nowMillis(); if (lastReload && now - Number(lastReload) < 10_000) { 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; } public renderError(error: Error): ReactNode { return createElement(ErrorViewer, { error, alepha: this.alepha }); } public renderEmptyView(): ReactNode { return createElement(NestedView, {}); } public href( page: { options: { name?: string } }, params: Record<string, any> = {}, ): string { 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, "/") || "/"; } public compile(path: string, params: Record<string, string> = {}) { for (const [key, value] of Object.entries(params)) { path = path.replace(`:${key}`, value); } return path; } protected renderView( index: number, path: string, view: ReactNode | undefined, page: PageRoute, ): ReactNode { 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. */ public isSSR(route: PageRoute): boolean { let current: PageRoute | undefined = route; while (current) { if (typeof current.ssr === "boolean") { return current.ssr; } current = current.parent; } return true; } protected map( pages: Array<PagePrimitive>, target: PagePrimitive, ): PageRouteEntry { const children = target.options.children ? Array.isArray(target.options.children) ? target.options.children : target.options.children() : []; const getChildrenFromParent = (it: PagePrimitive): PagePrimitive[] => { 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: undefined, children: children.map((it) => this.map(pages, it)), } as PageRoute; } public add(entry: PageRouteEntry) { if (this.alepha.isReady()) { throw new AlephaError("Router is already initialized"); } entry.name ??= this.nextId(); const page = entry as PageRoute; page.match = this.createMatch(page); this.pages.push(page); if (page.children) { for (const child of page.children) { (child as PageRoute).parent = page; this.add(child); } } } protected createMatch(page: PageRoute): string { 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 !== "/") { // remove trailing slash path = path.slice(0, -1); } return path; } protected nextId(): string { this.nextIdCursor += 1; return `P${this.nextIdCursor}`; } } // --------------------------------------------------------------------------------------------------------------------- export const isPageRoute = (it: any): it is PageRoute => { return ( it && typeof it === "object" && typeof it.path === "string" && typeof it.page === "object" ); }; export interface PageRouteEntry extends Omit<PagePrimitiveOptions, "children" | "parent"> { children?: PageRouteEntry[]; } export interface ConcretePageRoute extends PageRoute { /** * When exported, static routes can be split into multiple pages with different params. * We replace 'name' by the new name for each static entry, and old 'name' becomes 'staticName'. */ staticName?: string; params?: Record<string, string>; } export interface PageRoute extends PageRouteEntry { type: "page"; name: string; parent?: PageRoute; match: string; /** * Optional meta information associated with the page route, can be used for any purpose (e.g. menu label, icon, etc.). */ label?: string; } export interface Layer { config?: { query?: Record<string, any>; params?: Record<string, any>; // stack of resolved props context?: Record<string, any>; }; name: string; props?: Record<string, any>; error?: Error; part?: string; element: ReactNode; index: number; path: string; route?: PageRoute; cache?: boolean; } export type PreviousLayerData = Omit<Layer, "element" | "index" | "path">; export interface AnchorProps { href: string; onClick: (ev?: any) => any; } export interface ReactRouterState { /** * Stack of layers for the current page. */ layers: Array<Layer>; /** * URL of the current page. */ url: URL; /** * Error handler for the current page. */ onError: ErrorHandler; /** * Params extracted from the URL for the current page. */ params: Record<string, any>; /** * Query parameters extracted from the URL for the current page. */ query: Record<string, string>; /** * Optional meta information associated with the current page. */ meta: Record<string, any>; /** * Head configuration for the current page (title, meta tags, etc.). * Populated by HeadProvider during SSR. */ head: Head; /** * Optional name of the current page route */ name?: string; } export interface RouterStackItem { route: PageRoute; config?: Record<string, any>; props?: Record<string, any>; error?: Error; cache?: boolean; } export interface CreateLayersResult { redirect?: string; state?: ReactRouterState; }