UNPKG

@adonisjs/inertia

Version:

Official Inertia.js adapter for AdonisJS

413 lines (407 loc) 12.9 kB
// src/server_renderer.ts import { pathToFileURL } from "url"; var ServerRenderer = class _ServerRenderer { constructor(config, vite) { this.config = config; this.vite = vite; } static runtime; /** * Render the page on the server * * On development, we use the Vite Runtime API * On production, we just import and use the SSR bundle generated by Vite */ async render(pageObject) { let render; const devServer = this.vite?.getDevServer(); if (devServer) { _ServerRenderer.runtime ??= await this.vite.createModuleRunner(); _ServerRenderer.runtime.clearCache(); render = await _ServerRenderer.runtime.import(this.config.ssr.entrypoint); } else { render = await import(pathToFileURL(this.config.ssr.bundle).href); } const result = await render.default(pageObject); return { head: result.head, body: result.body }; } }; // src/props.ts var ignoreFirstLoadSymbol = Symbol("ignoreFirstLoad"); var MergeableProp = class { shouldMerge = false; merge() { this.shouldMerge = true; return this; } }; var OptionalProp = class { constructor(callback) { this.callback = callback; } [ignoreFirstLoadSymbol] = true; }; var DeferProp = class extends MergeableProp { constructor(callback, group) { super(); this.callback = callback; this.group = group; } [ignoreFirstLoadSymbol] = true; getGroup() { return this.group; } }; var MergeProp = class extends MergeableProp { constructor(callback) { super(); this.callback = callback; this.shouldMerge = true; } }; var AlwaysProp = class extends MergeableProp { constructor(callback) { super(); this.callback = callback; } }; // src/headers.ts var InertiaHeaders = { Inertia: "x-inertia", Reset: "x-inertia-reset", Version: "x-inertia-version", Location: "x-inertia-location", ErrorBag: "X-Inertia-Error-Bag", PartialOnly: "x-inertia-partial-data", PartialExcept: "x-inertia-partial-except", PartialComponent: "x-inertia-partial-component" }; // src/inertia.ts var Inertia = class { constructor(ctx, config, vite) { this.ctx = ctx; this.config = config; this.vite = vite; this.#sharedData = config.sharedData; this.#serverRenderer = new ServerRenderer(config, vite); this.#shouldClearHistory = false; this.#shouldEncryptHistory = config.history.encrypt; } #sharedData = {}; #serverRenderer; #shouldClearHistory = false; #shouldEncryptHistory = false; /** * Check if the current request is a partial request */ #isPartial(component) { return this.ctx.request.header(InertiaHeaders.PartialComponent) === component; } /** * Resolve the `only` partial request props. * Only the props listed in the `x-inertia-partial-data` header * will be returned */ #resolveOnly(props) { const partialOnlyHeader = this.ctx.request.header(InertiaHeaders.PartialOnly); const only = partialOnlyHeader.split(",").filter(Boolean); let newProps = {}; for (const key of only) newProps[key] = props[key]; return newProps; } /** * Resolve the `except` partial request props. * Remove the props listed in the `x-inertia-partial-except` header */ #resolveExcept(props) { const partialExceptHeader = this.ctx.request.header(InertiaHeaders.PartialExcept); const except = partialExceptHeader.split(",").filter(Boolean); for (const key of except) delete props[key]; return props; } /** * Resolve the props for the current request * by filtering out the props that are not needed * based on the request headers */ #pickPropsToResolve(component, props = {}) { const isPartial = this.#isPartial(component); let newProps = props; if (!isPartial) { newProps = Object.fromEntries( Object.entries(props).filter(([_, value]) => { if (value && value[ignoreFirstLoadSymbol]) return false; return true; }) ); } const partialOnlyHeader = this.ctx.request.header(InertiaHeaders.PartialOnly); if (isPartial && partialOnlyHeader) newProps = this.#resolveOnly(props); const partialExceptHeader = this.ctx.request.header(InertiaHeaders.PartialExcept); if (isPartial && partialExceptHeader) newProps = this.#resolveExcept(newProps); for (const [key, value] of Object.entries(props)) { if (value instanceof AlwaysProp) newProps[key] = props[key]; } return newProps; } /** * Resolve a single prop */ async #resolveProp(key, value) { if (value instanceof OptionalProp || value instanceof MergeProp || value instanceof DeferProp || value instanceof AlwaysProp) { return [key, await value.callback()]; } return [key, value]; } /** * Resolve a single prop by calling the callback or resolving the promise */ async #resolvePageProps(props = {}) { return Object.fromEntries( await Promise.all( Object.entries(props).map(async ([key, value]) => { if (typeof value === "function") { const result = await value(this.ctx); return this.#resolveProp(key, result); } return this.#resolveProp(key, value); }) ) ); } /** * Resolve the deferred props listing. Will be returned only * on the first visit to the page and will be used to make * subsequent partial requests */ #resolveDeferredProps(component, pageProps) { if (this.#isPartial(component)) return {}; const deferredProps = Object.entries(pageProps || {}).filter(([_, value]) => value instanceof DeferProp).map(([key, value]) => ({ key, group: value.getGroup() })).reduce( (groups, { key, group }) => { if (!groups[group]) groups[group] = []; groups[group].push(key); return groups; }, {} ); return Object.keys(deferredProps).length ? { deferredProps } : {}; } /** * Resolve the props that should be merged */ #resolveMergeProps(pageProps) { const inertiaResetHeader = this.ctx.request.header(InertiaHeaders.Reset) || ""; const resetProps = new Set(inertiaResetHeader.split(",").filter(Boolean)); const mergeProps = Object.entries(pageProps || {}).filter(([_, value]) => value instanceof MergeableProp && value.shouldMerge).map(([key]) => key).filter((key) => !resetProps.has(key)); return mergeProps.length ? { mergeProps } : {}; } /** * Build the page object that will be returned to the client * * See https://inertiajs.com/the-protocol#the-page-object */ async #buildPageObject(component, pageProps) { const propsToResolve = this.#pickPropsToResolve(component, { ...this.#sharedData, ...pageProps }); return { component, url: this.ctx.request.url(true), version: this.config.versionCache.getVersion(), props: await this.#resolvePageProps(propsToResolve), clearHistory: this.#shouldClearHistory, encryptHistory: this.#shouldEncryptHistory, ...this.#resolveMergeProps(pageProps), ...this.#resolveDeferredProps(component, pageProps) }; } /** * If the page should be rendered on the server or not * * The ssr.pages config can be a list of pages or a function that returns a boolean */ async #shouldRenderOnServer(component) { const isSsrEnabled = this.config.ssr.enabled; if (!isSsrEnabled) return false; let isSsrEnabledForPage = false; if (typeof this.config.ssr.pages === "function") { isSsrEnabledForPage = await this.config.ssr.pages(this.ctx, component); } else if (this.config.ssr.pages) { isSsrEnabledForPage = this.config.ssr.pages?.includes(component); } else { isSsrEnabledForPage = true; } return isSsrEnabledForPage; } /** * Resolve the root view */ #resolveRootView() { return typeof this.config.rootView === "function" ? this.config.rootView(this.ctx) : this.config.rootView; } /** * Render the page on the server */ async #renderOnServer(pageObject, viewProps) { const { head, body } = await this.#serverRenderer.render(pageObject); return this.ctx.view.render(this.#resolveRootView(), { ...viewProps, page: { ssrHead: head, ssrBody: body, ...pageObject } }); } /** * Share data for the current request. * This data will override any shared data defined in the config. */ share(data) { this.#sharedData = { ...this.#sharedData, ...data }; } /** * Render a page using Inertia */ async render(component, pageProps, viewProps) { const pageObject = await this.#buildPageObject(component, pageProps); const isInertiaRequest = !!this.ctx.request.header(InertiaHeaders.Inertia); if (!isInertiaRequest) { const shouldRenderOnServer = await this.#shouldRenderOnServer(component); if (shouldRenderOnServer) return this.#renderOnServer(pageObject, viewProps); return this.ctx.view.render(this.#resolveRootView(), { ...viewProps, page: pageObject }); } this.ctx.response.header(InertiaHeaders.Inertia, "true"); return pageObject; } /** * Clear history state. * * See https://v2.inertiajs.com/history-encryption#clearing-history */ clearHistory() { this.#shouldClearHistory = true; } /** * Encrypt history * * See https://v2.inertiajs.com/history-encryption */ encryptHistory(encrypt = true) { this.#shouldEncryptHistory = encrypt; } /** * Create a lazy prop * * Lazy props are never resolved on first visit, but only when the client * request a partial reload explicitely with this value. * * See https://inertiajs.com/partial-reloads#lazy-data-evaluation * * @deprecated use `optional` instead */ lazy(callback) { return new OptionalProp(callback); } /** * Create an optional prop * * See https://inertiajs.com/partial-reloads#lazy-data-evaluation */ optional(callback) { return new OptionalProp(callback); } /** * Create a mergeable prop * * See https://v2.inertiajs.com/merging-props */ merge(callback) { return new MergeProp(callback); } /** * Create an always prop * * Always props are resolved on every request, no matter if it's a partial * request or not. * * See https://inertiajs.com/partial-reloads#lazy-data-evaluation */ always(callback) { return new AlwaysProp(callback); } /** * Create a deferred prop * * Deferred props feature allows you to defer the loading of certain * page data until after the initial page render. * * See https://v2.inertiajs.com/deferred-props */ defer(callback, group = "default") { return new DeferProp(callback, group); } /** * This method can be used to redirect the user to an external website * or even a non-inertia route of your application. * * See https://inertiajs.com/redirects#external-redirects */ async location(url) { this.ctx.response.header(InertiaHeaders.Location, url); this.ctx.response.status(409); } }; // src/inertia_middleware.ts var InertiaMiddleware = class { constructor(config, vite) { this.config = config; this.vite = vite; } /** * Resolves the validation errors to be shared with Inertia */ #resolveValidationErrors(ctx) { const { session, request } = ctx; if (!session) { return {}; } if (!session.flashMessages.has("errorsBag.E_VALIDATION_ERROR")) { return session.flashMessages.get("errorsBag"); } const errors = Object.entries(session.flashMessages.get("inputErrorsBag")).reduce( (acc, [field, messages]) => { acc[field] = Array.isArray(messages) ? messages[0] : messages; return acc; }, {} ); const errorBag = request.header(InertiaHeaders.ErrorBag); return errorBag ? { [errorBag]: errors } : errors; } /** * Share validation and flashed errors with Inertia */ #shareErrors(ctx) { ctx.inertia.share({ errors: ctx.inertia.always(() => this.#resolveValidationErrors(ctx)) }); } async handle(ctx, next) { const { response, request } = ctx; ctx.inertia = new Inertia(ctx, this.config, this.vite); this.#shareErrors(ctx); await next(); const isInertiaRequest = !!request.header(InertiaHeaders.Inertia); if (!isInertiaRequest) return; response.header("Vary", InertiaHeaders.Inertia); const method = request.method(); if (response.getStatus() === 302 && ["PUT", "PATCH", "DELETE"].includes(method)) { response.status(303); } const version = this.config.versionCache.getVersion().toString(); if (method === "GET" && request.header(InertiaHeaders.Version, "") !== version) { response.removeHeader(InertiaHeaders.Inertia); response.header(InertiaHeaders.Location, request.url()); response.status(409); } } }; export { InertiaMiddleware };