├── .editorconfig ├── .github ├── funding.yml ├── lock.yml ├── stale.yml └── workflows │ ├── checks.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── LICENSE.md ├── README.md ├── bin └── test.ts ├── configure.ts ├── eslint.config.js ├── factories └── inertia_factory.ts ├── index.ts ├── package.json ├── providers └── inertia_provider.ts ├── src ├── debug.ts ├── define_config.ts ├── files_detector.ts ├── headers.ts ├── helpers.ts ├── inertia.ts ├── inertia_middleware.ts ├── plugins │ ├── edge │ │ ├── plugin.ts │ │ ├── tags.ts │ │ └── utils.ts │ ├── japa │ │ └── api_client.ts │ └── vite.ts ├── props.ts ├── server_renderer.ts ├── types.ts └── version_cache.ts ├── stubs ├── app.css.stub ├── config.stub ├── main.ts ├── react │ ├── app.tsx.stub │ ├── errors │ │ ├── not_found.tsx.stub │ │ └── server_error.tsx.stub │ ├── home.tsx.stub │ ├── root.edge.stub │ ├── ssr.tsx.stub │ └── tsconfig.json.stub ├── solid │ ├── app.tsx.stub │ ├── errors │ │ ├── not_found.tsx.stub │ │ └── server_error.tsx.stub │ ├── home.tsx.stub │ ├── root.edge.stub │ ├── ssr.tsx.stub │ └── tsconfig.json.stub ├── svelte │ ├── app.ts.stub │ ├── errors │ │ ├── not_found.svelte.stub │ │ └── server_error.svelte.stub │ ├── home.svelte.stub │ ├── root.edge.stub │ ├── ssr.ts.stub │ └── tsconfig.json.stub └── vue │ ├── app.ts.stub │ ├── errors │ ├── not_found.vue.stub │ └── server_error.vue.stub │ ├── home.vue.stub │ ├── root.edge.stub │ ├── ssr.ts.stub │ └── tsconfig.json.stub ├── tests ├── configure.spec.ts ├── define_config.spec.ts ├── helpers.ts ├── inertia.spec.ts ├── middleware.spec.ts ├── plugins │ ├── api_client.spec.ts │ └── edge.plugin.spec.ts ├── provider.spec.ts ├── types.spec.ts └── version_cache.spec.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | insert_final_newline = ignore 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: [thetutlage, Julien-R44] 2 | polar: Julien-R44 3 | -------------------------------------------------------------------------------- /.github/lock.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ignoreUnless: {{ STALE_BOT }} 3 | --- 4 | # Configuration for Lock Threads - https://github.com/dessant/lock-threads-app 5 | 6 | # Number of days of inactivity before a closed issue or pull request is locked 7 | daysUntilLock: 60 8 | 9 | # Skip issues and pull requests created before a given timestamp. Timestamp must 10 | # follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable 11 | skipCreatedBefore: false 12 | 13 | # Issues and pull requests with these labels will be ignored. Set to `[]` to disable 14 | exemptLabels: ['Type: Security'] 15 | 16 | # Label to add before locking, such as `outdated`. Set to `false` to disable 17 | lockLabel: false 18 | 19 | # Comment to post before locking. Set to `false` to disable 20 | lockComment: > 21 | This thread has been automatically locked since there has not been 22 | any recent activity after it was closed. Please open a new issue for 23 | related bugs. 24 | 25 | # Assign `resolved` as the reason for locking. Set to `false` to disable 26 | setLockReason: false 27 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ignoreUnless: {{ STALE_BOT }} 3 | --- 4 | # Number of days of inactivity before an issue becomes stale 5 | daysUntilStale: 60 6 | 7 | # Number of days of inactivity before a stale issue is closed 8 | daysUntilClose: 7 9 | 10 | # Issues with these labels will never be considered stale 11 | exemptLabels: 12 | - 'Type: Security' 13 | 14 | # Label to use when marking an issue as stale 15 | staleLabel: 'Status: Abandoned' 16 | 17 | # Comment to post when marking an issue as stale. Set to `false` to disable 18 | markComment: > 19 | This issue has been automatically marked as stale because it has not had 20 | recent activity. It will be closed if no further activity occurs. Thank you 21 | for your contributions. 22 | 23 | # Comment to post when closing a stale issue. Set to `false` to disable 24 | closeComment: false 25 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | on: 3 | - push 4 | - pull_request 5 | - workflow_call 6 | 7 | jobs: 8 | test: 9 | uses: adonisjs/.github/.github/workflows/test.yml@main 10 | 11 | lint: 12 | uses: adonisjs/.github/.github/workflows/lint.yml@main 13 | 14 | typecheck: 15 | uses: adonisjs/.github/.github/workflows/typecheck.yml@main 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: workflow_dispatch 3 | 4 | permissions: 5 | contents: write 6 | id-token: write 7 | 8 | jobs: 9 | checks: 10 | uses: ./.github/workflows/checks.yml 11 | release: 12 | needs: checks 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | - name: git config 22 | run: | 23 | git config user.name "${GITHUB_ACTOR}" 24 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" 25 | - name: Init npm config 26 | run: npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 27 | env: 28 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 29 | - run: npm install 30 | - run: npm run release -- --ci 31 | env: 32 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | .todo.md 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | docs 3 | coverage 4 | *.html 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright (c) 2023 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @adonisjs/inertia 2 | 3 |
4 | 5 | [![gh-workflow-image]][gh-workflow-url] [![npm-image]][npm-url] ![][typescript-image] [![license-image]][license-url] 6 | 7 | ## Introduction 8 | Official [Inertia.js](https://inertiajs.com/) adapter for AdonisJS. 9 | 10 | ## Official Documentation 11 | The documentation is available on the [AdonisJS website](https://docs.adonisjs.com/guides/views-and-templates/inertia). 12 | 13 | ## Starter Kit 14 | The AdonisJS team maintains an Inertia starter kit. This starter kit provides a configurable base application using AdonisJS with Inertia and your favorite frontend framework (e.g. React, Vue.js, Svelte). 15 | 16 | - [adonisjs/inertia-starter-kit](https://github.com/adonisjs/inertia-starter-kit) 17 | 18 | ## Contributing 19 | One of the primary goals of AdonisJS is to have a vibrant community of users and contributors who believes in the principles of the framework. 20 | 21 | We encourage you to read the [contribution guide](https://github.com/adonisjs/.github/blob/main/docs/CONTRIBUTING.md) before contributing to the framework. 22 | 23 | ## Code of Conduct 24 | In order to ensure that the AdonisJS community is welcoming to all, please review and abide by the [Code of Conduct](https://github.com/adonisjs/.github/blob/main/docs/CODE_OF_CONDUCT.md). 25 | 26 | ## License 27 | AdonisJS Inertia is open-sourced software licensed under the [MIT license](LICENSE.md). 28 | 29 | [gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/adonisjs/inertia/checks.yml?style=for-the-badge 30 | [gh-workflow-url]: https://github.com/adonisjs/inertia/actions/workflows/checks.yml "Github action" 31 | 32 | [npm-image]: https://img.shields.io/npm/v/@adonisjs/inertia/latest.svg?style=for-the-badge&logo=npm 33 | [npm-url]: https://www.npmjs.com/package/@adonisjs/inertia/v/latest "npm" 34 | 35 | [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript 36 | 37 | [license-url]: LICENSE.md 38 | [license-image]: https://img.shields.io/github/license/adonisjs/inertia?style=for-the-badge 39 | -------------------------------------------------------------------------------- /bin/test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '@japa/assert' 2 | import { snapshot } from '@japa/snapshot' 3 | import { fileSystem } from '@japa/file-system' 4 | import { expectTypeOf } from '@japa/expect-type' 5 | import { processCLIArgs, configure, run } from '@japa/runner' 6 | 7 | import { BASE_URL } from '../tests/helpers.js' 8 | 9 | /* 10 | |-------------------------------------------------------------------------- 11 | | Configure tests 12 | |-------------------------------------------------------------------------- 13 | | 14 | | The configure method accepts the configuration to configure the Japa 15 | | tests runner. 16 | | 17 | | The first method call "processCLIArgs" process the command line arguments 18 | | and turns them into a config object. Using this method is not mandatory. 19 | | 20 | | Please consult japa.dev/runner-config for the config docs. 21 | */ 22 | processCLIArgs(process.argv.slice(2)) 23 | configure({ 24 | files: ['tests/**/*.spec.ts'], 25 | plugins: [assert(), fileSystem({ basePath: BASE_URL }), expectTypeOf(), snapshot()], 26 | }) 27 | 28 | /* 29 | |-------------------------------------------------------------------------- 30 | | Run tests 31 | |-------------------------------------------------------------------------- 32 | | 33 | | The following "run" method is required to execute all the tests. 34 | | 35 | */ 36 | run() 37 | -------------------------------------------------------------------------------- /configure.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import string from '@poppinss/utils/string' 11 | import { Codemods } from '@adonisjs/core/ace/codemods' 12 | import type Configure from '@adonisjs/core/commands/configure' 13 | 14 | import { stubsRoot } from './stubs/main.js' 15 | 16 | const ADAPTERS = ['vue', 'react', 'svelte', 'solid'] as const 17 | const ADAPTERS_INFO: { 18 | [K in (typeof ADAPTERS)[number]]: { 19 | stubFolder: string 20 | appExtension: string 21 | componentsExtension: string 22 | dependencies: { name: string; isDevDependency: boolean }[] 23 | ssrDependencies?: { name: string; isDevDependency: boolean }[] 24 | viteRegister: { 25 | pluginCall: Parameters[0] 26 | ssrPluginCall?: Parameters[0] 27 | importDeclarations: Parameters[1] 28 | } 29 | ssrEntrypoint?: string 30 | } 31 | } = { 32 | vue: { 33 | stubFolder: 'vue', 34 | appExtension: 'ts', 35 | componentsExtension: 'vue', 36 | dependencies: [ 37 | { name: '@inertiajs/vue3', isDevDependency: false }, 38 | { name: 'vue', isDevDependency: false }, 39 | { name: '@vitejs/plugin-vue', isDevDependency: true }, 40 | ], 41 | ssrDependencies: [{ name: '@vue/server-renderer', isDevDependency: false }], 42 | viteRegister: { 43 | pluginCall: 'vue()', 44 | importDeclarations: [{ isNamed: false, module: '@vitejs/plugin-vue', identifier: 'vue' }], 45 | }, 46 | ssrEntrypoint: 'inertia/app/ssr.ts', 47 | }, 48 | react: { 49 | stubFolder: 'react', 50 | appExtension: 'tsx', 51 | componentsExtension: 'tsx', 52 | dependencies: [ 53 | { name: '@inertiajs/react', isDevDependency: false }, 54 | { name: 'react', isDevDependency: false }, 55 | { name: 'react-dom', isDevDependency: false }, 56 | { name: '@vitejs/plugin-react', isDevDependency: true }, 57 | { name: '@types/react', isDevDependency: true }, 58 | { name: '@types/react-dom', isDevDependency: true }, 59 | ], 60 | viteRegister: { 61 | pluginCall: 'react()', 62 | importDeclarations: [{ isNamed: false, module: '@vitejs/plugin-react', identifier: 'react' }], 63 | }, 64 | ssrEntrypoint: 'inertia/app/ssr.tsx', 65 | }, 66 | svelte: { 67 | stubFolder: 'svelte', 68 | appExtension: 'ts', 69 | componentsExtension: 'svelte', 70 | dependencies: [ 71 | { name: '@inertiajs/svelte', isDevDependency: false }, 72 | { name: 'svelte', isDevDependency: false }, 73 | { name: '@sveltejs/vite-plugin-svelte', isDevDependency: true }, 74 | ], 75 | viteRegister: { 76 | pluginCall: 'svelte()', 77 | ssrPluginCall: 'svelte({ compilerOptions: { hydratable: true } })', 78 | importDeclarations: [ 79 | { isNamed: true, module: '@sveltejs/vite-plugin-svelte', identifier: 'svelte' }, 80 | ], 81 | }, 82 | ssrEntrypoint: 'inertia/app/ssr.ts', 83 | }, 84 | solid: { 85 | stubFolder: 'solid', 86 | appExtension: 'tsx', 87 | componentsExtension: 'tsx', 88 | dependencies: [ 89 | { name: 'solid-js', isDevDependency: false }, 90 | { name: 'inertia-adapter-solid', isDevDependency: false }, 91 | { name: 'vite-plugin-solid', isDevDependency: true }, 92 | { name: '@solidjs/meta', isDevDependency: false }, 93 | ], 94 | viteRegister: { 95 | pluginCall: 'solid()', 96 | ssrPluginCall: 'solid({ ssr: true })', 97 | importDeclarations: [{ isNamed: false, module: 'vite-plugin-solid', identifier: 'solid' }], 98 | }, 99 | ssrEntrypoint: 'inertia/app/ssr.tsx', 100 | }, 101 | } 102 | 103 | /** 104 | * Adds the example route to the routes file 105 | */ 106 | async function defineExampleRoute(command: Configure, codemods: Codemods) { 107 | const tsMorph = await codemods.getTsMorphProject() 108 | const routesFile = tsMorph?.getSourceFile(command.app.makePath('./start/routes.ts')) 109 | 110 | if (!routesFile) { 111 | return command.logger.warning('Unable to find the routes file') 112 | } 113 | 114 | const action = command.logger.action('update start/routes.ts file') 115 | try { 116 | routesFile?.addStatements((writer) => { 117 | writer.writeLine(`router.on('/').renderInertia('home')`) 118 | }) 119 | 120 | await tsMorph?.save() 121 | action.succeeded() 122 | } catch (error) { 123 | codemods.emit('error', error) 124 | action.failed(error.message) 125 | } 126 | } 127 | 128 | /** 129 | * Configures the package 130 | */ 131 | export async function configure(command: Configure) { 132 | let adapter: keyof typeof ADAPTERS_INFO | undefined = command.parsedFlags.adapter 133 | let ssr: boolean | undefined = command.parsedFlags.ssr 134 | let shouldInstallPackages: boolean | undefined = command.parsedFlags.install 135 | let shouldSkipExampleRoute: boolean | undefined = command.parsedFlags['skip-example-route'] 136 | 137 | /** 138 | * Prompt to select the adapter when `--adapter` flag is not passed 139 | */ 140 | if (adapter === undefined) { 141 | adapter = await command.prompt.choice( 142 | 'Select the Inertia adapter you want to use', 143 | ADAPTERS.map((adapterName) => string.capitalCase(adapterName)), 144 | { name: 'adapter', result: (value) => value.toLowerCase() as (typeof ADAPTERS)[number] } 145 | ) 146 | } 147 | 148 | /** 149 | * Prompt to select if SSR is needed when `--ssr` flag is not passed 150 | */ 151 | if (ssr === undefined) { 152 | ssr = await command.prompt.confirm('Do you want to use server-side rendering?', { 153 | name: 'ssr', 154 | }) 155 | } 156 | 157 | /** 158 | * Show error when selected adapter is not supported 159 | */ 160 | if (adapter! in ADAPTERS_INFO === false) { 161 | command.logger.error( 162 | `The selected adapter "${adapter}" is invalid. Select one from: ${string.sentence( 163 | Object.keys(ADAPTERS_INFO) 164 | )}` 165 | ) 166 | command.exitCode = 1 167 | return 168 | } 169 | 170 | const adapterInfo = ADAPTERS_INFO[adapter!] 171 | const codemods = await command.createCodemods() 172 | 173 | /** 174 | * Publish provider 175 | */ 176 | await codemods.updateRcFile((rcFile) => { 177 | rcFile.addProvider('@adonisjs/inertia/inertia_provider') 178 | }) 179 | 180 | /** 181 | * Add Inertia middleware 182 | */ 183 | await codemods.registerMiddleware('server', [ 184 | { path: '@adonisjs/inertia/inertia_middleware', position: 'after' }, 185 | ]) 186 | 187 | /** 188 | * Publish stubs 189 | */ 190 | const appExt = adapterInfo.appExtension 191 | const stubFolder = adapterInfo.stubFolder 192 | const compExt = adapterInfo.componentsExtension 193 | 194 | await codemods.makeUsingStub(stubsRoot, 'config.stub', { 195 | ssr, 196 | ssrEntrypoint: adapterInfo.ssrEntrypoint, 197 | }) 198 | await codemods.makeUsingStub(stubsRoot, `app.css.stub`, {}) 199 | await codemods.makeUsingStub(stubsRoot, `${stubFolder}/root.edge.stub`, {}) 200 | await codemods.makeUsingStub(stubsRoot, `${stubFolder}/tsconfig.json.stub`, {}) 201 | await codemods.makeUsingStub(stubsRoot, `${stubFolder}/app.${appExt}.stub`, { ssr }) 202 | await codemods.makeUsingStub(stubsRoot, `${stubFolder}/home.${compExt}.stub`, {}) 203 | await codemods.makeUsingStub(stubsRoot, `${stubFolder}/errors/not_found.${compExt}.stub`, {}) 204 | await codemods.makeUsingStub(stubsRoot, `${stubFolder}/errors/server_error.${compExt}.stub`, {}) 205 | 206 | if (ssr) { 207 | await codemods.makeUsingStub(stubsRoot, `${stubFolder}/ssr.${appExt}.stub`, {}) 208 | } 209 | 210 | /** 211 | * Register the inertia plugin in vite config 212 | */ 213 | const inertiaPluginCall = ssr 214 | ? `inertia({ ssr: { enabled: true, entrypoint: 'inertia/app/ssr.${appExt}' } })` 215 | : `inertia({ ssr: { enabled: false } })` 216 | 217 | await codemods.registerVitePlugin(inertiaPluginCall, [ 218 | { isNamed: false, module: '@adonisjs/inertia/client', identifier: 'inertia' }, 219 | ]) 220 | 221 | /** 222 | * Register the adapter plugin in vite config 223 | */ 224 | await codemods.registerVitePlugin( 225 | ssr && adapterInfo.viteRegister.ssrPluginCall 226 | ? adapterInfo.viteRegister.ssrPluginCall 227 | : adapterInfo.viteRegister.pluginCall, 228 | adapterInfo.viteRegister.importDeclarations 229 | ) 230 | 231 | /** 232 | * Register vite with adonisjs plugin 233 | */ 234 | const adonisjsPluginCall = `adonisjs({ entrypoints: ['inertia/app/app.${appExt}'], reload: ['resources/views/**/*.edge'] })` 235 | await codemods.registerVitePlugin(adonisjsPluginCall, [ 236 | { isNamed: false, module: '@adonisjs/vite/client', identifier: 'adonisjs' }, 237 | ]) 238 | 239 | /** 240 | * Add route example 241 | */ 242 | if (shouldSkipExampleRoute !== true) { 243 | await defineExampleRoute(command, codemods) 244 | } 245 | 246 | /** 247 | * Install packages 248 | */ 249 | const pkgToInstall = adapterInfo.dependencies 250 | if (ssr && adapterInfo.ssrDependencies) { 251 | pkgToInstall.push(...adapterInfo.ssrDependencies) 252 | } 253 | 254 | /** 255 | * Prompt when `install` or `--no-install` flags are 256 | * not used 257 | */ 258 | if (shouldInstallPackages === undefined) { 259 | shouldInstallPackages = await command.prompt.confirm( 260 | `Do you want to install dependencies ${pkgToInstall.map((pkg) => pkg.name).join(', ')}?`, 261 | { name: 'install' } 262 | ) 263 | } 264 | 265 | if (shouldInstallPackages) { 266 | await codemods.installPackages(pkgToInstall) 267 | } else { 268 | await codemods.listPackagesToInstall(pkgToInstall) 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { configPkg } from '@adonisjs/eslint-config' 2 | export default configPkg() 3 | -------------------------------------------------------------------------------- /factories/inertia_factory.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { Vite } from '@adonisjs/vite' 11 | import { HttpContext } from '@adonisjs/core/http' 12 | import { AppFactory } from '@adonisjs/core/factories/app' 13 | import { ApplicationService } from '@adonisjs/core/types' 14 | import { HttpContextFactory } from '@adonisjs/core/factories/http' 15 | 16 | import { defineConfig } from '../index.js' 17 | import { Inertia } from '../src/inertia.js' 18 | import { InertiaHeaders } from '../src/headers.js' 19 | import { ServerRenderer } from '../src/server_renderer.js' 20 | import { AssetsVersion, InertiaConfig } from '../src/types.js' 21 | 22 | type FactoryParameters = { 23 | ctx: HttpContext 24 | config?: InertiaConfig 25 | } 26 | 27 | /** 28 | * Inertia factory to quickly create a new instance of Inertia 29 | * for testing purposes 30 | */ 31 | export class InertiaFactory { 32 | #vite?: Vite 33 | #parameters: FactoryParameters = { 34 | ctx: new HttpContextFactory().create(), 35 | } 36 | 37 | #getApp() { 38 | return new AppFactory().create(new URL('./', import.meta.url), () => {}) as ApplicationService 39 | } 40 | 41 | merge(parameters: Partial) { 42 | Object.assign(this.#parameters, parameters) 43 | return this 44 | } 45 | 46 | withXInertiaHeader() { 47 | this.#parameters.ctx.request.request.headers[InertiaHeaders.Inertia] = 'true' 48 | return this 49 | } 50 | 51 | withInertiaPartialComponent(component: string) { 52 | this.#parameters.ctx.request.request.headers[InertiaHeaders.PartialComponent] = component 53 | return this 54 | } 55 | 56 | withInertiaPartialReload(component: string, data: string[]) { 57 | this.withInertiaPartialData(data) 58 | this.withInertiaPartialComponent(component) 59 | return this 60 | } 61 | 62 | withInertiaPartialExcept(data: string[]) { 63 | this.#parameters.ctx.request.request.headers[InertiaHeaders.PartialExcept] = data.join(',') 64 | return this 65 | } 66 | 67 | withVite(options: Vite) { 68 | this.#vite = options 69 | return this 70 | } 71 | 72 | withInertiaPartialData(data: string[]) { 73 | this.#parameters.ctx.request.request.headers[InertiaHeaders.PartialOnly] = data.join(',') 74 | return this 75 | } 76 | 77 | withVersion(version: AssetsVersion) { 78 | this.#parameters.config = { ...this.#parameters.config, assetsVersion: version } 79 | return this 80 | } 81 | 82 | async create() { 83 | const config = await defineConfig(this.#parameters.config || {}).resolver(this.#getApp()) 84 | // @ts-ignore 85 | ServerRenderer.runtime = undefined 86 | return new Inertia(this.#parameters.ctx, config, this.#vite) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export { configure } from './configure.js' 11 | export { defineConfig } from './src/define_config.js' 12 | export { stubsRoot } from './stubs/main.js' 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adonisjs/inertia", 3 | "description": "Official Inertia.js adapter for AdonisJS", 4 | "version": "3.1.1", 5 | "engines": { 6 | "node": ">=20.6.0" 7 | }, 8 | "main": "build/index.js", 9 | "type": "module", 10 | "files": [ 11 | "build" 12 | ], 13 | "exports": { 14 | ".": "./build/index.js", 15 | "./types": "./build/src/types.js", 16 | "./services/main": "./build/services/inertia.js", 17 | "./inertia_middleware": "./build/src/inertia_middleware.js", 18 | "./inertia_provider": "./build/providers/inertia_provider.js", 19 | "./plugins/edge": "./build/src/plugins/edge/plugin.js", 20 | "./plugins/api_client": "./build/src/plugins/japa/api_client.js", 21 | "./client": "./build/src/plugins/vite.js", 22 | "./helpers": "./build/src/helpers.js" 23 | }, 24 | "scripts": { 25 | "clean": "del-cli build", 26 | "copy:templates": "copyfiles --up 1 \"stubs/**/*.stub\" build", 27 | "typecheck": "tsc --noEmit", 28 | "lint": "eslint", 29 | "format": "prettier --write .", 30 | "quick:test": "node --enable-source-maps --import=ts-node-maintained/register/esm bin/test.ts", 31 | "pretest": "npm run lint", 32 | "test": "c8 npm run quick:test", 33 | "prebuild": "npm run lint && npm run clean", 34 | "build": "tsup-node", 35 | "postbuild": "npm run copy:templates", 36 | "release": "release-it", 37 | "version": "npm run build", 38 | "prepublishOnly": "npm run build" 39 | }, 40 | "devDependencies": { 41 | "@adonisjs/assembler": "^7.8.2", 42 | "@adonisjs/core": "6.17.1", 43 | "@adonisjs/eslint-config": "^2.0.0", 44 | "@adonisjs/prettier-config": "^1.4.2", 45 | "@adonisjs/session": "^7.5.1", 46 | "@adonisjs/tsconfig": "^1.4.0", 47 | "@adonisjs/vite": "^4.0.0", 48 | "@japa/api-client": "^3.0.3", 49 | "@japa/assert": "4.0.1", 50 | "@japa/expect-type": "^2.0.3", 51 | "@japa/file-system": "^2.3.2", 52 | "@japa/plugin-adonisjs": "^4.0.0", 53 | "@japa/runner": "4.2.0", 54 | "@japa/snapshot": "^2.0.8", 55 | "@release-it/conventional-changelog": "^10.0.0", 56 | "@swc/core": "1.10.7", 57 | "@types/node": "^22.13.9", 58 | "@types/qs": "^6.9.18", 59 | "@types/supertest": "^6.0.2", 60 | "@vavite/multibuild": "^5.1.0", 61 | "c8": "^10.1.3", 62 | "copyfiles": "^2.4.1", 63 | "del-cli": "^6.0.0", 64 | "edge-parser": "^9.0.4", 65 | "edge.js": "^6.2.1", 66 | "eslint": "^9.21.0", 67 | "get-port": "^7.1.0", 68 | "prettier": "^3.5.3", 69 | "release-it": "^18.1.2", 70 | "supertest": "^7.0.0", 71 | "ts-node-maintained": "^10.9.5", 72 | "tsup": "^8.4.0", 73 | "typescript": "~5.8.2", 74 | "vite": "^6.2.1" 75 | }, 76 | "dependencies": { 77 | "@poppinss/utils": "^6.9.2", 78 | "@tuyau/utils": "^0.0.7", 79 | "edge-error": "^4.0.2", 80 | "html-entities": "^2.5.2", 81 | "locate-path": "^7.2.0", 82 | "qs": "^6.14.0" 83 | }, 84 | "peerDependencies": { 85 | "@adonisjs/core": "^6.9.1", 86 | "@adonisjs/session": "^7.4.0", 87 | "@adonisjs/vite": "^4.0.0", 88 | "@japa/api-client": "^2.0.0 || ^3.0.0", 89 | "edge.js": "^6.0.0" 90 | }, 91 | "peerDependenciesMeta": { 92 | "@japa/api-client": { 93 | "optional": true 94 | } 95 | }, 96 | "publishConfig": { 97 | "access": "public" 98 | }, 99 | "author": "Julien Ripouteau ", 100 | "license": "MIT", 101 | "homepage": "https://github.com/adonisjs/inertia#readme", 102 | "repository": { 103 | "type": "git", 104 | "url": "git+https://github.com/adonisjs/inertia.git" 105 | }, 106 | "bugs": { 107 | "url": "https://github.com/adonisjs/inertia/issues" 108 | }, 109 | "keywords": [ 110 | "inertia", 111 | "adonisjs" 112 | ], 113 | "prettier": "@adonisjs/prettier-config", 114 | "release-it": { 115 | "git": { 116 | "requireUpstream": true, 117 | "commitMessage": "chore(release): ${version}", 118 | "tagAnnotation": "v${version}", 119 | "push": true, 120 | "tagName": "v${version}" 121 | }, 122 | "github": { 123 | "release": true 124 | }, 125 | "npm": { 126 | "publish": true, 127 | "skipChecks": true, 128 | "tag": "latest" 129 | }, 130 | "plugins": { 131 | "@release-it/conventional-changelog": { 132 | "preset": { 133 | "name": "angular" 134 | } 135 | } 136 | } 137 | }, 138 | "c8": { 139 | "reporter": [ 140 | "text", 141 | "html" 142 | ], 143 | "exclude": [ 144 | "tests/**", 145 | "tests_helpers/**" 146 | ] 147 | }, 148 | "tsup": { 149 | "entry": [ 150 | "./index.ts", 151 | "./src/types.ts", 152 | "./src/helpers.ts", 153 | "./src/plugins/vite.ts", 154 | "./services/inertia.ts", 155 | "./src/inertia_middleware.ts", 156 | "./providers/inertia_provider.ts", 157 | "./src/plugins/edge/plugin.ts", 158 | "./src/plugins/japa/api_client.ts" 159 | ], 160 | "outDir": "./build", 161 | "clean": true, 162 | "format": "esm", 163 | "dts": true, 164 | "target": "esnext" 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /providers/inertia_provider.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /// 11 | 12 | import { configProvider } from '@adonisjs/core' 13 | import { RuntimeException } from '@poppinss/utils' 14 | import { BriskRoute, Route } from '@adonisjs/core/http' 15 | import type { ApplicationService } from '@adonisjs/core/types' 16 | 17 | import InertiaMiddleware from '../src/inertia_middleware.js' 18 | import type { InertiaConfig, ResolvedConfig } from '../src/types.js' 19 | 20 | declare module '@adonisjs/core/http' { 21 | interface BriskRoute { 22 | /** 23 | * Render an inertia page without defining an 24 | * explicit route handler 25 | */ 26 | renderInertia( 27 | component: string, 28 | props?: Record, 29 | viewProps?: Record 30 | ): Route 31 | } 32 | } 33 | 34 | /** 35 | * Inertia provider 36 | */ 37 | export default class InertiaProvider { 38 | constructor(protected app: ApplicationService) {} 39 | 40 | /** 41 | * Registers edge plugin when edge is installed 42 | */ 43 | protected async registerEdgePlugin() { 44 | if (!this.app.usingEdgeJS) return 45 | 46 | const edgeExports = await import('edge.js') 47 | const { edgePluginInertia } = await import('../src/plugins/edge/plugin.js') 48 | edgeExports.default.use(edgePluginInertia()) 49 | } 50 | 51 | /** 52 | * Register inertia middleware 53 | */ 54 | async register() { 55 | this.app.container.singleton(InertiaMiddleware, async () => { 56 | const inertiaConfigProvider = this.app.config.get('inertia') 57 | const config = await configProvider.resolve(this.app, inertiaConfigProvider) 58 | const vite = await this.app.container.make('vite') 59 | 60 | if (!config) { 61 | throw new RuntimeException( 62 | 'Invalid "config/inertia.ts" file. Make sure you are using the "defineConfig" method' 63 | ) 64 | } 65 | 66 | return new InertiaMiddleware(config, vite) 67 | }) 68 | } 69 | 70 | /** 71 | * Register edge plugin and brisk route macro 72 | */ 73 | async boot() { 74 | await this.registerEdgePlugin() 75 | 76 | /** 77 | * Adding brisk route to render inertia pages 78 | * without an explicit handler 79 | */ 80 | BriskRoute.macro('renderInertia', function (this: BriskRoute, template, props, viewProps) { 81 | return this.setHandler(({ inertia }) => { 82 | return inertia.render(template, props, viewProps) 83 | }) 84 | }) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { debuglog } from 'node:util' 11 | 12 | export default debuglog('adonisjs:inertia') 13 | -------------------------------------------------------------------------------- /src/define_config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { slash } from '@poppinss/utils' 11 | import { configProvider } from '@adonisjs/core' 12 | import type { ConfigProvider } from '@adonisjs/core/types' 13 | 14 | import { VersionCache } from './version_cache.js' 15 | import { FilesDetector } from './files_detector.js' 16 | import type { InertiaConfig, ResolvedConfig, SharedData } from './types.js' 17 | 18 | /** 19 | * Define the Inertia configuration 20 | */ 21 | export function defineConfig( 22 | config: InertiaConfig 23 | ): ConfigProvider> { 24 | return configProvider.create(async (app) => { 25 | const detector = new FilesDetector(app) 26 | const versionCache = new VersionCache(app.appRoot, config.assetsVersion) 27 | await versionCache.computeVersion() 28 | 29 | return { 30 | versionCache, 31 | rootView: config.rootView ?? 'inertia_layout', 32 | sharedData: config.sharedData! || {}, 33 | history: { encrypt: config.history?.encrypt ?? false }, 34 | entrypoint: slash( 35 | config.entrypoint ?? (await detector.detectEntrypoint('inertia/app/app.ts')) 36 | ), 37 | ssr: { 38 | enabled: config.ssr?.enabled ?? false, 39 | pages: config.ssr?.pages, 40 | entrypoint: 41 | config.ssr?.entrypoint ?? (await detector.detectSsrEntrypoint('inertia/app/ssr.ts')), 42 | 43 | bundle: config.ssr?.bundle ?? (await detector.detectSsrBundle('ssr/ssr.js')), 44 | }, 45 | } 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /src/files_detector.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { locatePath } from 'locate-path' 11 | import { Application } from '@adonisjs/core/app' 12 | 13 | export class FilesDetector { 14 | constructor(protected app: Application) {} 15 | 16 | /** 17 | * Try to locate the entrypoint file based 18 | * on the conventional locations 19 | */ 20 | async detectEntrypoint(defaultPath: string) { 21 | const possiblesLocations = [ 22 | './inertia/app/app.ts', 23 | './inertia/app/app.tsx', 24 | './resources/app.ts', 25 | './resources/app.tsx', 26 | './resources/app.jsx', 27 | './resources/app.js', 28 | './inertia/app/app.jsx', 29 | ] 30 | 31 | const path = await locatePath(possiblesLocations, { cwd: this.app.appRoot }) 32 | return this.app.makePath(path || defaultPath) 33 | } 34 | 35 | /** 36 | * Try to locate the SSR entrypoint file based 37 | * on the conventional locations 38 | */ 39 | async detectSsrEntrypoint(defaultPath: string) { 40 | const possiblesLocations = [ 41 | './inertia/app/ssr.ts', 42 | './inertia/app/ssr.tsx', 43 | './resources/ssr.ts', 44 | './resources/ssr.tsx', 45 | './resources/ssr.jsx', 46 | './resources/ssr.js', 47 | './inertia/app/ssr.jsx', 48 | ] 49 | 50 | const path = await locatePath(possiblesLocations, { cwd: this.app.appRoot }) 51 | return this.app.makePath(path || defaultPath) 52 | } 53 | 54 | /** 55 | * Try to locate the SSR bundle file based 56 | * on the conventional locations 57 | */ 58 | async detectSsrBundle(defaultPath: string) { 59 | const possiblesLocations = ['./ssr/ssr.js', './ssr/ssr.mjs'] 60 | 61 | const path = await locatePath(possiblesLocations, { cwd: this.app.appRoot }) 62 | return this.app.makePath(path || defaultPath) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/headers.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /** 11 | * List of possible headers used by InertiaJS 12 | */ 13 | export const InertiaHeaders = { 14 | Inertia: 'x-inertia', 15 | Reset: 'x-inertia-reset', 16 | Version: 'x-inertia-version', 17 | Location: 'x-inertia-location', 18 | ErrorBag: 'X-Inertia-Error-Bag', 19 | PartialOnly: 'x-inertia-partial-data', 20 | PartialExcept: 'x-inertia-partial-except', 21 | PartialComponent: 'x-inertia-partial-component', 22 | } as const 23 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility function to resolve a page component 3 | * 4 | * @example 5 | * return resolvePageComponent( 6 | * `./pages/${name}.vue`, 7 | * import.meta.glob("./pages/**\/*.vue") 8 | * ) 9 | */ 10 | export async function resolvePageComponent( 11 | path: string | string[], 12 | pages: Record | (() => Promise)> 13 | ): Promise { 14 | for (const p of Array.isArray(path) ? path : [path]) { 15 | const page = pages[p] 16 | 17 | if (typeof page === 'undefined') { 18 | continue 19 | } 20 | 21 | return typeof page === 'function' ? page() : page 22 | } 23 | 24 | throw new Error(`Page not found: ${path}`) 25 | } 26 | -------------------------------------------------------------------------------- /src/inertia.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /// 11 | 12 | import { Vite } from '@adonisjs/vite' 13 | import type { HttpContext } from '@adonisjs/core/http' 14 | 15 | import { ServerRenderer } from './server_renderer.js' 16 | import type { 17 | Data, 18 | MaybePromise, 19 | PageObject, 20 | PageProps, 21 | ResolvedConfig, 22 | SharedData, 23 | } from './types.js' 24 | import { 25 | AlwaysProp, 26 | DeferProp, 27 | ignoreFirstLoadSymbol, 28 | MergeableProp, 29 | MergeProp, 30 | OptionalProp, 31 | } from './props.js' 32 | import { InertiaHeaders } from './headers.js' 33 | 34 | /** 35 | * Main class used to interact with Inertia 36 | */ 37 | export class Inertia { 38 | #sharedData: SharedData = {} 39 | #serverRenderer: ServerRenderer 40 | 41 | #shouldClearHistory = false 42 | #shouldEncryptHistory = false 43 | 44 | constructor( 45 | protected ctx: HttpContext, 46 | protected config: ResolvedConfig, 47 | protected vite?: Vite 48 | ) { 49 | this.#sharedData = config.sharedData 50 | this.#serverRenderer = new ServerRenderer(config, vite) 51 | 52 | this.#shouldClearHistory = false 53 | this.#shouldEncryptHistory = config.history.encrypt 54 | } 55 | 56 | /** 57 | * Check if the current request is a partial request 58 | */ 59 | #isPartial(component: string) { 60 | return this.ctx.request.header(InertiaHeaders.PartialComponent) === component 61 | } 62 | 63 | /** 64 | * Resolve the `only` partial request props. 65 | * Only the props listed in the `x-inertia-partial-data` header 66 | * will be returned 67 | */ 68 | #resolveOnly(props: PageProps) { 69 | const partialOnlyHeader = this.ctx.request.header(InertiaHeaders.PartialOnly) 70 | const only = partialOnlyHeader!.split(',').filter(Boolean) 71 | let newProps: PageProps = {} 72 | 73 | for (const key of only) newProps[key] = props[key] 74 | 75 | return newProps 76 | } 77 | 78 | /** 79 | * Resolve the `except` partial request props. 80 | * Remove the props listed in the `x-inertia-partial-except` header 81 | */ 82 | #resolveExcept(props: PageProps) { 83 | const partialExceptHeader = this.ctx.request.header(InertiaHeaders.PartialExcept) 84 | const except = partialExceptHeader!.split(',').filter(Boolean) 85 | 86 | for (const key of except) delete props[key] 87 | 88 | return props 89 | } 90 | 91 | /** 92 | * Resolve the props for the current request 93 | * by filtering out the props that are not needed 94 | * based on the request headers 95 | */ 96 | #pickPropsToResolve(component: string, props: PageProps = {}) { 97 | const isPartial = this.#isPartial(component) 98 | let newProps = props 99 | 100 | /** 101 | * If it's not a partial request, keep everything as it is 102 | * except the props that are marked as `ignoreFirstLoad` 103 | */ 104 | if (!isPartial) { 105 | newProps = Object.fromEntries( 106 | Object.entries(props).filter(([_, value]) => { 107 | if (value && (value as any)[ignoreFirstLoadSymbol]) return false 108 | 109 | return true 110 | }) 111 | ) 112 | } 113 | 114 | /** 115 | * Keep only the props that are listed in the `x-inertia-partial-data` header 116 | */ 117 | const partialOnlyHeader = this.ctx.request.header(InertiaHeaders.PartialOnly) 118 | if (isPartial && partialOnlyHeader) newProps = this.#resolveOnly(props) 119 | 120 | /** 121 | * Remove the props that are listed in the `x-inertia-partial-except` header 122 | */ 123 | const partialExceptHeader = this.ctx.request.header(InertiaHeaders.PartialExcept) 124 | if (isPartial && partialExceptHeader) newProps = this.#resolveExcept(newProps) 125 | 126 | /** 127 | * Resolve all the props that are marked as `AlwaysProp` since they 128 | * should be resolved on every request, no matter if it's a partial 129 | * request or not. 130 | */ 131 | for (const [key, value] of Object.entries(props)) { 132 | if (value instanceof AlwaysProp) newProps[key] = props[key] 133 | } 134 | 135 | return newProps 136 | } 137 | 138 | /** 139 | * Resolve a single prop 140 | */ 141 | async #resolveProp(key: string, value: any) { 142 | if ( 143 | value instanceof OptionalProp || 144 | value instanceof MergeProp || 145 | value instanceof DeferProp || 146 | value instanceof AlwaysProp 147 | ) { 148 | return [key, await value.callback()] 149 | } 150 | 151 | return [key, value] 152 | } 153 | 154 | /** 155 | * Resolve a single prop by calling the callback or resolving the promise 156 | */ 157 | async #resolvePageProps(props: PageProps = {}) { 158 | return Object.fromEntries( 159 | await Promise.all( 160 | Object.entries(props).map(async ([key, value]) => { 161 | if (typeof value === 'function') { 162 | const result = await value(this.ctx) 163 | return this.#resolveProp(key, result) 164 | } 165 | 166 | return this.#resolveProp(key, value) 167 | }) 168 | ) 169 | ) 170 | } 171 | 172 | /** 173 | * Resolve the deferred props listing. Will be returned only 174 | * on the first visit to the page and will be used to make 175 | * subsequent partial requests 176 | */ 177 | #resolveDeferredProps(component: string, pageProps?: PageProps) { 178 | if (this.#isPartial(component)) return {} 179 | 180 | const deferredProps = Object.entries(pageProps || {}) 181 | .filter(([_, value]) => value instanceof DeferProp) 182 | .map(([key, value]) => ({ key, group: (value as DeferProp).getGroup() })) 183 | .reduce( 184 | (groups, { key, group }) => { 185 | if (!groups[group]) groups[group] = [] 186 | 187 | groups[group].push(key) 188 | return groups 189 | }, 190 | {} as Record 191 | ) 192 | 193 | return Object.keys(deferredProps).length ? { deferredProps } : {} 194 | } 195 | 196 | /** 197 | * Resolve the props that should be merged 198 | */ 199 | #resolveMergeProps(pageProps?: PageProps) { 200 | const inertiaResetHeader = this.ctx.request.header(InertiaHeaders.Reset) || '' 201 | const resetProps = new Set(inertiaResetHeader.split(',').filter(Boolean)) 202 | 203 | const mergeProps = Object.entries(pageProps || {}) 204 | .filter(([_, value]) => value instanceof MergeableProp && value.shouldMerge) 205 | .map(([key]) => key) 206 | .filter((key) => !resetProps.has(key)) 207 | 208 | return mergeProps.length ? { mergeProps } : {} 209 | } 210 | 211 | /** 212 | * Build the page object that will be returned to the client 213 | * 214 | * See https://inertiajs.com/the-protocol#the-page-object 215 | */ 216 | async #buildPageObject( 217 | component: string, 218 | pageProps?: TPageProps 219 | ): Promise> { 220 | const propsToResolve = this.#pickPropsToResolve(component, { 221 | ...this.#sharedData, 222 | ...pageProps, 223 | }) 224 | 225 | return { 226 | component, 227 | url: this.ctx.request.url(true), 228 | version: this.config.versionCache.getVersion(), 229 | props: await this.#resolvePageProps(propsToResolve), 230 | clearHistory: this.#shouldClearHistory, 231 | encryptHistory: this.#shouldEncryptHistory, 232 | ...this.#resolveMergeProps(pageProps), 233 | ...this.#resolveDeferredProps(component, pageProps), 234 | } 235 | } 236 | 237 | /** 238 | * If the page should be rendered on the server or not 239 | * 240 | * The ssr.pages config can be a list of pages or a function that returns a boolean 241 | */ 242 | async #shouldRenderOnServer(component: string) { 243 | const isSsrEnabled = this.config.ssr.enabled 244 | if (!isSsrEnabled) return false 245 | 246 | let isSsrEnabledForPage = false 247 | if (typeof this.config.ssr.pages === 'function') { 248 | isSsrEnabledForPage = await this.config.ssr.pages(this.ctx, component) 249 | } else if (this.config.ssr.pages) { 250 | isSsrEnabledForPage = this.config.ssr.pages?.includes(component) 251 | } else { 252 | isSsrEnabledForPage = true 253 | } 254 | 255 | return isSsrEnabledForPage 256 | } 257 | 258 | /** 259 | * Resolve the root view 260 | */ 261 | #resolveRootView() { 262 | return typeof this.config.rootView === 'function' 263 | ? this.config.rootView(this.ctx) 264 | : this.config.rootView 265 | } 266 | 267 | /** 268 | * Render the page on the server 269 | */ 270 | async #renderOnServer(pageObject: PageObject, viewProps?: Record) { 271 | const { head, body } = await this.#serverRenderer.render(pageObject) 272 | 273 | return this.ctx.view.render(this.#resolveRootView(), { 274 | ...viewProps, 275 | page: { ssrHead: head, ssrBody: body, ...pageObject }, 276 | }) 277 | } 278 | 279 | /** 280 | * Share data for the current request. 281 | * This data will override any shared data defined in the config. 282 | */ 283 | share(data: Record) { 284 | this.#sharedData = { ...this.#sharedData, ...data } 285 | } 286 | 287 | /** 288 | * Render a page using Inertia 289 | */ 290 | async render< 291 | TPageProps extends Record = {}, 292 | TViewProps extends Record = {}, 293 | >( 294 | component: string, 295 | pageProps?: TPageProps, 296 | viewProps?: TViewProps 297 | ): Promise> { 298 | const pageObject = await this.#buildPageObject(component, pageProps) 299 | const isInertiaRequest = !!this.ctx.request.header(InertiaHeaders.Inertia) 300 | 301 | if (!isInertiaRequest) { 302 | const shouldRenderOnServer = await this.#shouldRenderOnServer(component) 303 | if (shouldRenderOnServer) return this.#renderOnServer(pageObject, viewProps) 304 | 305 | return this.ctx.view.render(this.#resolveRootView(), { ...viewProps, page: pageObject }) 306 | } 307 | 308 | this.ctx.response.header(InertiaHeaders.Inertia, 'true') 309 | return pageObject 310 | } 311 | 312 | /** 313 | * Clear history state. 314 | * 315 | * See https://v2.inertiajs.com/history-encryption#clearing-history 316 | */ 317 | clearHistory() { 318 | this.#shouldClearHistory = true 319 | } 320 | 321 | /** 322 | * Encrypt history 323 | * 324 | * See https://v2.inertiajs.com/history-encryption 325 | */ 326 | encryptHistory(encrypt = true) { 327 | this.#shouldEncryptHistory = encrypt 328 | } 329 | 330 | /** 331 | * Create a lazy prop 332 | * 333 | * Lazy props are never resolved on first visit, but only when the client 334 | * request a partial reload explicitely with this value. 335 | * 336 | * See https://inertiajs.com/partial-reloads#lazy-data-evaluation 337 | * 338 | * @deprecated use `optional` instead 339 | */ 340 | lazy(callback: () => MaybePromise) { 341 | return new OptionalProp(callback) 342 | } 343 | 344 | /** 345 | * Create an optional prop 346 | * 347 | * See https://inertiajs.com/partial-reloads#lazy-data-evaluation 348 | */ 349 | optional(callback: () => MaybePromise) { 350 | return new OptionalProp(callback) 351 | } 352 | 353 | /** 354 | * Create a mergeable prop 355 | * 356 | * See https://v2.inertiajs.com/merging-props 357 | */ 358 | merge(callback: () => MaybePromise) { 359 | return new MergeProp(callback) 360 | } 361 | 362 | /** 363 | * Create an always prop 364 | * 365 | * Always props are resolved on every request, no matter if it's a partial 366 | * request or not. 367 | * 368 | * See https://inertiajs.com/partial-reloads#lazy-data-evaluation 369 | */ 370 | always(callback: () => MaybePromise) { 371 | return new AlwaysProp(callback) 372 | } 373 | 374 | /** 375 | * Create a deferred prop 376 | * 377 | * Deferred props feature allows you to defer the loading of certain 378 | * page data until after the initial page render. 379 | * 380 | * See https://v2.inertiajs.com/deferred-props 381 | */ 382 | defer(callback: () => MaybePromise, group = 'default') { 383 | return new DeferProp(callback, group) 384 | } 385 | 386 | /** 387 | * This method can be used to redirect the user to an external website 388 | * or even a non-inertia route of your application. 389 | * 390 | * See https://inertiajs.com/redirects#external-redirects 391 | */ 392 | async location(url: string) { 393 | this.ctx.response.header(InertiaHeaders.Location, url) 394 | this.ctx.response.status(409) 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /src/inertia_middleware.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /// 11 | 12 | import type { Vite } from '@adonisjs/vite' 13 | import type { HttpContext } from '@adonisjs/core/http' 14 | import type { NextFn } from '@adonisjs/core/types/http' 15 | 16 | import { Inertia } from './inertia.js' 17 | import { InertiaHeaders } from './headers.js' 18 | import type { ResolvedConfig } from './types.js' 19 | 20 | /** 21 | * HttpContext augmentations 22 | */ 23 | declare module '@adonisjs/core/http' { 24 | export interface HttpContext { 25 | inertia: Inertia 26 | } 27 | } 28 | 29 | /** 30 | * Inertia middleware to handle the Inertia requests and 31 | * set appropriate headers/status 32 | */ 33 | export default class InertiaMiddleware { 34 | constructor( 35 | protected config: ResolvedConfig, 36 | protected vite?: Vite 37 | ) {} 38 | 39 | /** 40 | * Resolves the validation errors to be shared with Inertia 41 | */ 42 | #resolveValidationErrors(ctx: HttpContext) { 43 | const { session, request } = ctx 44 | 45 | // If the session middleware wasn't executed 46 | // (on routes that are not in the router, for instance), 47 | // then the session object will be undefined. 48 | if (!session) { 49 | return {} 50 | } 51 | 52 | /** 53 | * If not a Vine Validation error, then return the entire error bag 54 | */ 55 | if (!session.flashMessages.has('errorsBag.E_VALIDATION_ERROR')) { 56 | return session.flashMessages.get('errorsBag') 57 | } 58 | 59 | /** 60 | * Otherwise, resolve the validation errors. We only keep the first 61 | * error message for each field 62 | */ 63 | const errors = Object.entries(session.flashMessages.get('inputErrorsBag')).reduce( 64 | (acc, [field, messages]) => { 65 | acc[field] = Array.isArray(messages) ? messages[0] : messages 66 | return acc 67 | }, 68 | {} as Record 69 | ) 70 | 71 | /** 72 | * Also, nest the errors under the error bag key if asked 73 | * See https://inertiajs.com/validation#error-bags 74 | */ 75 | const errorBag = request.header(InertiaHeaders.ErrorBag) 76 | return errorBag ? { [errorBag]: errors } : errors 77 | } 78 | 79 | /** 80 | * Share validation and flashed errors with Inertia 81 | */ 82 | #shareErrors(ctx: HttpContext) { 83 | ctx.inertia.share({ errors: ctx.inertia.always(() => this.#resolveValidationErrors(ctx)) }) 84 | } 85 | 86 | async handle(ctx: HttpContext, next: NextFn) { 87 | const { response, request } = ctx 88 | 89 | ctx.inertia = new Inertia(ctx, this.config, this.vite) 90 | this.#shareErrors(ctx) 91 | 92 | await next() 93 | 94 | const isInertiaRequest = !!request.header(InertiaHeaders.Inertia) 95 | if (!isInertiaRequest) return 96 | 97 | response.header('Vary', InertiaHeaders.Inertia) 98 | 99 | /** 100 | * When redirecting a PUT/PATCH/DELETE request, we need to change the 101 | * we must use a 303 status code instead of a 302 to force 102 | * the browser to use a GET request after redirecting. 103 | * 104 | * See https://inertiajs.com/redirects 105 | */ 106 | const method = request.method() 107 | if (response.getStatus() === 302 && ['PUT', 'PATCH', 'DELETE'].includes(method)) { 108 | response.status(303) 109 | } 110 | 111 | /** 112 | * Handle version change 113 | * 114 | * See https://inertiajs.com/the-protocol#asset-versioning 115 | */ 116 | const version = this.config.versionCache.getVersion().toString() 117 | if (method === 'GET' && request.header(InertiaHeaders.Version, '') !== version) { 118 | response.removeHeader(InertiaHeaders.Inertia) 119 | response.header(InertiaHeaders.Location, request.url()) 120 | response.status(409) 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/plugins/edge/plugin.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { encode } from 'html-entities' 11 | import type { PluginFn } from 'edge.js/types' 12 | 13 | import debug from '../../debug.js' 14 | import { inertiaHeadTag, inertiaTag } from './tags.js' 15 | 16 | /** 17 | * Register the Inertia tags and globals within Edge 18 | */ 19 | export const edgePluginInertia: () => PluginFn = () => { 20 | return (edge) => { 21 | debug('sharing globals and inertia tags with edge') 22 | 23 | /** 24 | * Register the `inertia` global used by the `@inertia` tag 25 | */ 26 | edge.global( 27 | 'inertia', 28 | (page: Record = {}, attributes: Record = {}) => { 29 | if (page.ssrBody) return page.ssrBody 30 | 31 | const className = attributes?.class ? ` class="${attributes.class}"` : '' 32 | const id = attributes?.id ? ` id="${attributes.id}"` : ' id="app"' 33 | const tag = attributes?.as || 'div' 34 | const dataPage = encode(JSON.stringify(page)) 35 | 36 | return `<${tag}${id}${className} data-page="${dataPage}">` 37 | } 38 | ) 39 | 40 | /** 41 | * Register the `inertiaHead` global used by the `@inertiaHead` tag 42 | */ 43 | edge.global('inertiaHead', (page: Record) => { 44 | const { ssrHead = [] }: { ssrHead?: string[] } = page || {} 45 | return ssrHead.join('\n') 46 | }) 47 | 48 | /** 49 | * Register tags 50 | */ 51 | edge.registerTag(inertiaHeadTag) 52 | edge.registerTag(inertiaTag) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/plugins/edge/tags.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { EdgeError } from 'edge-error' 11 | import { TagContract } from 'edge.js/types' 12 | 13 | import { isSubsetOf } from './utils.js' 14 | 15 | /** 16 | * `@inertia` tag is used to generate the root element with 17 | * encoded page data. 18 | * 19 | * We can pass an object with `as`, `class` and `id` properties 20 | * - `as` is the tag name for the root element. Defaults to `div` 21 | * - `class` is the class name for the root element. 22 | * - `id` is the id for the root element. Defaults to `app` 23 | */ 24 | export const inertiaTag: TagContract = { 25 | block: false, 26 | tagName: 'inertia', 27 | seekable: true, 28 | compile(parser, buffer, { filename, loc, properties }) { 29 | /** 30 | * Handle case where no arguments are passed to the tag 31 | */ 32 | if (properties.jsArg.trim() === '') { 33 | buffer.writeExpression(`out += state.inertia(state.page)`, filename, loc.start.line) 34 | return 35 | } 36 | 37 | /** 38 | * Get AST for the arguments and ensure it is a valid object expression 39 | */ 40 | properties.jsArg = `(${properties.jsArg})` 41 | const parsed = parser.utils.transformAst( 42 | parser.utils.generateAST(properties.jsArg, loc, filename), 43 | filename, 44 | parser 45 | ) 46 | 47 | isSubsetOf(parsed, ['ObjectExpression'], () => { 48 | const { line, col } = parser.utils.getExpressionLoc(parsed) 49 | 50 | throw new EdgeError( 51 | `"${properties.jsArg}" is not a valid argument for @inertia`, 52 | 'E_UNALLOWED_EXPRESSION', 53 | { line, col, filename } 54 | ) 55 | }) 56 | 57 | /** 58 | * Stringify the object expression and pass it to the `inertia` helper 59 | */ 60 | const attributes = parser.utils.stringify(parsed) 61 | buffer.writeExpression( 62 | `out += state.inertia(state.page, ${attributes})`, 63 | filename, 64 | loc.start.line 65 | ) 66 | }, 67 | } 68 | 69 | /** 70 | * `@inertiaHead` tag 71 | */ 72 | export const inertiaHeadTag: TagContract = { 73 | block: false, 74 | tagName: 'inertiaHead', 75 | seekable: false, 76 | compile(_, buffer, { filename, loc }) { 77 | buffer.writeExpression(`out += state.inertiaHead(state.page)`, filename, loc.start.line) 78 | }, 79 | } 80 | -------------------------------------------------------------------------------- /src/plugins/edge/utils.ts: -------------------------------------------------------------------------------- 1 | import { expressions as expressionsList } from 'edge-parser' 2 | 3 | type ExpressionList = readonly (keyof typeof expressionsList | 'ObjectPattern' | 'ArrayPattern')[] 4 | 5 | /** 6 | * Validates the expression type to be part of the allowed 7 | * expressions only. 8 | * 9 | * The filename is required to report errors. 10 | * 11 | * ```js 12 | * isNotSubsetOf(expression, ['Literal', 'Identifier'], () => {}) 13 | * ``` 14 | */ 15 | export function isSubsetOf( 16 | expression: any, 17 | expressions: ExpressionList, 18 | errorCallback: () => void 19 | ) { 20 | if (!expressions.includes(expression.type)) { 21 | errorCallback() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/plugins/japa/api_client.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { configProvider } from '@adonisjs/core' 11 | import { RuntimeException } from '@poppinss/utils' 12 | import type { PluginFn } from '@japa/runner/types' 13 | import { ApiRequest, ApiResponse } from '@japa/api-client' 14 | import type { ApplicationService } from '@adonisjs/core/types' 15 | 16 | import type { PageProps, ResolvedConfig } from '../../types.js' 17 | 18 | declare module '@japa/api-client' { 19 | export interface ApiRequest { 20 | /** 21 | * Set `X-Inertia` header on the request 22 | */ 23 | withInertia(): this 24 | 25 | /** 26 | * Set `X-Inertia-Partial-Data` and `X-Inertia-Partial-Component` headers on the request 27 | */ 28 | withInertiaPartialReload(component: string, data: string[]): this 29 | } 30 | 31 | export interface ApiResponse { 32 | /** 33 | * The inertia component 34 | */ 35 | inertiaComponent?: string 36 | 37 | /** 38 | * The inertia response props 39 | */ 40 | inertiaProps: Record 41 | 42 | /** 43 | * Assert component name of inertia response 44 | */ 45 | assertInertiaComponent(component: string): this 46 | 47 | /** 48 | * Assert props to be exactly the same as the given props 49 | */ 50 | assertInertiaProps(props: PageProps): this 51 | 52 | /** 53 | * Assert inertia props contains a subset of the given props 54 | */ 55 | assertInertiaPropsContains(props: PageProps): this 56 | } 57 | } 58 | 59 | /** 60 | * Ensure the response is an inertia response, otherwise throw an error 61 | */ 62 | function ensureIsInertiaResponse(this: ApiResponse) { 63 | if (!this.header('x-inertia')) { 64 | throw new Error( 65 | 'Response is not an Inertia response. Make sure to call `withInertia()` on the request' 66 | ) 67 | } 68 | } 69 | 70 | export function inertiaApiClient(app: ApplicationService): PluginFn { 71 | return async () => { 72 | const inertiaConfigProvider = app.config.get('inertia') 73 | const config = await configProvider.resolve(app, inertiaConfigProvider) 74 | if (!config) { 75 | throw new RuntimeException( 76 | 'Invalid "config/inertia.ts" file. Make sure you are using the "defineConfig" method' 77 | ) 78 | } 79 | 80 | ApiRequest.macro('withInertia', function (this: ApiRequest) { 81 | this.header('x-inertia', 'true') 82 | this.header('x-inertia-version', config.versionCache.getVersion().toString()) 83 | return this 84 | }) 85 | 86 | ApiRequest.macro( 87 | 'withInertiaPartialReload', 88 | function (this: ApiRequest, component: string, data: string[]) { 89 | this.withInertia() 90 | this.header('X-Inertia-Partial-Data', data.join(',')) 91 | this.header('X-Inertia-Partial-Component', component) 92 | return this 93 | } 94 | ) 95 | 96 | /** 97 | * Response getters 98 | */ 99 | ApiResponse.getter('inertiaComponent', function (this: ApiResponse) { 100 | ensureIsInertiaResponse.call(this) 101 | return this.body().component 102 | }) 103 | 104 | ApiResponse.getter('inertiaProps', function (this: ApiResponse) { 105 | ensureIsInertiaResponse.call(this) 106 | return this.body().props 107 | }) 108 | 109 | /** 110 | * Response assertions 111 | */ 112 | ApiResponse.macro('assertInertiaComponent', function (this: ApiResponse, component: string) { 113 | ensureIsInertiaResponse.call(this) 114 | 115 | this.assert!.deepEqual(this.body().component, component) 116 | return this 117 | }) 118 | 119 | ApiResponse.macro( 120 | 'assertInertiaProps', 121 | function (this: ApiResponse, props: Record) { 122 | if (!this.assert) { 123 | throw new Error( 124 | 'Response assertions are not available. Make sure to install the @japa/assert plugin' 125 | ) 126 | } 127 | ensureIsInertiaResponse.call(this) 128 | this.assert.deepEqual(this.body().props, props) 129 | return this 130 | } 131 | ) 132 | 133 | ApiResponse.macro( 134 | 'assertInertiaPropsContains', 135 | function (this: ApiResponse, props: Record) { 136 | if (!this.assert) { 137 | throw new Error( 138 | 'Response assertions are not available. Make sure to install the @japa/assert plugin' 139 | ) 140 | } 141 | ensureIsInertiaResponse.call(this) 142 | this.assert.containsSubset(this.body().props, props) 143 | return this 144 | } 145 | ) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/plugins/vite.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | /// 11 | 12 | import type { PluginOption } from 'vite' 13 | 14 | export type InertiaPluginOptions = { 15 | ssr?: 16 | | { 17 | /** 18 | * Whether or not to enable server-side rendering 19 | */ 20 | enabled: true 21 | 22 | /** 23 | * The entrypoint for the server-side rendering 24 | */ 25 | entrypoint: string 26 | 27 | /** 28 | * The output directory for the server-side rendering bundle 29 | */ 30 | output?: string 31 | } 32 | | { enabled: false } 33 | } 34 | 35 | /** 36 | * Inertia plugin for Vite that is tailored for AdonisJS 37 | */ 38 | export default function inertia(options?: InertiaPluginOptions): PluginOption { 39 | return { 40 | name: 'vite-plugin-inertia', 41 | config: (_, { command }) => { 42 | if (!options?.ssr?.enabled) return {} 43 | 44 | /** 45 | * We need to set the `NODE_ENV` to production when building 46 | * front-end assets. Otherwise, some libraries may behave 47 | * differently. 48 | * 49 | * For example `react` will use a `jsxDev` function 50 | * that is not available in production. 51 | * See https://github.com/remix-run/remix/issues/4081 52 | */ 53 | if (command === 'build') { 54 | process.env.NODE_ENV = 'production' 55 | } 56 | 57 | return { 58 | buildSteps: [ 59 | { 60 | name: 'build-client', 61 | description: 'build inertia client bundle', 62 | config: { build: { outDir: 'build/public/assets/' } }, 63 | }, 64 | { 65 | name: 'build-ssr', 66 | description: 'build inertia server bundle', 67 | config: { 68 | build: { 69 | ssr: true, 70 | outDir: options.ssr.output || 'build/ssr', 71 | rollupOptions: { input: options.ssr.entrypoint }, 72 | }, 73 | }, 74 | }, 75 | ], 76 | } 77 | }, 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/props.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { MaybePromise } from './types.js' 11 | 12 | export const ignoreFirstLoadSymbol = Symbol('ignoreFirstLoad') 13 | 14 | /** 15 | * Base class for Mergeable props 16 | */ 17 | export abstract class MergeableProp { 18 | public shouldMerge = false 19 | 20 | public merge() { 21 | this.shouldMerge = true 22 | return this 23 | } 24 | } 25 | 26 | /** 27 | * Optional prop 28 | */ 29 | export class OptionalProp> { 30 | [ignoreFirstLoadSymbol] = true 31 | 32 | constructor(public callback: T) {} 33 | } 34 | 35 | /** 36 | * Defer prop 37 | */ 38 | export class DeferProp> extends MergeableProp { 39 | [ignoreFirstLoadSymbol] = true as const 40 | 41 | constructor( 42 | public callback: T, 43 | private group: string 44 | ) { 45 | super() 46 | } 47 | 48 | public getGroup() { 49 | return this.group 50 | } 51 | } 52 | 53 | /** 54 | * Merge prop 55 | */ 56 | export class MergeProp> extends MergeableProp { 57 | constructor(public callback: T) { 58 | super() 59 | this.shouldMerge = true 60 | } 61 | } 62 | 63 | /** 64 | * Always prop 65 | */ 66 | export class AlwaysProp> extends MergeableProp { 67 | constructor(public callback: T) { 68 | super() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/server_renderer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { Vite } from '@adonisjs/vite' 11 | 12 | import { pathToFileURL } from 'node:url' 13 | import type { PageObject, RenderInertiaSsrApp, ResolvedConfig } from './types.js' 14 | import type { ModuleRunner } from 'vite/module-runner' 15 | 16 | /** 17 | * Responsible for rendering page on the server 18 | * 19 | * - In development, we use the Vite Runtime API 20 | * - In production, we just import and use the SSR 21 | * bundle generated by Vite 22 | */ 23 | export class ServerRenderer { 24 | static runtime: ModuleRunner 25 | 26 | constructor( 27 | protected config: ResolvedConfig, 28 | protected vite?: Vite 29 | ) {} 30 | 31 | /** 32 | * Render the page on the server 33 | * 34 | * On development, we use the Vite Runtime API 35 | * On production, we just import and use the SSR bundle generated by Vite 36 | */ 37 | async render(pageObject: PageObject) { 38 | let render: { default: RenderInertiaSsrApp } 39 | const devServer = this.vite?.getDevServer() 40 | 41 | /** 42 | * Use the Vite Runtime API to execute the entrypoint 43 | * if we are in development mode 44 | */ 45 | if (devServer) { 46 | ServerRenderer.runtime ??= await this.vite!.createModuleRunner() 47 | ServerRenderer.runtime.clearCache() 48 | render = await ServerRenderer.runtime.import(this.config.ssr.entrypoint!) 49 | } else { 50 | /** 51 | * Otherwise, just import the SSR bundle 52 | */ 53 | render = await import(pathToFileURL(this.config.ssr.bundle).href) 54 | } 55 | 56 | /** 57 | * Call the render function and return head and body 58 | */ 59 | const result = await render.default(pageObject) 60 | return { head: result.head, body: result.body } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { ConfigProvider } from '@adonisjs/core/types' 11 | import type { HttpContext } from '@adonisjs/core/http' 12 | import type { Serialize, Simplify } from '@tuyau/utils/types' 13 | 14 | import type { VersionCache } from './version_cache.js' 15 | import { DeferProp, OptionalProp } from './props.js' 16 | 17 | export type MaybePromise = T | Promise 18 | 19 | /** 20 | * Props that will be passed to inertia render method 21 | */ 22 | export type PageProps = Record 23 | 24 | /** 25 | * Shared data types 26 | */ 27 | export type Data = string | number | object | boolean 28 | export type SharedDatumFactory = (ctx: HttpContext) => MaybePromise 29 | export type SharedData = Record 30 | 31 | /** 32 | * Allowed values for the assets version 33 | */ 34 | export type AssetsVersion = string | number | undefined 35 | 36 | export interface InertiaConfig { 37 | /** 38 | * Path to the Edge view that will be used as the root view for Inertia responses. 39 | * @default root (resources/views/inertia_layout.edge) 40 | */ 41 | rootView?: string | ((ctx: HttpContext) => string) 42 | 43 | /** 44 | * Path to your client-side entrypoint file. 45 | */ 46 | entrypoint?: string 47 | 48 | /** 49 | * The version of your assets. Every client request will be checked against this version. 50 | * If the version is not the same, the client will do a full reload. 51 | */ 52 | assetsVersion?: AssetsVersion 53 | 54 | /** 55 | * Data that should be shared with all rendered pages 56 | */ 57 | sharedData?: T 58 | 59 | /** 60 | * History encryption 61 | * 62 | * See https://v2.inertiajs.com/history-encryption 63 | */ 64 | history?: { 65 | encrypt?: boolean 66 | } 67 | 68 | /** 69 | * Options to configure SSR 70 | */ 71 | ssr?: { 72 | /** 73 | * Enable or disable SSR 74 | */ 75 | enabled: boolean 76 | 77 | /** 78 | * List of components that should be rendered on the server 79 | */ 80 | pages?: string[] | ((ctx: HttpContext, page: string) => MaybePromise) 81 | 82 | /** 83 | * Path to the SSR entrypoint file 84 | */ 85 | entrypoint?: string 86 | 87 | /** 88 | * Path to the SSR bundled file that will be used in production 89 | */ 90 | bundle?: string 91 | } 92 | } 93 | 94 | /** 95 | * Resolved inertia configuration 96 | */ 97 | export interface ResolvedConfig { 98 | rootView: string | ((ctx: HttpContext) => string) 99 | versionCache: VersionCache 100 | sharedData: T 101 | history: { encrypt: boolean } 102 | ssr: { 103 | enabled: boolean 104 | entrypoint: string 105 | pages?: string[] | ((ctx: HttpContext, page: string) => MaybePromise) 106 | bundle: string 107 | } 108 | } 109 | 110 | export interface PageObject { 111 | ssrHead?: string 112 | ssrBody?: string 113 | 114 | /** 115 | * The name of the JavaScript page component. 116 | */ 117 | component: string 118 | 119 | /** 120 | * The current asset version. 121 | */ 122 | version: string | number 123 | 124 | /** 125 | * The page props (data). 126 | */ 127 | props: TPageProps 128 | 129 | /** 130 | * The page URL. 131 | */ 132 | url: string 133 | 134 | /** 135 | * List of deferred props that will be loaded with subsequent requests 136 | */ 137 | deferredProps?: Record 138 | 139 | /** 140 | * List of mergeable props that will be merged with subsequent requests 141 | */ 142 | mergeProps?: string[] 143 | 144 | /** 145 | * Whether or not to encrypt the current page's history state. 146 | */ 147 | encryptHistory?: boolean 148 | 149 | /** 150 | * Whether or not to clear any encrypted history state. 151 | */ 152 | clearHistory?: boolean 153 | } 154 | 155 | type IsOptionalProp = 156 | T extends OptionalProp ? true : T extends DeferProp ? true : false 157 | 158 | type InferProps = { 159 | // First extract and unwrap lazy props. Also make them optional as they are lazy 160 | [K in keyof T as IsOptionalProp extends true ? K : never]+?: T[K] extends { 161 | callback: () => MaybePromise 162 | } 163 | ? U 164 | : T[K] 165 | } & { 166 | // Then include all other props as it is 167 | [K in keyof T as IsOptionalProp extends true ? never : K]: T[K] extends { 168 | callback: () => MaybePromise 169 | } 170 | ? U 171 | : T[K] extends () => MaybePromise // Unwrap "callback" props like inertia.render('foo', { lazy: () => 'foo' }) 172 | ? U 173 | : T[K] 174 | } 175 | 176 | type ReturnsTypesSharedData = {} extends T 177 | ? {} 178 | : InferProps<{ 179 | [K in keyof T]: T[K] extends (...args: any[]) => MaybePromise ? U : T[K] 180 | }> 181 | 182 | /** 183 | * Infer shared data types from the config provider 184 | */ 185 | export type InferSharedProps> = ReturnsTypesSharedData< 186 | Awaited>['sharedData'] 187 | > 188 | 189 | /** 190 | * The shared props inferred from the user config user-land. 191 | * Should be module augmented by the user 192 | */ 193 | export interface SharedProps {} 194 | 195 | /** 196 | * Helper for infering the page props from a Controller method that returns 197 | * inertia.render 198 | * 199 | * InferPageProps will also include the shared props 200 | * 201 | * ```ts 202 | * // Your Adonis Controller 203 | * class MyController { 204 | * index() { 205 | * return inertia.render('foo', { foo: 1 }) 206 | * } 207 | * } 208 | * 209 | * // Your React component 210 | * export default MyReactComponent(props: InferPageProps) { 211 | * } 212 | * ``` 213 | */ 214 | export type InferPageProps< 215 | Controller, 216 | Method extends keyof Controller, 217 | > = Controller[Method] extends (...args: any[]) => any 218 | ? Simplify< 219 | Serialize< 220 | InferProps>, PageObject>['props']> & 221 | SharedProps 222 | > 223 | > 224 | : never 225 | 226 | /** 227 | * Signature for the method in the SSR entrypoint file 228 | */ 229 | export type RenderInertiaSsrApp = (page: PageObject) => Promise<{ head: string[]; body: string }> 230 | -------------------------------------------------------------------------------- /src/version_cache.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { createHash } from 'node:crypto' 11 | import { readFile } from 'node:fs/promises' 12 | import type { AssetsVersion } from './types.js' 13 | 14 | /** 15 | * VersionCache is used to cache the version of the assets. 16 | * 17 | * If the user has provided a version, it will be used. 18 | * Otherwise, we will compute a hash from the manifest file 19 | * and cache it. 20 | */ 21 | export class VersionCache { 22 | #cachedVersion?: AssetsVersion 23 | 24 | constructor( 25 | protected appRoot: URL, 26 | protected assetsVersion?: AssetsVersion 27 | ) { 28 | this.#cachedVersion = assetsVersion 29 | } 30 | 31 | /** 32 | * Compute the hash of the manifest file and cache it 33 | */ 34 | async #getManifestHash(): Promise { 35 | try { 36 | const manifestPath = new URL('public/assets/.vite/manifest.json', this.appRoot) 37 | const manifestFile = await readFile(manifestPath, 'utf-8') 38 | this.#cachedVersion = createHash('md5').update(manifestFile).digest('hex') 39 | 40 | return this.#cachedVersion 41 | } catch { 42 | /** 43 | * If the manifest file does not exist, it probably means that we are in 44 | * development mode 45 | */ 46 | this.#cachedVersion = '1' 47 | return this.#cachedVersion 48 | } 49 | } 50 | 51 | /** 52 | * Pre-compute the version 53 | */ 54 | async computeVersion() { 55 | if (!this.assetsVersion) await this.#getManifestHash() 56 | return this 57 | } 58 | 59 | /** 60 | * Returns the current assets version 61 | */ 62 | getVersion() { 63 | if (!this.#cachedVersion) throw new Error('Version has not been computed yet') 64 | return this.#cachedVersion 65 | } 66 | 67 | /** 68 | * Set the assets version 69 | */ 70 | async setVersion(version: AssetsVersion) { 71 | this.#cachedVersion = version 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /stubs/app.css.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/css/app.css') }) 3 | }}} 4 | * { 5 | margin: 0; 6 | padding: 0; 7 | } 8 | 9 | html, 10 | body { 11 | height: 100%; 12 | width: 100%; 13 | } 14 | -------------------------------------------------------------------------------- /stubs/config.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.configPath('inertia.ts') }) 3 | }}} 4 | import { defineConfig } from '@adonisjs/inertia' 5 | import type { InferSharedProps } from '@adonisjs/inertia/types' 6 | 7 | const inertiaConfig = defineConfig({ 8 | /** 9 | * Path to the Edge view that will be used as the root view for Inertia responses 10 | */ 11 | rootView: 'inertia_layout', 12 | 13 | /** 14 | * Data that should be shared with all rendered pages 15 | */ 16 | sharedData: { 17 | // user: (ctx) => ctx.inertia.always(() => ctx.auth.user), 18 | }, 19 | 20 | /** 21 | * Options for the server-side rendering 22 | */ 23 | ssr: { 24 | enabled: {{ ssr }}, 25 | entrypoint: '{{ ssrEntrypoint }}' 26 | } 27 | }) 28 | 29 | export default inertiaConfig 30 | 31 | declare module '@adonisjs/inertia/types' { 32 | export interface SharedProps extends InferSharedProps {} 33 | } 34 | -------------------------------------------------------------------------------- /stubs/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { getDirname } from '@poppinss/utils' 11 | 12 | export const stubsRoot = getDirname(import.meta.url) 13 | -------------------------------------------------------------------------------- /stubs/react/app.tsx.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/app/app.tsx') }) 3 | }}} 4 | /// 5 | /// 6 | 7 | import '../css/app.css'; 8 | 9 | {{#if ssr}} 10 | import { hydrateRoot } from 'react-dom/client' 11 | {{#else}} 12 | import { createRoot } from 'react-dom/client'; 13 | {{/if}} 14 | import { createInertiaApp } from '@inertiajs/react'; 15 | import { resolvePageComponent } from '@adonisjs/inertia/helpers' 16 | 17 | const appName = import.meta.env.VITE_APP_NAME || 'AdonisJS' 18 | 19 | createInertiaApp({ 20 | progress: { color: '#5468FF' }, 21 | 22 | title: (title) => {{ '`${title} - ${appName}`' }}, 23 | 24 | resolve: (name) => { 25 | return resolvePageComponent( 26 | {{ '`../pages/${name}.tsx`' }}, 27 | import.meta.glob('../pages/**/*.tsx'), 28 | ) 29 | }, 30 | 31 | setup({ el, App, props }) { 32 | {{#if ssr}} 33 | hydrateRoot(el, ) 34 | {{#else}} 35 | createRoot(el).render(); 36 | {{/if}} 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /stubs/react/errors/not_found.tsx.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/pages/errors/not_found.tsx') }) 3 | }}} 4 | export default function NotFound() { 5 | return ( 6 | <> 7 |
8 |
Page not found
9 | 10 | This page does not exist. 11 |
12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /stubs/react/errors/server_error.tsx.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/pages/errors/server_error.tsx') }) 3 | }}} 4 | export default function ServerError(props: { error: any }) { 5 | return ( 6 | <> 7 |
8 |
Server Error
9 | 10 | {props.error.message} 11 |
12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /stubs/react/root.edge.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('resources/views/inertia_layout.edge') }) 3 | }}} 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | AdonisJS x Inertia x React 12 | 13 | 14 | 15 | 16 | 32 | 33 | 34 | 35 | 65 | 66 | @stack('dumper') 67 | @viteReactRefresh() 68 | @inertiaHead() 69 | {{ "@vite(['inertia/app/app.tsx', `inertia/pages/${page.component}.tsx`])" }} 70 | 71 | 72 | 73 | @inertia() 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /stubs/react/ssr.tsx.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/app/ssr.tsx') }) 3 | }}} 4 | import ReactDOMServer from 'react-dom/server' 5 | import { createInertiaApp } from '@inertiajs/react' 6 | 7 | export default function render(page: any) { 8 | return createInertiaApp({ 9 | page, 10 | render: ReactDOMServer.renderToString, 11 | resolve: (name) => { 12 | const pages = import.meta.glob('../pages/**/*.tsx', { eager: true }) 13 | {{ 'return pages[`../pages/${name}.tsx`]' }} 14 | }, 15 | setup: ({ App, props }) => , 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /stubs/react/tsconfig.json.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/tsconfig.json') }) 3 | }}} 4 | { 5 | "extends": "@adonisjs/tsconfig/tsconfig.client.json", 6 | "compilerOptions": { 7 | "baseUrl": ".", 8 | "module": "ESNext", 9 | "jsx": "react-jsx", 10 | "paths": { 11 | "~/*": ["./*"], 12 | }, 13 | }, 14 | "include": ["./**/*.ts", "./**/*.tsx"], 15 | } 16 | -------------------------------------------------------------------------------- /stubs/solid/app.tsx.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/app/app.tsx') }) 3 | }}} 4 | /// 5 | /// 6 | 7 | import '../css/app.css' 8 | 9 | {{#if ssr}} 10 | import { hydrate } from 'solid-js/web'; 11 | {{#else}} 12 | import { render } from 'solid-js/web' 13 | {{/if}} 14 | import { createInertiaApp } from 'inertia-adapter-solid' 15 | import { resolvePageComponent } from '@adonisjs/inertia/helpers' 16 | 17 | const appName = import.meta.env.VITE_APP_NAME || 'AdonisJS' 18 | 19 | createInertiaApp({ 20 | progress: { color: '#5468FF' }, 21 | 22 | title: (title) => {{ '`${title} - ${appName}`' }}, 23 | 24 | resolve: (name) => { 25 | return resolvePageComponent( 26 | {{ '`../pages/${name}.tsx`' }}, 27 | import.meta.glob('../pages/**/*.tsx'), 28 | ) 29 | }, 30 | 31 | setup({ el, App, props }) { 32 | {{#if ssr}} 33 | hydrate(() => , el) 34 | {{#else}} 35 | render(() => , el) 36 | {{/if}} 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /stubs/solid/errors/not_found.tsx.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/pages/errors/not_found.tsx') }) 3 | }}} 4 | export default function NotFound() { 5 | return ( 6 | <> 7 |
8 |
Page not found
9 | 10 | This page does not exist. 11 |
12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /stubs/solid/errors/server_error.tsx.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/pages/errors/server_error.tsx') }) 3 | }}} 4 | export default function ServerError(props: { error: any }) { 5 | return ( 6 | <> 7 |
8 |
Server Error
9 | 10 | {props.error.message} 11 |
12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /stubs/solid/root.edge.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('resources/views/inertia_layout.edge') }) 3 | }}} 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 30 | 31 | 32 | 33 | 63 | 64 | @stack('dumper') 65 | @inertiaHead() 66 | {{ "@vite(['inertia/app/app.tsx', `inertia/pages/${page.component}.tsx`])" }} 67 | 68 | 69 | 70 | @inertia() 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /stubs/solid/ssr.tsx.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/app/ssr.tsx') }) 3 | }}} 4 | 5 | import { hydrate } from 'solid-js/web' 6 | import { createInertiaApp } from 'inertia-adapter-solid' 7 | 8 | export default function render(page: any) { 9 | return createInertiaApp({ 10 | page, 11 | resolve: (name) => { 12 | const pages = import.meta.glob('../pages/**/*.tsx', { eager: true }) 13 | {{ 'return pages[`../pages/${name}.tsx`]' }} 14 | }, 15 | setup({ el, App, props }) { 16 | hydrate(() => , el) 17 | }, 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /stubs/solid/tsconfig.json.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/tsconfig.json') }) 3 | }}} 4 | { 5 | "extends": "@adonisjs/tsconfig/tsconfig.client.json", 6 | "compilerOptions": { 7 | "baseUrl": ".", 8 | "jsx": "preserve", 9 | "module": "ESNext", 10 | "jsxImportSource": "solid-js", 11 | "paths": { 12 | "~/*": ["./*"], 13 | }, 14 | }, 15 | "include": ["./**/*.ts", "./**/*.tsx"], 16 | } 17 | -------------------------------------------------------------------------------- /stubs/svelte/app.ts.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/app/app.ts') }) 3 | }}} 4 | /// 5 | /// 6 | 7 | import '../css/app.css'; 8 | 9 | import { createInertiaApp, type ResolvedComponent } from '@inertiajs/svelte' 10 | import { resolvePageComponent } from '@adonisjs/inertia/helpers' 11 | import { hydrate, mount } from 'svelte' 12 | 13 | createInertiaApp({ 14 | progress: { color: '#5468FF' }, 15 | 16 | resolve: (name) => { 17 | return resolvePageComponent( 18 | {{ '`../pages/${name}.svelte`' }}, 19 | import.meta.glob('../pages/**/*.svelte'), 20 | ) 21 | }, 22 | 23 | setup({ el, App, props }) { 24 | if (!el) throw new Error('Missing root element. Make sure to add a div#app to your page') 25 | 26 | if (el.dataset.serverRendered === 'true') { 27 | hydrate(App, { target: el, props }) 28 | } else { 29 | mount(App, { target: el, props }) 30 | } 31 | }, 32 | }) 33 | -------------------------------------------------------------------------------- /stubs/svelte/errors/not_found.svelte.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/pages/errors/not_found.svelte') }) 3 | }}} 4 |
5 |
6 |
Page not found
7 | 8 | This page does not exist. 9 |
10 |
11 | -------------------------------------------------------------------------------- /stubs/svelte/errors/server_error.svelte.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/pages/errors/server_error.svelte') }) 3 | }}} 4 | 7 | 8 |
9 |
10 |
Server Error
11 | 12 | {error.message} 13 |
14 |
15 | -------------------------------------------------------------------------------- /stubs/svelte/home.svelte.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/pages/home.svelte') }) 3 | }}} 4 | 5 | Homepage 6 | 7 | 8 |
9 | 10 |
11 | 12 | 23 | 24 | 25 |
26 |
27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 46 | 47 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 |
65 |
66 | 67 |
68 |
69 | 70 | 74 | 75 |
76 | 77 |
78 |

79 | 80 | Documentation 81 | 82 | 83 |

84 | 85 |

86 | Dive into the official documentation to learn AdonisJS. Read carefully to discover 87 | an unmatched set of features, best practices and developer experience. Through 88 | examples, guides and API references, you'll find everything you need to build your 89 | next project. From installation to deployment, we've got you covered. 90 |

91 |
92 |
93 |
94 | 95 |
96 |
97 | 98 | 102 | 103 |
104 | 105 |
106 |

107 | 108 | Adocasts 109 | 110 | 111 |

112 | 113 |

114 | Level up your development and Adonis skills with hours of video content, from 115 | beginner to advanced, through databases, testing, and more. 116 |

117 |
118 |
119 | 120 |
121 |
122 | 123 | 127 | 128 |
129 | 130 |
131 |

132 | 133 | Packages 134 | 135 | 136 |

137 | 138 |

139 | Supercharge your AdonisJS application with packages built and maintained by both the 140 | core team and the community. 141 |

142 |
143 |
144 | 145 |
146 |
147 | 148 | 152 | 153 |
154 | 155 |
156 |

157 | 158 | Discord 159 | 160 | 161 |

162 | 163 |

164 | Never get lost again, ask questions, and share your knowledge or projects with a 165 | growing and supportive community. Join us. 166 |

167 |
168 |
169 |
170 | 171 | 172 |
173 |
174 | 216 | 217 |
218 |

219 | 220 | 221 | 222 | 230 | 231 | 232 | Vine 233 | 234 | 235 |

236 | 237 |

238 | A yet simple but feature rich and type-safe form data validation. It comes with 50+ 239 | built-in rules and an expressive API to define custom rules. 240 |

241 | 242 | 246 | 254 | 255 |
256 | 257 | 296 | 297 | 332 |
333 |
334 | 335 |
336 | Route for this page is registered in start/routes.ts file, rendering 337 | inertia/pages/home.svelte template 338 |
339 |
340 | -------------------------------------------------------------------------------- /stubs/svelte/root.edge.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('resources/views/inertia_layout.edge') }) 3 | }}} 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | AdonisJS x Inertia x Svelte 12 | 13 | 14 | 15 | 16 | 32 | 33 | 34 | 35 | 65 | 66 | {{ "@vite(['inertia/app/app.ts', `inertia/pages/${page.component}.svelte`])" }} 67 | @inertiaHead() 68 | @stack('dumper') 69 | 70 | 71 | 72 | @inertia() 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /stubs/svelte/ssr.ts.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/app/ssr.ts') }) 3 | }}} 4 | 5 | import { createInertiaApp, type ResolvedComponent } from '@inertiajs/svelte' 6 | import { render as svelteRender } from 'svelte/server' 7 | 8 | export default function render(page: any) { 9 | return createInertiaApp({ 10 | page, 11 | resolve: (name) => { 12 | const pages = import.meta.glob('../pages/**/*.svelte', { eager: true }) 13 | {{ 'return pages[`../pages/${name}.svelte`]' }} 14 | }, 15 | setup({ App, props }) { 16 | return svelteRender(App, { props }) 17 | }, 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /stubs/svelte/tsconfig.json.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/tsconfig.json') }) 3 | }}} 4 | { 5 | "extends": "@adonisjs/tsconfig/tsconfig.client.json", 6 | "compilerOptions": { 7 | "baseUrl": ".", 8 | "module": "ESNext", 9 | "paths": { 10 | "~/*": ["./*"], 11 | }, 12 | }, 13 | "include": ["./**/*.ts", "./**/*.svelte"], 14 | } 15 | -------------------------------------------------------------------------------- /stubs/vue/app.ts.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/app/app.ts') }) 3 | }}} 4 | /// 5 | /// 6 | 7 | import '../css/app.css'; 8 | 9 | {{#if ssr}} 10 | import { createSSRApp, h } from 'vue' 11 | {{#else}} 12 | import { createApp, h } from 'vue' 13 | {{/if}} 14 | import type { DefineComponent } from 'vue' 15 | import { createInertiaApp } from '@inertiajs/vue3' 16 | import { resolvePageComponent } from '@adonisjs/inertia/helpers' 17 | 18 | const appName = import.meta.env.VITE_APP_NAME || 'AdonisJS' 19 | 20 | createInertiaApp({ 21 | progress: { color: '#5468FF' }, 22 | 23 | title: (title) => {{ '`${title} - ${appName}`' }}, 24 | 25 | resolve: (name) => { 26 | return resolvePageComponent( 27 | {{ '`../pages/${name}.vue`' }}, 28 | import.meta.glob('../pages/**/*.vue'), 29 | ) 30 | }, 31 | 32 | setup({ el, App, props, plugin }) { 33 | {{#if ssr}} 34 | createSSRApp({ render: () => h(App, props) }) 35 | {{#else}} 36 | createApp({ render: () => h(App, props) }) 37 | {{/if}} 38 | .use(plugin) 39 | .mount(el) 40 | }, 41 | }) 42 | -------------------------------------------------------------------------------- /stubs/vue/errors/not_found.vue.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/pages/errors/not_found.vue') }) 3 | }}} 4 | 11 | -------------------------------------------------------------------------------- /stubs/vue/errors/server_error.vue.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/pages/errors/server_error.vue') }) 3 | }}} 4 | 7 | 8 | 15 | -------------------------------------------------------------------------------- /stubs/vue/home.vue.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/pages/home.vue') }) 3 | }}} 4 | 7 | 8 | 344 | -------------------------------------------------------------------------------- /stubs/vue/root.edge.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('resources/views/inertia_layout.edge') }) 3 | }}} 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | AdonisJS x Inertia x VueJS 12 | 13 | 14 | 15 | 16 | 32 | 33 | 34 | 35 | 65 | 66 | {{ "@vite(['inertia/app/app.ts', `inertia/pages/${page.component}.vue`])" }} 67 | @inertiaHead() 68 | @stack('dumper') 69 | 70 | 71 | 72 | @inertia() 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /stubs/vue/ssr.ts.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/app/ssr.ts') }) 3 | }}} 4 | 5 | import { createInertiaApp } from '@inertiajs/vue3' 6 | import { renderToString } from '@vue/server-renderer' 7 | import { createSSRApp, h, type DefineComponent } from 'vue' 8 | 9 | export default function render(page: any) { 10 | return createInertiaApp({ 11 | page, 12 | render: renderToString, 13 | resolve: (name) => { 14 | const pages = import.meta.glob('../pages/**/*.vue', { eager: true }) 15 | {{ 'return pages[`../pages/${name}.vue`]' }} 16 | }, 17 | 18 | setup({ App, props, plugin }) { 19 | return createSSRApp({ render: () => h(App, props) }).use(plugin) 20 | }, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /stubs/vue/tsconfig.json.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ to: app.makePath('inertia/tsconfig.json') }) 3 | }}} 4 | { 5 | "extends": "@adonisjs/tsconfig/tsconfig.client.json", 6 | "compilerOptions": { 7 | "baseUrl": ".", 8 | "jsx": "preserve", 9 | "module": "ESNext", 10 | "jsxImportSource": "vue", 11 | "paths": { 12 | "~/*": ["./*"], 13 | }, 14 | }, 15 | "include": ["./**/*.ts", "./**/*.vue"], 16 | } 17 | -------------------------------------------------------------------------------- /tests/configure.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { FileSystem } from '@japa/file-system' 12 | import Configure from '@adonisjs/core/commands/configure' 13 | 14 | import { setupApp } from './helpers.js' 15 | 16 | async function setupFakeAdonisProject(fs: FileSystem) { 17 | await Promise.all([ 18 | fs.create('.env', ''), 19 | fs.createJson('tsconfig.json', {}), 20 | fs.create('adonisrc.ts', `export default defineConfig({})`), 21 | fs.create('vite.config.ts', `export default { plugins: [] }`), 22 | fs.create( 23 | 'start/kernel.ts', 24 | ` 25 | import router from '@adonisjs/core/services/router' 26 | import server from '@adonisjs/core/services/server' 27 | 28 | router.use([ 29 | () => import('@adonisjs/core/bodyparser_middleware'), 30 | ]) 31 | 32 | server.use([]) 33 | ` 34 | ), 35 | ]) 36 | } 37 | 38 | test.group('Configure', (group) => { 39 | group.tap((t) => t.timeout(20_000)) 40 | group.each.setup(async ({ context }) => setupFakeAdonisProject(context.fs)) 41 | 42 | test('add provider, config file, and middleware', async ({ assert }) => { 43 | const { ace } = await setupApp() 44 | 45 | ace.prompt.trap('adapter').replyWith('vue') 46 | ace.prompt.trap('ssr').reject() 47 | ace.prompt.trap('install').reject() 48 | 49 | const command = await ace.create(Configure, ['../../index.js']) 50 | await command.exec() 51 | 52 | await assert.fileExists('config/inertia.ts') 53 | await assert.fileExists('adonisrc.ts') 54 | await assert.fileContains('adonisrc.ts', '@adonisjs/inertia/inertia_provider') 55 | await assert.fileContains('start/kernel.ts', '@adonisjs/inertia/inertia_middleware') 56 | }) 57 | 58 | test('add example route', async ({ assert, fs }) => { 59 | await fs.createJson('tsconfig.json', { compilerOptions: {} }) 60 | await fs.create('start/routes.ts', '') 61 | 62 | const { ace } = await setupApp() 63 | 64 | ace.prompt.trap('adapter').replyWith('vue') 65 | ace.prompt.trap('ssr').reject() 66 | ace.prompt.trap('install').reject() 67 | 68 | const command = await ace.create(Configure, ['../../index.js']) 69 | await command.exec() 70 | 71 | await assert.fileContains('start/routes.ts', `router.on('/'`) 72 | }) 73 | 74 | test('skip example route when flag is passed', async ({ assert, fs }) => { 75 | await fs.createJson('tsconfig.json', { compilerOptions: {} }) 76 | await fs.create('start/routes.ts', '') 77 | 78 | const { ace } = await setupApp() 79 | 80 | ace.prompt.trap('adapter').replyWith('vue') 81 | ace.prompt.trap('ssr').reject() 82 | ace.prompt.trap('install').reject() 83 | 84 | const command = await ace.create(Configure, ['../../index.js', '--skip-example-route']) 85 | await command.exec() 86 | 87 | await assert.fileNotContains('start/routes.ts', `router.on('/'`) 88 | }) 89 | }) 90 | 91 | test.group('Frameworks', (group) => { 92 | group.tap((t) => t.timeout(20_000)) 93 | group.each.setup(async ({ context }) => setupFakeAdonisProject(context.fs)) 94 | 95 | test('vue', async ({ assert, fs }) => { 96 | await fs.createJson('package.json', {}) 97 | await fs.createJson('tsconfig.json', { compilerOptions: {} }) 98 | 99 | const { ace } = await setupApp() 100 | 101 | ace.prompt.trap('adapter').replyWith('vue') 102 | ace.prompt.trap('ssr').reject() 103 | ace.prompt.trap('install').reject() 104 | 105 | const command = await ace.create(Configure, ['../../index.js']) 106 | await command.exec() 107 | 108 | await assert.fileExists('inertia/app/app.ts') 109 | await assert.fileExists('resources/views/inertia_layout.edge') 110 | await assert.fileExists('inertia/tsconfig.json') 111 | await assert.fileExists('inertia/pages/home.vue') 112 | await assert.fileExists('inertia/pages/errors/not_found.vue') 113 | await assert.fileExists('inertia/pages/errors/server_error.vue') 114 | await assert.fileContains('inertia/app/app.ts', 'createApp') 115 | 116 | const viteConfig = await fs.contents('vite.config.ts') 117 | assert.snapshot(viteConfig).matchInline(` 118 | "import inertia from '@adonisjs/inertia/client' 119 | import vue from '@vitejs/plugin-vue' 120 | import adonisjs from '@adonisjs/vite/client' 121 | 122 | export default { plugins: [inertia({ ssr: { enabled: false } }), vue(), adonisjs({ entrypoints: ['inertia/app/app.ts'], reload: ['resources/views/**/*.edge'] })] } 123 | " 124 | `) 125 | }) 126 | 127 | test('React', async ({ assert, fs }) => { 128 | const { ace } = await setupApp() 129 | 130 | ace.prompt.trap('adapter').replyWith('react') 131 | ace.prompt.trap('ssr').reject() 132 | ace.prompt.trap('install').reject() 133 | 134 | const command = await ace.create(Configure, ['../../index.js']) 135 | await command.exec() 136 | 137 | await assert.fileExists('inertia/app/app.tsx') 138 | await assert.fileExists('resources/views/inertia_layout.edge') 139 | await assert.fileExists('inertia/tsconfig.json') 140 | await assert.fileExists('inertia/pages/home.tsx') 141 | await assert.fileExists('inertia/pages/errors/not_found.tsx') 142 | await assert.fileExists('inertia/pages/errors/server_error.tsx') 143 | await assert.fileContains('inertia/app/app.tsx', 'createRoot') 144 | 145 | const viteConfig = await fs.contents('vite.config.ts') 146 | assert.snapshot(viteConfig).matchInline(` 147 | "import inertia from '@adonisjs/inertia/client' 148 | import react from '@vitejs/plugin-react' 149 | import adonisjs from '@adonisjs/vite/client' 150 | 151 | export default { plugins: [inertia({ ssr: { enabled: false } }), react(), adonisjs({ entrypoints: ['inertia/app/app.tsx'], reload: ['resources/views/**/*.edge'] })] } 152 | " 153 | `) 154 | }) 155 | 156 | test('Solid', async ({ assert, fs }) => { 157 | const { ace } = await setupApp() 158 | 159 | ace.prompt.trap('adapter').replyWith('solid') 160 | ace.prompt.trap('ssr').reject() 161 | ace.prompt.trap('install').reject() 162 | 163 | const command = await ace.create(Configure, ['../../index.js']) 164 | await command.exec() 165 | 166 | await assert.fileExists('inertia/app/app.tsx') 167 | await assert.fileExists('resources/views/inertia_layout.edge') 168 | await assert.fileExists('inertia/tsconfig.json') 169 | await assert.fileExists('inertia/pages/home.tsx') 170 | await assert.fileExists('inertia/pages/errors/not_found.tsx') 171 | await assert.fileExists('inertia/pages/errors/server_error.tsx') 172 | await assert.fileNotContains('inertia/app/app.tsx', 'hydrateRoot') 173 | 174 | const viteConfig = await fs.contents('vite.config.ts') 175 | assert.snapshot(viteConfig).matchInline(` 176 | "import inertia from '@adonisjs/inertia/client' 177 | import solid from 'vite-plugin-solid' 178 | import adonisjs from '@adonisjs/vite/client' 179 | 180 | export default { plugins: [inertia({ ssr: { enabled: false } }), solid(), adonisjs({ entrypoints: ['inertia/app/app.tsx'], reload: ['resources/views/**/*.edge'] })] } 181 | " 182 | `) 183 | }) 184 | 185 | test('Svelte', async ({ assert, fs }) => { 186 | const { ace } = await setupApp() 187 | 188 | ace.prompt.trap('adapter').replyWith('svelte') 189 | ace.prompt.trap('ssr').reject() 190 | ace.prompt.trap('install').reject() 191 | 192 | const command = await ace.create(Configure, ['../../index.js']) 193 | await command.exec() 194 | 195 | await assert.fileExists('inertia/app/app.ts') 196 | await assert.fileExists('resources/views/inertia_layout.edge') 197 | await assert.fileExists('inertia/tsconfig.json') 198 | await assert.fileExists('inertia/pages/home.svelte') 199 | await assert.fileExists('inertia/pages/errors/not_found.svelte') 200 | await assert.fileExists('inertia/pages/errors/server_error.svelte') 201 | 202 | const viteConfig = await fs.contents('vite.config.ts') 203 | assert.snapshot(viteConfig).matchInline(` 204 | "import inertia from '@adonisjs/inertia/client' 205 | import { svelte } from '@sveltejs/vite-plugin-svelte' 206 | import adonisjs from '@adonisjs/vite/client' 207 | 208 | export default { plugins: [inertia({ ssr: { enabled: false } }), svelte(), adonisjs({ entrypoints: ['inertia/app/app.ts'], reload: ['resources/views/**/*.edge'] })] } 209 | " 210 | `) 211 | }) 212 | }) 213 | 214 | test.group('Frameworks | SSR', (group) => { 215 | group.tap((t) => t.timeout(20_000)) 216 | group.each.setup(async ({ context }) => setupFakeAdonisProject(context.fs)) 217 | 218 | test('vue', async ({ assert, fs }) => { 219 | await fs.createJson('package.json', {}) 220 | await fs.createJson('tsconfig.json', { compilerOptions: {} }) 221 | 222 | const { ace } = await setupApp() 223 | 224 | ace.prompt.trap('adapter').replyWith('vue') 225 | ace.prompt.trap('ssr').accept() 226 | ace.prompt.trap('install').reject() 227 | 228 | const command = await ace.create(Configure, ['../../index.js']) 229 | await command.exec() 230 | 231 | await assert.fileExists('inertia/app/ssr.ts') 232 | await assert.fileContains('vite.config.ts', 'inertia({ ssr: { enabled: true') 233 | const inertiaConfig = await fs.contents('config/inertia.ts') 234 | assert.snapshot(inertiaConfig).matchInline(` 235 | "import { defineConfig } from '@adonisjs/inertia' 236 | import type { InferSharedProps } from '@adonisjs/inertia/types' 237 | 238 | const inertiaConfig = defineConfig({ 239 | /** 240 | * Path to the Edge view that will be used as the root view for Inertia responses 241 | */ 242 | rootView: 'inertia_layout', 243 | 244 | /** 245 | * Data that should be shared with all rendered pages 246 | */ 247 | sharedData: { 248 | // user: (ctx) => ctx.inertia.always(() => ctx.auth.user), 249 | }, 250 | 251 | /** 252 | * Options for the server-side rendering 253 | */ 254 | ssr: { 255 | enabled: true, 256 | entrypoint: 'inertia/app/ssr.ts' 257 | } 258 | }) 259 | 260 | export default inertiaConfig 261 | 262 | declare module '@adonisjs/inertia/types' { 263 | export interface SharedProps extends InferSharedProps {} 264 | }" 265 | `) 266 | }) 267 | 268 | test('React', async ({ assert, fs }) => { 269 | const { ace } = await setupApp() 270 | 271 | ace.prompt.trap('adapter').replyWith('react') 272 | ace.prompt.trap('ssr').accept() 273 | ace.prompt.trap('install').reject() 274 | 275 | const command = await ace.create(Configure, ['../../index.js']) 276 | await command.exec() 277 | 278 | await assert.fileExists('inertia/app/app.tsx') 279 | await assert.fileContains( 280 | 'vite.config.ts', 281 | `inertia({ ssr: { enabled: true, entrypoint: 'inertia/app/ssr.tsx' } })` 282 | ) 283 | await assert.fileContains('inertia/app/app.tsx', 'hydrateRoot') 284 | 285 | const inertiaConfig = await fs.contents('config/inertia.ts') 286 | 287 | assert.snapshot(inertiaConfig).matchInline(` 288 | "import { defineConfig } from '@adonisjs/inertia' 289 | import type { InferSharedProps } from '@adonisjs/inertia/types' 290 | 291 | const inertiaConfig = defineConfig({ 292 | /** 293 | * Path to the Edge view that will be used as the root view for Inertia responses 294 | */ 295 | rootView: 'inertia_layout', 296 | 297 | /** 298 | * Data that should be shared with all rendered pages 299 | */ 300 | sharedData: { 301 | // user: (ctx) => ctx.inertia.always(() => ctx.auth.user), 302 | }, 303 | 304 | /** 305 | * Options for the server-side rendering 306 | */ 307 | ssr: { 308 | enabled: true, 309 | entrypoint: 'inertia/app/ssr.tsx' 310 | } 311 | }) 312 | 313 | export default inertiaConfig 314 | 315 | declare module '@adonisjs/inertia/types' { 316 | export interface SharedProps extends InferSharedProps {} 317 | }" 318 | `) 319 | }) 320 | 321 | test('Solid', async ({ assert, fs }) => { 322 | const { ace } = await setupApp() 323 | 324 | ace.prompt.trap('adapter').replyWith('solid') 325 | ace.prompt.trap('ssr').accept() 326 | ace.prompt.trap('install').reject() 327 | 328 | const command = await ace.create(Configure, ['../../index.js']) 329 | await command.exec() 330 | await assert.fileExists('inertia/app/app.tsx') 331 | await assert.fileContains( 332 | 'vite.config.ts', 333 | `inertia({ ssr: { enabled: true, entrypoint: 'inertia/app/ssr.tsx' } })` 334 | ) 335 | 336 | await assert.fileContains('vite.config.ts', `solid({ ssr: true })`) 337 | await assert.fileContains('inertia/app/app.tsx', 'hydrate') 338 | 339 | const inertiaConfig = await fs.contents('config/inertia.ts') 340 | assert.snapshot(inertiaConfig).matchInline(` 341 | "import { defineConfig } from '@adonisjs/inertia' 342 | import type { InferSharedProps } from '@adonisjs/inertia/types' 343 | 344 | const inertiaConfig = defineConfig({ 345 | /** 346 | * Path to the Edge view that will be used as the root view for Inertia responses 347 | */ 348 | rootView: 'inertia_layout', 349 | 350 | /** 351 | * Data that should be shared with all rendered pages 352 | */ 353 | sharedData: { 354 | // user: (ctx) => ctx.inertia.always(() => ctx.auth.user), 355 | }, 356 | 357 | /** 358 | * Options for the server-side rendering 359 | */ 360 | ssr: { 361 | enabled: true, 362 | entrypoint: 'inertia/app/ssr.tsx' 363 | } 364 | }) 365 | 366 | export default inertiaConfig 367 | 368 | declare module '@adonisjs/inertia/types' { 369 | export interface SharedProps extends InferSharedProps {} 370 | }" 371 | `) 372 | }) 373 | 374 | test('Svelte', async ({ assert, fs }) => { 375 | const { ace } = await setupApp() 376 | 377 | ace.prompt.trap('adapter').replyWith('svelte') 378 | ace.prompt.trap('ssr').accept() 379 | ace.prompt.trap('install').reject() 380 | 381 | const command = await ace.create(Configure, ['../../index.js']) 382 | await command.exec() 383 | 384 | await assert.fileExists('inertia/app/app.ts') 385 | await assert.fileExists('resources/views/inertia_layout.edge') 386 | await assert.fileExists('inertia/tsconfig.json') 387 | await assert.fileExists('inertia/pages/home.svelte') 388 | 389 | const viteConfig = await fs.contents('vite.config.ts') 390 | assert.snapshot(viteConfig).matchInline(` 391 | "import inertia from '@adonisjs/inertia/client' 392 | import { svelte } from '@sveltejs/vite-plugin-svelte' 393 | import adonisjs from '@adonisjs/vite/client' 394 | 395 | export default { plugins: [inertia({ ssr: { enabled: true, entrypoint: 'inertia/app/ssr.ts' } }), svelte({ compilerOptions: { hydratable: true } }), adonisjs({ entrypoints: ['inertia/app/app.ts'], reload: ['resources/views/**/*.edge'] })] } 396 | " 397 | `) 398 | }) 399 | }) 400 | -------------------------------------------------------------------------------- /tests/define_config.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { join } from 'node:path' 11 | import { test } from '@japa/runner' 12 | 13 | import { defineConfig } from '../index.js' 14 | import { setupApp } from './helpers.js' 15 | import { InferSharedProps } from '../src/types.js' 16 | 17 | test.group('Define Config', () => { 18 | test('detect bundle automatically - "{$self}"') 19 | .with(['ssr/ssr.js', 'ssr/ssr.mjs']) 20 | .run(async ({ assert, fs }, filePath) => { 21 | const { app } = await setupApp() 22 | const configProvider = defineConfig({}) 23 | await fs.create(filePath, '') 24 | 25 | const result = await configProvider.resolver(app) 26 | 27 | assert.deepEqual(result.ssr.bundle, join(app.makePath(filePath))) 28 | }) 29 | 30 | test('detect ssr entrypoint automatically - "{$self}"') 31 | .with(['inertia/app/ssr.tsx', 'resources/ssr.ts', 'resources/ssr.tsx']) 32 | .run(async ({ assert, fs }, filePath) => { 33 | const { app } = await setupApp() 34 | const configProvider = defineConfig({}) 35 | await fs.create(filePath, '') 36 | 37 | const result = await configProvider.resolver(app) 38 | 39 | assert.deepEqual(result.ssr.entrypoint, join(app.makePath(filePath))) 40 | }) 41 | 42 | test('can infer shared data', async ({ expectTypeOf }) => { 43 | const config = defineConfig({ 44 | sharedData: { 45 | foo: 'string' as const, 46 | bar: (ctx) => ctx.request.url(), 47 | bar2: () => (Math.random() ? 'string' : 1), 48 | bar4: (ctx) => ctx.inertia.always(() => 'foo'), 49 | }, 50 | }) 51 | 52 | type Props = InferSharedProps 53 | expectTypeOf().toMatchTypeOf<{ 54 | foo: 'string' 55 | bar: string 56 | bar2: 'string' | 1 57 | bar4: string 58 | }>() 59 | }) 60 | 61 | test('doesnt create a Record when sharedData is not defined', async ({ 62 | expectTypeOf, 63 | }) => { 64 | const config = defineConfig({}) 65 | type Props = InferSharedProps 66 | expectTypeOf().toEqualTypeOf<{}>() 67 | 68 | const props: Props = {} 69 | 70 | // @ts-expect-error 71 | props.notExistent 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /tests/helpers.ts: -------------------------------------------------------------------------------- 1 | import { InlineConfig } from 'vite' 2 | import { Vite } from '@adonisjs/vite' 3 | import { getActiveTest } from '@japa/runner' 4 | import type { Test } from '@japa/runner/core' 5 | import { HttpContext } from '@adonisjs/core/http' 6 | import { pluginAdonisJS } from '@japa/plugin-adonisjs' 7 | import { ApiClient, apiClient } from '@japa/api-client' 8 | import { ApplicationService } from '@adonisjs/core/types' 9 | import { IgnitorFactory } from '@adonisjs/core/factories' 10 | import { NamedReporterContract } from '@japa/runner/types' 11 | import { runner, syncReporter } from '@japa/runner/factories' 12 | import { IncomingMessage, ServerResponse, createServer } from 'node:http' 13 | 14 | import { inertiaApiClient } from '../src/plugins/japa/api_client.js' 15 | 16 | export const BASE_URL = new URL('./tmp/', import.meta.url) 17 | 18 | /** 19 | * Create a http server that will be closed automatically 20 | * when the test ends 21 | */ 22 | export const httpServer = { 23 | create(callback: (req: IncomingMessage, res: ServerResponse) => any) { 24 | const server = createServer(callback) 25 | getActiveTest()?.cleanup(async () => { 26 | await new Promise((resolve) => { 27 | server.close(() => resolve()) 28 | }) 29 | }) 30 | return server 31 | }, 32 | } 33 | 34 | /** 35 | * Mock the `view` macro on HttpContext to return a fake 36 | */ 37 | export function setupViewMacroMock() { 38 | // @ts-expect-error 39 | HttpContext.getter('view', () => ({ render: (view: any, props: any) => ({ view, props }) })) 40 | getActiveTest()?.cleanup(() => { 41 | // @ts-expect-error 42 | delete HttpContext.prototype.view 43 | }) 44 | } 45 | 46 | /** 47 | * Runs a japa test in isolation 48 | */ 49 | export async function runJapaTest(app: ApplicationService, callback: Parameters[0]) { 50 | ApiClient.clearSetupHooks() 51 | ApiClient.clearTeardownHooks() 52 | ApiClient.clearRequestHandlers() 53 | 54 | await runner() 55 | .configure({ 56 | reporters: { 57 | activated: [syncReporter.name], 58 | list: [syncReporter as NamedReporterContract], 59 | }, 60 | plugins: [apiClient(), pluginAdonisJS(app), inertiaApiClient(app)], 61 | files: [], 62 | }) 63 | .runTest('testing japa integration', callback) 64 | } 65 | 66 | /** 67 | * Spin up a Vite server for the test 68 | */ 69 | export async function setupVite(options: InlineConfig) { 70 | const test = getActiveTest() 71 | if (!test) throw new Error('Cannot use setupVite outside a test') 72 | 73 | /** 74 | * Create a dummy file to ensure the root directory exists 75 | * otherwise Vite will throw an error 76 | */ 77 | await test.context.fs.create('dummy.txt', 'dummy') 78 | 79 | const vite = new Vite(true, { 80 | buildDirectory: test.context.fs.basePath, 81 | manifestFile: 'manifest.json', 82 | }) 83 | 84 | await vite.createDevServer({ 85 | root: test.context.fs.basePath, 86 | clearScreen: false, 87 | logLevel: 'silent', 88 | ...options, 89 | }) 90 | 91 | test.cleanup(() => vite.stopDevServer()) 92 | 93 | return vite 94 | } 95 | 96 | /** 97 | * Setup an AdonisJS app for testing 98 | */ 99 | export async function setupApp() { 100 | const ignitor = new IgnitorFactory() 101 | .withCoreProviders() 102 | .withCoreConfig() 103 | .create(BASE_URL, { 104 | importer: (filePath) => { 105 | if (filePath.startsWith('./') || filePath.startsWith('../')) { 106 | return import(new URL(filePath, BASE_URL).href) 107 | } 108 | 109 | return import(filePath) 110 | }, 111 | }) 112 | 113 | const app = ignitor.createApp('web') 114 | await app.init().then(() => app.boot()) 115 | 116 | const ace = await app.container.make('ace') 117 | ace.ui.switchMode('raw') 118 | 119 | return { ace, app } 120 | } 121 | -------------------------------------------------------------------------------- /tests/inertia.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { join } from 'node:path' 11 | import { test } from '@japa/runner' 12 | import { Vite } from '@adonisjs/vite' 13 | import { HttpContext } from '@adonisjs/core/http' 14 | import { HttpContextFactory, RequestFactory } from '@adonisjs/core/factories/http' 15 | 16 | import { InertiaFactory } from '../factories/inertia_factory.js' 17 | import { setupViewMacroMock, setupVite } from './helpers.js' 18 | 19 | test.group('Inertia', () => { 20 | test('location should returns x-inertia-location with 409 code', async ({ assert }) => { 21 | const ctx = new HttpContextFactory().create() 22 | 23 | const inertia = await new InertiaFactory().merge({ ctx }).create() 24 | 25 | inertia.location('https://adonisjs.com') 26 | 27 | assert.equal(ctx.response.getStatus(), 409) 28 | assert.equal(ctx.response.getHeader('x-inertia-location'), 'https://adonisjs.com') 29 | }) 30 | 31 | test('location should not returns x-inertia header', async ({ assert }) => { 32 | const ctx = new HttpContextFactory().create() 33 | 34 | const inertia = await new InertiaFactory().merge({ ctx }).create() 35 | 36 | inertia.location('https://adonisjs.com') 37 | 38 | assert.equal(ctx.response.getHeader('x-inertia'), null) 39 | }) 40 | 41 | test('render should returns x-inertia header', async ({ assert }) => { 42 | setupViewMacroMock() 43 | 44 | const ctx = new HttpContextFactory().create() 45 | const inertia = await new InertiaFactory().merge({ ctx }).withXInertiaHeader().create() 46 | 47 | await inertia.render('foo') 48 | 49 | assert.equal(ctx.response.getHeader('x-inertia'), 'true') 50 | }) 51 | 52 | test('render root view with page props', async ({ assert }) => { 53 | setupViewMacroMock() 54 | 55 | const inertia = await new InertiaFactory().create() 56 | const result: any = await inertia.render('foo', { foo: 'bar' }) 57 | 58 | assert.deepEqual(result.view, 'inertia_layout') 59 | assert.deepEqual(result.props.page, { 60 | component: 'foo', 61 | version: '1', 62 | props: { foo: 'bar' }, 63 | url: null, 64 | clearHistory: false, 65 | encryptHistory: false, 66 | }) 67 | }) 68 | 69 | test('render dynamic root view', async ({ assert }) => { 70 | setupViewMacroMock() 71 | 72 | let i = 0 73 | const inertia = await new InertiaFactory() 74 | .merge({ config: { rootView: () => `inertia_layout_${i++}` } }) 75 | .create() 76 | 77 | const r1: any = await inertia.render('foo', { foo: 'bar' }) 78 | const r2: any = await inertia.render('foo', { foo: 'bar' }) 79 | 80 | assert.deepEqual(r1.view, 'inertia_layout_0') 81 | assert.deepEqual(r2.view, 'inertia_layout_1') 82 | }) 83 | 84 | test('only return page object when request is from inertia', async ({ assert }) => { 85 | const inertia = await new InertiaFactory().withXInertiaHeader().create() 86 | const result = await inertia.render('foo', { foo: 'bar' }) 87 | 88 | assert.deepEqual(result, { 89 | component: 'foo', 90 | version: '1', 91 | props: { foo: 'bar' }, 92 | url: null, 93 | encryptHistory: false, 94 | clearHistory: false, 95 | }) 96 | }) 97 | 98 | test('return given component name in page object', async ({ assert }) => { 99 | const inertia = await new InertiaFactory().withXInertiaHeader().create() 100 | const result: any = await inertia.render('Pages/Login', { foo: 'bar' }) 101 | 102 | assert.deepEqual(result.component, 'Pages/Login') 103 | }) 104 | 105 | test('return sharedData in page object', async ({ assert }) => { 106 | const inertia = await new InertiaFactory() 107 | .merge({ config: { sharedData: { foo: 'bar' } } }) 108 | .withXInertiaHeader() 109 | .create() 110 | 111 | const result: any = await inertia.render('foo', { errors: [1, 2] }) 112 | 113 | assert.deepEqual(result.props, { 114 | foo: 'bar', 115 | errors: [1, 2], 116 | }) 117 | }) 118 | 119 | test('render props take precedence over sharedData', async ({ assert }) => { 120 | const inertia = await new InertiaFactory() 121 | .merge({ config: { sharedData: { foo: 'bar' } } }) 122 | .withXInertiaHeader() 123 | .create() 124 | 125 | const result: any = await inertia.render('foo', { foo: 'baz' }) 126 | 127 | assert.deepEqual(result.props, { foo: 'baz' }) 128 | }) 129 | 130 | test('if x-inertia-partial-data header is present only return partial data', async ({ 131 | assert, 132 | }) => { 133 | const inertia = await new InertiaFactory() 134 | .withXInertiaHeader() 135 | .withInertiaPartialReload('Auth/Login', ['user']) 136 | .create() 137 | 138 | const result: any = await inertia.render('Auth/Login', { user: 'jul', categories: [1, 2] }) 139 | 140 | assert.deepEqual(result.props, { user: 'jul' }) 141 | }) 142 | 143 | test('if x-inertia-partial-component is different from component name return all data', async ({ 144 | assert, 145 | }) => { 146 | const inertia = await new InertiaFactory() 147 | .withXInertiaHeader() 148 | .withInertiaPartialReload('Auth/Login', ['user']) 149 | .create() 150 | 151 | const result: any = await inertia.render('Auth/Register', { user: 'jul', categories: [1, 2] }) 152 | 153 | assert.deepEqual(result.props, { user: 'jul', categories: [1, 2] }) 154 | }) 155 | 156 | test('exclude props from partial response', async ({ assert }) => { 157 | setupViewMacroMock() 158 | 159 | const inertia = await new InertiaFactory() 160 | .withXInertiaHeader() 161 | .withInertiaPartialComponent('Auth/Login') 162 | .withInertiaPartialExcept(['user']) 163 | .create() 164 | 165 | const result: any = await inertia.render('Auth/Login', { 166 | user: 'jul', 167 | message: 'hello', 168 | }) 169 | 170 | assert.deepEqual(result.props, { message: 'hello' }) 171 | }) 172 | 173 | test('AlwaysProps are included on partial response', async ({ assert }) => { 174 | setupViewMacroMock() 175 | 176 | const inertia = await new InertiaFactory() 177 | .withXInertiaHeader() 178 | .withInertiaPartialReload('Auth/Login', ['user']) 179 | .create() 180 | 181 | const result: any = await inertia.render('Auth/Login', { 182 | user: 'jul', 183 | message: inertia.always(() => 'hello'), 184 | }) 185 | 186 | assert.deepEqual(result.props, { user: 'jul', message: 'hello' }) 187 | }) 188 | 189 | test('shared data are unwrapped when they use always', async ({ assert }) => { 190 | setupViewMacroMock() 191 | 192 | const inertia = await new InertiaFactory() 193 | .merge({ config: { sharedData: { foo: () => inertia.always(() => 'bar') } } }) 194 | .withXInertiaHeader() 195 | .create() 196 | 197 | const result: any = await inertia.render('foo') 198 | 199 | assert.deepEqual(result.props, { foo: 'bar' }) 200 | }) 201 | 202 | test('correct server response when mergeable props is used', async ({ assert }) => { 203 | const inertia = await new InertiaFactory().withXInertiaHeader().create() 204 | 205 | const result: any = await inertia.render('foo', { 206 | foo: 'bar', 207 | baz: inertia.merge(() => [1, 2, 3]), 208 | bar: inertia.merge(() => 'bar'), 209 | }) 210 | 211 | assert.deepEqual(result.props, { foo: 'bar', baz: [1, 2, 3], bar: 'bar' }) 212 | assert.deepEqual(result.mergeProps, ['baz', 'bar']) 213 | }) 214 | 215 | test('correct server response witht mergeable and deferred props', async ({ assert }) => { 216 | const inertia = await new InertiaFactory().withXInertiaHeader().create() 217 | 218 | const result: any = await inertia.render('foo', { 219 | foo: 'bar', 220 | baz: inertia.merge(() => [1, 2, 3]), 221 | bar: inertia.defer(() => 'bar').merge(), 222 | }) 223 | 224 | assert.deepEqual(result.deferredProps, { default: ['bar'] }) 225 | assert.deepEqual(result.mergeProps, ['baz', 'bar']) 226 | }) 227 | 228 | test('properly handle null and undefined values props on first visit', async ({ assert }) => { 229 | setupViewMacroMock() 230 | 231 | const inertia = await new InertiaFactory().create() 232 | 233 | const result: any = await inertia.render('Auth/Login', { 234 | user: undefined, 235 | password: null, 236 | message: 'hello', 237 | }) 238 | 239 | assert.deepEqual(result.props.page.props, { 240 | message: 'hello', 241 | password: null, 242 | user: undefined, 243 | }) 244 | }) 245 | 246 | test("don't return lazy props on first visit", async ({ assert }) => { 247 | setupViewMacroMock() 248 | 249 | const inertia = await new InertiaFactory().create() 250 | 251 | const result: any = await inertia.render('Auth/Login', { 252 | user: 'jul', 253 | message: inertia.lazy(() => 'hello'), 254 | }) 255 | 256 | assert.deepEqual(result.props.page.props, { user: 'jul' }) 257 | }) 258 | 259 | test('load lazy props when present in x-inertia-partial-data', async ({ assert }) => { 260 | const inertia = await new InertiaFactory() 261 | .withXInertiaHeader() 262 | .withInertiaPartialReload('Auth/Login', ['user', 'message', 'foo']) 263 | .create() 264 | 265 | const result: any = await inertia.render('Auth/Login', { 266 | user: 'jul', 267 | categories: [1, 2], 268 | message: inertia.lazy(() => 'hello'), 269 | foo: inertia.lazy(async () => 'bar'), 270 | bar: inertia.lazy(() => 'baz'), 271 | }) 272 | 273 | assert.deepEqual(result.props, { user: 'jul', message: 'hello', foo: 'bar' }) 274 | }) 275 | 276 | test('resolve page props functions', async ({ assert }) => { 277 | const inertia = await new InertiaFactory().withXInertiaHeader().create() 278 | 279 | const result: any = await inertia.render('foo', { 280 | foo: 'bar', 281 | baz: () => 'baz', 282 | qux: async () => 'qux', 283 | }) 284 | 285 | assert.deepEqual(result.props, { foo: 'bar', baz: 'baz', qux: 'qux' }) 286 | }) 287 | 288 | test('resolve sharedData function', async ({ assert }) => { 289 | const inertia = await new InertiaFactory() 290 | .merge({ config: { sharedData: { foo: () => 'bar' } } }) 291 | .withXInertiaHeader() 292 | .create() 293 | 294 | const result: any = await inertia.render('foo') 295 | 296 | assert.deepEqual(result.props, { foo: 'bar' }) 297 | }) 298 | 299 | test('returns version in page object', async ({ assert }) => { 300 | const inertia = await new InertiaFactory().withXInertiaHeader().withVersion('2').create() 301 | 302 | const result: any = await inertia.render('foo') 303 | 304 | assert.deepEqual(result.version, '2') 305 | }) 306 | 307 | test('preserve query parameters in page object url', async ({ assert }) => { 308 | const request = new RequestFactory().merge({ url: '/foo?bar=baz&test[]=32&12&bla=42' }).create() 309 | const inertia = await new InertiaFactory() 310 | .merge({ ctx: new HttpContextFactory().merge({ request }).create() }) 311 | .withXInertiaHeader() 312 | .create() 313 | 314 | const result: any = await inertia.render('foo') 315 | 316 | assert.deepEqual(result.url, '/foo?bar=baz&test[]=32&12&bla=42') 317 | }) 318 | 319 | test('view props are passed to the root view', async ({ assert }) => { 320 | // @ts-expect-error mock 321 | HttpContext.getter('view', () => ({ render: (view: any, props: any) => ({ view, props }) })) 322 | 323 | const inertia = await new InertiaFactory().create() 324 | const result: any = await inertia.render('foo', { data: 42 }, { metaTitle: 'foo' }) 325 | 326 | assert.deepEqual(result.props.metaTitle, 'foo') 327 | 328 | // @ts-expect-error mock 329 | delete HttpContext.prototype.view 330 | }) 331 | 332 | test('share data for the current request', async ({ assert }) => { 333 | const inertia = await new InertiaFactory().withXInertiaHeader().create() 334 | 335 | inertia.share({ foo: 'bar' }) 336 | 337 | const result: any = await inertia.render('foo') 338 | 339 | assert.deepEqual(result.props, { foo: 'bar' }) 340 | }) 341 | 342 | test('share() data are scoped to current instance', async ({ assert }) => { 343 | const inertia = await new InertiaFactory().withXInertiaHeader().create() 344 | const inertia2 = await new InertiaFactory().withXInertiaHeader().create() 345 | 346 | inertia.share({ foo: 'bar' }) 347 | inertia2.share({ foo: 'baz' }) 348 | 349 | const result: any = await inertia.render('foo') 350 | const result2: any = await inertia2.render('foo') 351 | 352 | assert.deepEqual(result.props, { foo: 'bar' }) 353 | assert.deepEqual(result2.props, { foo: 'baz' }) 354 | }) 355 | 356 | test('share() data override the global shared data', async ({ assert }) => { 357 | const inertia = await new InertiaFactory() 358 | .merge({ config: { sharedData: { foo: 'bar' } } }) 359 | .withXInertiaHeader() 360 | .create() 361 | 362 | inertia.share({ foo: 'baz' }) 363 | 364 | const result: any = await inertia.render('foo') 365 | 366 | assert.deepEqual(result.props, { foo: 'baz' }) 367 | }) 368 | 369 | test('dont execute deferred props on first visit', async ({ assert }) => { 370 | setupViewMacroMock() 371 | 372 | const inertia = await new InertiaFactory().create() 373 | let executed = false 374 | 375 | await inertia.render('foo', { 376 | foo: 'bar', 377 | baz: inertia.defer(() => { 378 | executed = true 379 | return 'baz' 380 | }), 381 | }) 382 | 383 | assert.deepEqual(executed, false) 384 | }) 385 | 386 | test('deferred props listing are returned in page object', async ({ assert }) => { 387 | setupViewMacroMock() 388 | 389 | const inertia = await new InertiaFactory().create() 390 | 391 | const result: any = await inertia.render('foo', { 392 | foo: 'bar', 393 | baz: inertia.defer(() => 'baz'), 394 | qux: inertia.defer(() => 'qux'), 395 | }) 396 | 397 | assert.deepEqual(result.props.page.deferredProps, { 398 | default: ['baz', 'qux'], 399 | }) 400 | }) 401 | 402 | test('deferred props groups are respected', async ({ assert }) => { 403 | setupViewMacroMock() 404 | 405 | const inertia = await new InertiaFactory().create() 406 | 407 | const result: any = await inertia.render('foo', { 408 | foo: 'bar', 409 | baz: inertia.defer(() => 'baz', 'group1'), 410 | qux: inertia.defer(() => 'qux', 'group2'), 411 | lorem: inertia.defer(() => 'lorem', 'group1'), 412 | ipsum: inertia.defer(() => 'ipsum', 'group2'), 413 | }) 414 | 415 | assert.deepEqual(result.props.page.deferredProps, { 416 | group1: ['baz', 'lorem'], 417 | group2: ['qux', 'ipsum'], 418 | }) 419 | }) 420 | 421 | test('execute and return deferred props on partial reload', async ({ assert }) => { 422 | const inertia = await new InertiaFactory() 423 | .withXInertiaHeader() 424 | .withInertiaPartialReload('foo', ['baz']) 425 | .create() 426 | 427 | const result: any = await inertia.render('foo', { 428 | foo: 'bar', 429 | baz: inertia.defer(() => 'baz'), 430 | }) 431 | 432 | assert.deepEqual(result.props, { baz: 'baz' }) 433 | }) 434 | 435 | test('encrypt history with config file', async ({ assert }) => { 436 | const inertia = await new InertiaFactory() 437 | .merge({ config: { history: { encrypt: true } } }) 438 | .withXInertiaHeader() 439 | .create() 440 | 441 | const result: any = await inertia.render('foo') 442 | 443 | assert.isTrue(result.encryptHistory) 444 | }) 445 | 446 | test('encrypt history with api', async ({ assert }) => { 447 | const inertia = await new InertiaFactory() 448 | .merge({ config: { history: { encrypt: false } } }) 449 | .withXInertiaHeader() 450 | .create() 451 | 452 | inertia.encryptHistory() 453 | const result: any = await inertia.render('foo') 454 | 455 | assert.isTrue(result.encryptHistory) 456 | }) 457 | 458 | test('clear history with api', async ({ assert }) => { 459 | const inertia = await new InertiaFactory() 460 | .merge({ config: { history: { encrypt: false } } }) 461 | .withXInertiaHeader() 462 | .create() 463 | 464 | inertia.clearHistory() 465 | const result: any = await inertia.render('foo') 466 | 467 | assert.isTrue(result.clearHistory) 468 | }) 469 | }) 470 | 471 | test.group('Inertia | Ssr', () => { 472 | test('if devServer is available, use entrypoint file to render the page', async ({ 473 | assert, 474 | fs, 475 | }) => { 476 | setupViewMacroMock() 477 | 478 | await fs.create('foo.ts', 'export default () => ({ head: ["head"], body: "foo.ts" })') 479 | 480 | const vite = await setupVite({ build: { rollupOptions: { input: 'foo.ts' } } }) 481 | 482 | const inertia = await new InertiaFactory() 483 | .merge({ config: { ssr: { enabled: true, entrypoint: 'foo.ts' } } }) 484 | .withVite(vite) 485 | .create() 486 | 487 | const result: any = await inertia.render('foo') 488 | 489 | assert.deepEqual(result.props.page.ssrHead, ['head']) 490 | assert.deepEqual(result.props.page.ssrBody, 'foo.ts') 491 | }) 492 | 493 | test('if devServer is not available, use bundle file to render the page', async ({ 494 | assert, 495 | fs, 496 | }) => { 497 | setupViewMacroMock() 498 | 499 | const vite = new Vite(false, { 500 | buildDirectory: fs.basePath, 501 | manifestFile: 'manifest.json', 502 | }) 503 | 504 | await fs.createJson('package.json', { type: 'module' }) 505 | await fs.create('foo.js', 'export default () => ({ head: ["head"], body: "foo.ts" })') 506 | 507 | const inertia = await new InertiaFactory() 508 | .merge({ config: { ssr: { enabled: true, bundle: join(fs.basePath, 'foo.js') } } }) 509 | .withVite(vite) 510 | .create() 511 | 512 | const result: any = await inertia.render('foo') 513 | 514 | assert.deepEqual(result.props.page.ssrBody, 'foo.ts') 515 | assert.deepEqual(result.props.page.ssrHead, ['head']) 516 | }) 517 | 518 | test('enable everywhere if pages is not defined', async ({ assert, fs }) => { 519 | setupViewMacroMock() 520 | const vite = await setupVite({ build: { rollupOptions: { input: 'foo.ts' } } }) 521 | 522 | await fs.create('foo.ts', 'export default () => ({ head: ["head"], body: "foo.ts" })') 523 | 524 | const inertia = await new InertiaFactory() 525 | .withVite(vite) 526 | .merge({ config: { ssr: { enabled: true, entrypoint: 'foo.ts' } } }) 527 | .create() 528 | 529 | const result: any = await inertia.render('foo') 530 | const result2: any = await inertia.render('bar') 531 | 532 | assert.deepEqual(result.props.page.ssrBody, 'foo.ts') 533 | assert.deepEqual(result2.props.page.ssrBody, 'foo.ts') 534 | }) 535 | 536 | test('enable only for listed pages (Array)', async ({ assert, fs }) => { 537 | setupViewMacroMock() 538 | const vite = await setupVite({ build: { rollupOptions: { input: 'foo.ts' } } }) 539 | 540 | await fs.create('foo.ts', 'export default () => ({ head: ["head"], body: "foo.ts" })') 541 | 542 | const inertia = await new InertiaFactory() 543 | .withVite(vite) 544 | .merge({ config: { ssr: { enabled: true, entrypoint: 'foo.ts', pages: ['foo'] } } }) 545 | .create() 546 | 547 | const result: any = await inertia.render('foo') 548 | const result2: any = await inertia.render('bar') 549 | 550 | assert.deepEqual(result.props.page.ssrBody, 'foo.ts') 551 | assert.notExists(result2.props.page.ssrBody) 552 | }) 553 | 554 | test('enable only for listed pages (Function)', async ({ assert, fs }) => { 555 | setupViewMacroMock() 556 | const vite = await setupVite({ build: { rollupOptions: { input: 'foo.ts' } } }) 557 | 558 | await fs.create('foo.ts', 'export default () => ({ head: ["head"], body: "foo.ts" })') 559 | 560 | const inertia = await new InertiaFactory() 561 | .withVite(vite) 562 | .merge({ 563 | config: { 564 | ssr: { 565 | enabled: true, 566 | entrypoint: 'foo.ts', 567 | pages: (_, page) => page.startsWith('admin/'), 568 | }, 569 | }, 570 | }) 571 | .create() 572 | 573 | const r1: any = await inertia.render('foo') 574 | const r2: any = await inertia.render('bar') 575 | const r3: any = await inertia.render('admin/foo') 576 | const r4: any = await inertia.render('admin/bar') 577 | 578 | assert.notExists(r1.props.page.ssrBody) 579 | assert.notExists(r2.props.page.ssrBody) 580 | assert.deepEqual(r3.props.page.ssrBody, 'foo.ts') 581 | assert.deepEqual(r4.props.page.ssrBody, 'foo.ts') 582 | }) 583 | 584 | test('should pass page object to the view', async ({ assert, fs }) => { 585 | setupViewMacroMock() 586 | const vite = await setupVite({ build: { rollupOptions: { input: 'foo.ts' } } }) 587 | 588 | await fs.create('foo.ts', 'export default () => ({ head: ["head"], body: "foo.ts" })') 589 | 590 | const inertia = await new InertiaFactory() 591 | .withVite(vite) 592 | .merge({ config: { ssr: { enabled: true, entrypoint: 'foo.ts' } } }) 593 | .create() 594 | 595 | const result: any = await inertia.render('foo') 596 | 597 | assert.deepEqual(result.props.page.component, 'foo') 598 | assert.deepEqual(result.props.page.version, '1') 599 | }) 600 | }) 601 | -------------------------------------------------------------------------------- /tests/middleware.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import supertest from 'supertest' 11 | import { test } from '@japa/runner' 12 | import { HttpContextFactory, RequestFactory, ResponseFactory } from '@adonisjs/core/factories/http' 13 | 14 | import { Inertia } from '../src/inertia.js' 15 | import { InertiaHeaders } from '../src/headers.js' 16 | import { httpServer } from './helpers.js' 17 | import { VersionCache } from '../src/version_cache.js' 18 | import InertiaMiddleware from '../src/inertia_middleware.js' 19 | import { SessionMiddlewareFactory } from '@adonisjs/session/factories' 20 | 21 | test.group('Middleware', () => { 22 | test('add inertia to http context', async ({ assert }) => { 23 | const server = httpServer.create(async (_req, res) => { 24 | const ctx = new HttpContextFactory().create() 25 | 26 | const middleware = new InertiaMiddleware({ 27 | rootView: 'root', 28 | sharedData: {}, 29 | versionCache: new VersionCache(new URL(import.meta.url), '1'), 30 | ssr: { enabled: false, bundle: '', entrypoint: '' }, 31 | history: { encrypt: false }, 32 | }) 33 | 34 | await middleware.handle(ctx, () => {}) 35 | assert.instanceOf(ctx.inertia, Inertia) 36 | 37 | res.end() 38 | }) 39 | 40 | await supertest(server).get('/') 41 | }) 42 | 43 | test('set 303 http code on put/patch/delete method', async ({ assert }) => { 44 | const server = httpServer.create(async (req, res) => { 45 | const request = new RequestFactory().merge({ req, res }).create() 46 | const response = new ResponseFactory().merge({ req, res }).create() 47 | const ctx = new HttpContextFactory().merge({ request, response }).create() 48 | 49 | const middleware = new InertiaMiddleware({ 50 | rootView: 'root', 51 | sharedData: {}, 52 | versionCache: new VersionCache(new URL(import.meta.url), '1'), 53 | ssr: { enabled: false, bundle: '', entrypoint: '' }, 54 | history: { encrypt: false }, 55 | }) 56 | 57 | await middleware.handle(ctx, () => { 58 | ctx.response.redirect('/foo') 59 | }) 60 | 61 | ctx.response.finish() 62 | }) 63 | 64 | const r1 = await supertest(server).put('/').set(InertiaHeaders.Inertia, 'true') 65 | const r2 = await supertest(server).delete('/').set(InertiaHeaders.Inertia, 'true') 66 | const r3 = await supertest(server).patch('/').set(InertiaHeaders.Inertia, 'true') 67 | 68 | assert.equal(r1.status, 303) 69 | assert.equal(r2.status, 303) 70 | assert.equal(r3.status, 303) 71 | }) 72 | 73 | test('dont set 303 http code if not inertia request', async ({ assert }) => { 74 | const server = httpServer.create(async (req, res) => { 75 | const request = new RequestFactory().merge({ req, res }).create() 76 | const response = new ResponseFactory().merge({ req, res }).create() 77 | const ctx = new HttpContextFactory().merge({ request, response }).create() 78 | 79 | const middleware = new InertiaMiddleware({ 80 | rootView: 'root', 81 | sharedData: {}, 82 | versionCache: new VersionCache(new URL(import.meta.url), '1'), 83 | ssr: { enabled: false, bundle: '', entrypoint: '' }, 84 | history: { encrypt: false }, 85 | }) 86 | 87 | await middleware.handle(ctx, () => { 88 | ctx.response.redirect('/foo') 89 | }) 90 | 91 | ctx.response.finish() 92 | }) 93 | 94 | const r1 = await supertest(server).put('/') 95 | const r2 = await supertest(server).delete('/') 96 | const r3 = await supertest(server).patch('/') 97 | 98 | assert.equal(r1.status, 302) 99 | assert.equal(r2.status, 302) 100 | assert.equal(r3.status, 302) 101 | }) 102 | 103 | test('set vary header if its inertia request', async ({ assert }) => { 104 | const server = httpServer.create(async (req, res) => { 105 | const request = new RequestFactory().merge({ req, res }).create() 106 | const response = new ResponseFactory().merge({ req, res }).create() 107 | const ctx = new HttpContextFactory().merge({ request, response }).create() 108 | 109 | const middleware = new InertiaMiddleware({ 110 | rootView: 'root', 111 | sharedData: {}, 112 | versionCache: new VersionCache(new URL(import.meta.url), '1'), 113 | ssr: { enabled: false, bundle: '', entrypoint: '' }, 114 | history: { encrypt: false }, 115 | }) 116 | 117 | await middleware.handle(ctx, () => { 118 | ctx.response.redirect('/foo') 119 | }) 120 | 121 | ctx.response.finish() 122 | }) 123 | 124 | const r1 = await supertest(server).get('/').set(InertiaHeaders.Inertia, 'true') 125 | const r2 = await supertest(server).get('/') 126 | 127 | assert.equal(r1.headers.vary, InertiaHeaders.Inertia) 128 | assert.isUndefined(r2.headers.vary) 129 | }) 130 | 131 | test('should not append x-inertia request if not using inertia.render', async ({ assert }) => { 132 | const server = httpServer.create(async (req, res) => { 133 | const request = new RequestFactory().merge({ req, res }).create() 134 | const response = new ResponseFactory().merge({ req, res }).create() 135 | const ctx = new HttpContextFactory().merge({ request, response }).create() 136 | 137 | const middleware = new InertiaMiddleware({ 138 | rootView: 'root', 139 | sharedData: {}, 140 | versionCache: new VersionCache(new URL(import.meta.url), '1'), 141 | ssr: { enabled: false, bundle: '', entrypoint: '' }, 142 | history: { encrypt: false }, 143 | }) 144 | 145 | await middleware.handle(ctx, () => {}) 146 | 147 | ctx.response.finish() 148 | }) 149 | 150 | const r1 = await supertest(server).get('/') 151 | 152 | assert.isUndefined(r1.headers['x-inertia']) 153 | }) 154 | 155 | test('force a full reload if version has changed', async ({ assert }) => { 156 | let requestCount = 1 157 | 158 | const version = new VersionCache(new URL(import.meta.url), '1') 159 | const middleware = new InertiaMiddleware({ 160 | rootView: 'root', 161 | sharedData: {}, 162 | versionCache: version, 163 | ssr: { enabled: false, bundle: '', entrypoint: '' }, 164 | history: { encrypt: false }, 165 | }) 166 | 167 | const server = httpServer.create(async (req, res) => { 168 | const request = new RequestFactory().merge({ req, res }).create() 169 | const response = new ResponseFactory().merge({ req, res }).create() 170 | const ctx = new HttpContextFactory().merge({ request, response }).create() 171 | 172 | version.setVersion(requestCount.toString()) 173 | 174 | await middleware.handle(ctx, () => { 175 | ctx.response.redirect('/foo') 176 | }) 177 | 178 | ctx.response.finish() 179 | }) 180 | 181 | const r1 = await supertest(server).get('/').set(InertiaHeaders.Inertia, 'true') 182 | 183 | assert.equal(r1.status, 409) 184 | assert.equal(r1.headers['x-inertia-location'], '/') 185 | }) 186 | 187 | test('if version has changed response should not includes x-inertia header', async ({ 188 | assert, 189 | }) => { 190 | let requestCount = 1 191 | 192 | const version = new VersionCache(new URL(import.meta.url), '1') 193 | const middleware = new InertiaMiddleware({ 194 | rootView: 'root', 195 | sharedData: {}, 196 | versionCache: version, 197 | ssr: { enabled: false, bundle: '', entrypoint: '' }, 198 | history: { encrypt: false }, 199 | }) 200 | 201 | const server = httpServer.create(async (req, res) => { 202 | const request = new RequestFactory().merge({ req, res }).create() 203 | const response = new ResponseFactory().merge({ req, res }).create() 204 | const ctx = new HttpContextFactory().merge({ request, response }).create() 205 | 206 | version.setVersion(requestCount.toString()) 207 | 208 | await middleware.handle(ctx, () => { 209 | ctx.response.header('x-inertia', 'true') 210 | ctx.response.redirect('/foo') 211 | }) 212 | 213 | ctx.response.finish() 214 | }) 215 | 216 | const r1 = await supertest(server).get('/').set(InertiaHeaders.Inertia, 'true') 217 | 218 | assert.equal(r1.status, 409) 219 | assert.equal(r1.headers['x-inertia-location'], '/') 220 | assert.isUndefined(r1.headers['x-inertia']) 221 | }) 222 | 223 | test('if version is provided as integer it should compare it using a toString', async ({ 224 | assert, 225 | }) => { 226 | const version = new VersionCache(new URL(import.meta.url), 1) 227 | const middleware = new InertiaMiddleware({ 228 | rootView: 'root', 229 | sharedData: {}, 230 | versionCache: version, 231 | ssr: { enabled: false, bundle: '', entrypoint: '' }, 232 | history: { encrypt: false }, 233 | }) 234 | 235 | const server = httpServer.create(async (req, res) => { 236 | const request = new RequestFactory().merge({ req, res }).create() 237 | const response = new ResponseFactory().merge({ req, res }).create() 238 | const ctx = new HttpContextFactory().merge({ request, response }).create() 239 | 240 | await middleware.handle(ctx, () => {}) 241 | 242 | ctx.response.finish() 243 | }) 244 | 245 | const r1 = await supertest(server) 246 | .get('/') 247 | .set(InertiaHeaders.Inertia, 'true') 248 | .set('x-inertia-version', '1') 249 | 250 | assert.equal(r1.status, 200) 251 | }) 252 | }) 253 | 254 | test.group('Middleware | Errors', () => { 255 | test('flashed errors should be shared', async ({ assert }) => { 256 | const middleware = new InertiaMiddleware({ 257 | rootView: 'root', 258 | sharedData: {}, 259 | versionCache: new VersionCache(new URL(import.meta.url), '1'), 260 | ssr: { enabled: false, bundle: '', entrypoint: '' }, 261 | history: { encrypt: false }, 262 | }) 263 | 264 | const sessionMiddleware = await new SessionMiddlewareFactory().create() 265 | const server = httpServer.create(async (req, res) => { 266 | const request = new RequestFactory().merge({ req, res }).create() 267 | const response = new ResponseFactory().merge({ req, res }).create() 268 | const ctx = new HttpContextFactory().merge({ request, response }).create() 269 | await sessionMiddleware.handle(ctx, () => {}) 270 | 271 | ctx.session.flashMessages.set('errorsBag', { foo: 'bar', bar: 'baz' }) 272 | await middleware.handle(ctx, () => {}) 273 | 274 | ctx.response.json(await ctx.inertia.render('foo')) 275 | ctx.response.finish() 276 | }) 277 | 278 | const r1 = await supertest(server).post('/').set(InertiaHeaders.Inertia, 'true') 279 | assert.deepEqual(r1.body.props.errors, { foo: 'bar', bar: 'baz' }) 280 | }) 281 | 282 | test('if validation error, only return first message', async ({ assert }) => { 283 | const middleware = new InertiaMiddleware({ 284 | rootView: 'root', 285 | sharedData: {}, 286 | versionCache: new VersionCache(new URL(import.meta.url), '1'), 287 | ssr: { enabled: false, bundle: '', entrypoint: '' }, 288 | history: { encrypt: false }, 289 | }) 290 | 291 | const sessionMiddleware = await new SessionMiddlewareFactory().create() 292 | const server = httpServer.create(async (req, res) => { 293 | const request = new RequestFactory().merge({ req, res }).create() 294 | const response = new ResponseFactory().merge({ req, res }).create() 295 | const ctx = new HttpContextFactory().merge({ request, response }).create() 296 | await sessionMiddleware.handle(ctx, () => {}) 297 | 298 | ctx.session.flashMessages.set('errorsBag', { 299 | E_VALIDATION_ERROR: 'Could not be saved', 300 | }) 301 | ctx.session.flashMessages.set('inputErrorsBag', { 302 | email: ['Email is required'], 303 | password: ['Password is required'], 304 | }) 305 | 306 | await middleware.handle(ctx, () => {}) 307 | 308 | ctx.response.json(await ctx.inertia.render('foo')) 309 | ctx.response.finish() 310 | }) 311 | 312 | const r1 = await supertest(server).post('/').set(InertiaHeaders.Inertia, 'true') 313 | const errors = r1.body.props.errors 314 | 315 | assert.deepEqual(errors, { email: 'Email is required', password: 'Password is required' }) 316 | }) 317 | 318 | test('use correct error bag', async ({ assert }) => { 319 | const middleware = new InertiaMiddleware({ 320 | rootView: 'root', 321 | sharedData: {}, 322 | versionCache: new VersionCache(new URL(import.meta.url), '1'), 323 | ssr: { enabled: false, bundle: '', entrypoint: '' }, 324 | history: { encrypt: false }, 325 | }) 326 | 327 | const sessionMiddleware = await new SessionMiddlewareFactory().create() 328 | const server = httpServer.create(async (req, res) => { 329 | const request = new RequestFactory().merge({ req, res }).create() 330 | const response = new ResponseFactory().merge({ req, res }).create() 331 | const ctx = new HttpContextFactory().merge({ request, response }).create() 332 | await sessionMiddleware.handle(ctx, () => {}) 333 | 334 | ctx.session.flashMessages.set('errorsBag', { 335 | E_VALIDATION_ERROR: 'Could not be saved', 336 | }) 337 | ctx.session.flashMessages.set('inputErrorsBag', { 338 | email: ['Email is required'], 339 | password: ['Password is required'], 340 | }) 341 | 342 | await middleware.handle(ctx, () => {}) 343 | 344 | ctx.response.json(await ctx.inertia.render('foo')) 345 | ctx.response.finish() 346 | }) 347 | 348 | const r1 = await supertest(server) 349 | .post('/') 350 | .set(InertiaHeaders.Inertia, 'true') 351 | .set(InertiaHeaders.ErrorBag, 'createUser') 352 | 353 | const errors = r1.body.props.errors 354 | 355 | assert.deepEqual(errors, { 356 | createUser: { email: 'Email is required', password: 'Password is required' }, 357 | }) 358 | }) 359 | 360 | test('errors are always shared', async ({ assert }) => { 361 | const middleware = new InertiaMiddleware({ 362 | rootView: 'root', 363 | sharedData: {}, 364 | versionCache: new VersionCache(new URL(import.meta.url), '1'), 365 | ssr: { enabled: false, bundle: '', entrypoint: '' }, 366 | history: { encrypt: false }, 367 | }) 368 | 369 | const sessionMiddleware = await new SessionMiddlewareFactory().create() 370 | const server = httpServer.create(async (req, res) => { 371 | const request = new RequestFactory().merge({ req, res }).create() 372 | const response = new ResponseFactory().merge({ req, res }).create() 373 | const ctx = new HttpContextFactory().merge({ request, response }).create() 374 | await sessionMiddleware.handle(ctx, () => {}) 375 | 376 | ctx.session.flashMessages.set('errorsBag', { foo: 'bar', bar: 'baz' }) 377 | await middleware.handle(ctx, () => {}) 378 | 379 | ctx.response.json( 380 | await ctx.inertia.render('foo', { 381 | test: 'value', 382 | yeah: 'no', 383 | }) 384 | ) 385 | ctx.response.finish() 386 | }) 387 | 388 | const r1 = await supertest(server) 389 | .post('/') 390 | .set(InertiaHeaders.Inertia, 'true') 391 | .set(InertiaHeaders.PartialComponent, 'foo') 392 | .set(InertiaHeaders.PartialOnly, 'yeah') 393 | 394 | assert.deepEqual(r1.body.props, { 395 | yeah: 'no', 396 | errors: { foo: 'bar', bar: 'baz' }, 397 | }) 398 | }) 399 | 400 | test('if session isn\t initialized, doesn\t throw an error', async ({ assert }) => { 401 | const middleware = new InertiaMiddleware({ 402 | rootView: 'root', 403 | sharedData: {}, 404 | versionCache: new VersionCache(new URL(import.meta.url), '1'), 405 | ssr: { enabled: false, bundle: '', entrypoint: '' }, 406 | history: { encrypt: false }, 407 | }) 408 | 409 | const server = httpServer.create(async (req, res) => { 410 | const request = new RequestFactory().merge({ req, res }).create() 411 | const response = new ResponseFactory().merge({ req, res }).create() 412 | const ctx = new HttpContextFactory().merge({ request, response }).create() 413 | 414 | await middleware.handle(ctx, () => {}) 415 | 416 | try { 417 | ctx.response.json(await ctx.inertia.render('foo')) 418 | } catch (error) { 419 | ctx.response.internalServerError() 420 | } finally { 421 | ctx.response.finish() 422 | } 423 | }) 424 | 425 | const r1 = await supertest(server) 426 | .post('/') 427 | .set(InertiaHeaders.Inertia, 'true') 428 | .set(InertiaHeaders.Version, '1') 429 | 430 | assert.equal(r1.status, 200) 431 | }) 432 | }) 433 | -------------------------------------------------------------------------------- /tests/plugins/api_client.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import getPort from 'get-port' 11 | import { test } from '@japa/runner' 12 | import { AppFactory } from '@adonisjs/core/factories/app' 13 | import { ApplicationService } from '@adonisjs/core/types' 14 | import { HttpContextFactory, RequestFactory, ResponseFactory } from '@adonisjs/core/factories/http' 15 | 16 | import { defineConfig } from '../../index.js' 17 | import { VersionCache } from '../../src/version_cache.js' 18 | import InertiaMiddleware from '../../src/inertia_middleware.js' 19 | import { InertiaFactory } from '../../factories/inertia_factory.js' 20 | import { httpServer, runJapaTest } from '../helpers.js' 21 | 22 | const app = new AppFactory().create(new URL('./', import.meta.url), () => {}) as ApplicationService 23 | 24 | test.group('Japa plugin | Api Client', (group) => { 25 | group.setup(async () => { 26 | app.useConfig({ inertia: defineConfig({ assetsVersion: '1' }) }) 27 | 28 | await app.init() 29 | await app.boot() 30 | }) 31 | 32 | test('withInertia() should send the x-inertia header', async ({ assert }) => { 33 | assert.plan(1) 34 | 35 | const server = httpServer.create(async (req, res) => { 36 | assert.deepEqual(req.headers['x-inertia'], 'true') 37 | res.end() 38 | }) 39 | 40 | const port = await getPort({ port: 3333 }) 41 | const url = `http://localhost:${port}` 42 | server.listen(port) 43 | 44 | await runJapaTest(app, async ({ client }) => { 45 | await client.get(url).withInertia() 46 | }) 47 | }) 48 | 49 | test('withInertia() should send the x-inertia-version header', async ({ assert }) => { 50 | assert.plan(1) 51 | 52 | const server = httpServer.create(async (req, res) => { 53 | assert.deepEqual(req.headers['x-inertia-version'], '1') 54 | res.end() 55 | }) 56 | 57 | const port = await getPort({ port: 3333 }) 58 | const url = `http://localhost:${port}` 59 | server.listen(port) 60 | 61 | await runJapaTest(app, async ({ client }) => { 62 | await client.get(url).withInertia() 63 | }) 64 | }) 65 | 66 | test('assertions should works', async () => { 67 | const server = httpServer.create(async (req, res) => { 68 | const request = new RequestFactory().merge({ req, res }).create() 69 | const response = new ResponseFactory().merge({ req, res }).create() 70 | const ctx = new HttpContextFactory().merge({ request, response }).create() 71 | const inertia = await new InertiaFactory().merge({ ctx: ctx }).create() 72 | 73 | const middleware = new InertiaMiddleware({ 74 | versionCache: new VersionCache(new URL(import.meta.url), '1'), 75 | rootView: 'root', 76 | sharedData: {}, 77 | ssr: { enabled: false, bundle: '', entrypoint: '' }, 78 | history: { encrypt: false }, 79 | }) 80 | 81 | await middleware.handle(ctx, async () => { 82 | response.send(await inertia.render('Pages/Home', { username: 'foo', foo: 'bar' })) 83 | }) 84 | 85 | response.finish() 86 | }) 87 | 88 | const port = await getPort({ port: 3333 }) 89 | const url = `http://localhost:${port}` 90 | server.listen(port) 91 | 92 | await runJapaTest(app, async ({ client }) => { 93 | const r1 = await client.get(url).withInertia() 94 | 95 | r1.assertStatus(200) 96 | r1.assertInertiaComponent('Pages/Home') 97 | .assertInertiaProps({ username: 'foo', foo: 'bar' }) 98 | .assertInertiaPropsContains({ foo: 'bar' }) 99 | 100 | const r2 = await client.get(url).withInertiaPartialReload('Pages/Home', ['username']) 101 | 102 | r2.assertInertiaComponent('Pages/Home') 103 | .assertInertiaProps({ username: 'foo' }) 104 | .assertInertiaPropsContains({ username: 'foo' }) 105 | }) 106 | }) 107 | 108 | test('assertions should throws if not valid', async () => { 109 | const server = httpServer.create(async (req, res) => { 110 | const request = new RequestFactory().merge({ req, res }).create() 111 | const response = new ResponseFactory().merge({ req, res }).create() 112 | const ctx = new HttpContextFactory().merge({ request, response }).create() 113 | const inertia = await new InertiaFactory().merge({ ctx: ctx }).create() 114 | 115 | const middleware = new InertiaMiddleware({ 116 | versionCache: new VersionCache(new URL(import.meta.url), '1'), 117 | rootView: 'root', 118 | sharedData: {}, 119 | ssr: { enabled: false, bundle: '', entrypoint: '' }, 120 | history: { encrypt: false }, 121 | }) 122 | 123 | await middleware.handle(ctx, async () => { 124 | response.send(await inertia.render('Pages/Home', { username: 'foo', foo: 'bar' })) 125 | }) 126 | 127 | response.finish() 128 | }) 129 | 130 | const port = await getPort({ port: 3333 }) 131 | const url = `http://localhost:${port}` 132 | server.listen(port) 133 | 134 | await runJapaTest(app, async ({ client, assert }) => { 135 | const r1 = await client.get(url).withInertia() 136 | 137 | assert.throws(() => r1.assertInertiaComponent('Bar/Login')) 138 | assert.throws(() => r1.assertInertiaProps({ username: 'nopew' })) 139 | assert.throws(() => r1.assertInertiaPropsContains({ foo: 'nopew' })) 140 | }) 141 | }) 142 | 143 | test('api client properties should contains correct data', async () => { 144 | const server = httpServer.create(async (req, res) => { 145 | const request = new RequestFactory().merge({ req, res }).create() 146 | const response = new ResponseFactory().merge({ req, res }).create() 147 | const ctx = new HttpContextFactory().merge({ request, response }).create() 148 | const inertia = await new InertiaFactory().merge({ ctx: ctx }).create() 149 | 150 | const middleware = new InertiaMiddleware({ 151 | versionCache: new VersionCache(new URL(import.meta.url), '1'), 152 | rootView: 'root', 153 | sharedData: {}, 154 | ssr: { enabled: false, bundle: '', entrypoint: '' }, 155 | history: { encrypt: false }, 156 | }) 157 | 158 | await middleware.handle(ctx, async () => { 159 | response.send(await inertia.render('Pages/Home', { username: 'foo', foo: 'bar' })) 160 | }) 161 | 162 | response.finish() 163 | }) 164 | 165 | const port = await getPort({ port: 3333 }) 166 | const url = `http://localhost:${port}` 167 | server.listen(port) 168 | 169 | await runJapaTest(app, async ({ client, assert }) => { 170 | const r1 = await client.get(url).withInertia() 171 | 172 | assert.deepEqual(r1.inertiaComponent, 'Pages/Home') 173 | assert.deepEqual(r1.inertiaProps, { username: 'foo', foo: 'bar' }) 174 | }) 175 | }) 176 | }) 177 | -------------------------------------------------------------------------------- /tests/plugins/edge.plugin.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { Edge } from 'edge.js' 11 | import { test } from '@japa/runner' 12 | 13 | import { edgePluginInertia } from '../../src/plugins/edge/plugin.js' 14 | 15 | test.group('Edge plugin', () => { 16 | test('@inertia generate a root div with data-page', async ({ assert }) => { 17 | const edge = Edge.create().use(edgePluginInertia()) 18 | 19 | const html = await edge.renderRaw(`@inertia()`, { page: {} }) 20 | 21 | assert.deepEqual(html.split('\n'), ['
']) 22 | }) 23 | 24 | test('@inertia generate a root dive with data-page filled and encoded', async ({ assert }) => { 25 | const edge = Edge.create().use(edgePluginInertia()) 26 | 27 | const html = await edge.renderRaw(`@inertia()`, { 28 | page: { foo: 'bar' }, 29 | }) 30 | 31 | assert.deepEqual(html.split('\n'), [ 32 | '
', 33 | ]) 34 | }) 35 | 36 | test('throws if passing invalid argument', async () => { 37 | const edge = Edge.create().use(edgePluginInertia()) 38 | 39 | await edge.renderRaw(`@inertia('foo')`, { page: {} }) 40 | }).throws(`"('foo')" is not a valid argument for @inertia`) 41 | 42 | test('pass class to @inertia', async ({ assert }) => { 43 | const edge = Edge.create().use(edgePluginInertia()) 44 | 45 | const html = await edge.renderRaw(`@inertia({ class: 'foo' })`, { 46 | page: {}, 47 | }) 48 | 49 | assert.deepEqual(html.split('\n'), ['
']) 50 | }) 51 | 52 | test('pass id to @inertia', async ({ assert }) => { 53 | const edge = Edge.create().use(edgePluginInertia()) 54 | 55 | const html = await edge.renderRaw(`@inertia({ id: 'foo' })`, { 56 | page: {}, 57 | }) 58 | 59 | assert.deepEqual(html.split('\n'), ['
']) 60 | }) 61 | 62 | test('works with variable reference', async ({ assert }) => { 63 | const edge = Edge.create().use(edgePluginInertia()) 64 | 65 | const html = await edge.renderRaw(`@inertia({ class: mainClass })`, { 66 | mainClass: 'foo bar', 67 | page: {}, 68 | }) 69 | 70 | assert.deepEqual(html.split('\n'), ['
']) 71 | }) 72 | 73 | test('works with function call', async ({ assert }) => { 74 | const edge = Edge.create().use(edgePluginInertia()) 75 | 76 | const html = await edge.renderRaw(`@inertia({ class: mainClass() })`, { 77 | mainClass() { 78 | return 'foo bar' 79 | }, 80 | page: {}, 81 | }) 82 | 83 | assert.deepEqual(html.split('\n'), ['
']) 84 | }) 85 | 86 | test('render root div as another tag', async ({ assert }) => { 87 | const edge = Edge.create().use(edgePluginInertia()) 88 | 89 | const html = await edge.renderRaw(`@inertia({ as: 'main' })`, { 90 | page: {}, 91 | }) 92 | 93 | assert.deepEqual(html.split('\n'), ['
']) 94 | }) 95 | 96 | test('@inertia just insert the ssrBody if present', async ({ assert }) => { 97 | const edge = Edge.create().use(edgePluginInertia()) 98 | 99 | const html = await edge.renderRaw(`@inertia()`, { 100 | page: { ssrBody: '
foo
' }, 101 | }) 102 | 103 | assert.deepEqual(html.split('\n'), ['
foo
']) 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /tests/provider.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@japa/runner' 2 | import { IgnitorFactory } from '@adonisjs/core/factories' 3 | 4 | import { defineConfig } from '../index.js' 5 | import { defineConfig as viteDefineConfig } from '@adonisjs/vite' 6 | import InertiaMiddleware from '../src/inertia_middleware.js' 7 | import { Route } from '@adonisjs/core/http' 8 | 9 | const BASE_URL = new URL('./tmp/', import.meta.url) 10 | const IMPORTER = (filePath: string) => { 11 | if (filePath.startsWith('./') || filePath.startsWith('../')) { 12 | return import(new URL(filePath, BASE_URL).href) 13 | } 14 | return import(filePath) 15 | } 16 | 17 | test.group('Inertia Provider', () => { 18 | test('register inertia middleware singleton', async ({ assert, cleanup }) => { 19 | const ignitor = new IgnitorFactory() 20 | .merge({ 21 | rcFileContents: { 22 | providers: [ 23 | () => import('../providers/inertia_provider.js'), 24 | () => import('@adonisjs/vite/vite_provider'), 25 | ], 26 | }, 27 | }) 28 | .withCoreConfig() 29 | .withCoreProviders() 30 | .merge({ 31 | config: { inertia: defineConfig({ rootView: 'root' }), vite: viteDefineConfig({}) }, 32 | }) 33 | .create(BASE_URL, { importer: IMPORTER }) 34 | 35 | const app = ignitor.createApp('web') 36 | await app.init() 37 | await app.boot() 38 | 39 | cleanup(() => app.terminate()) 40 | 41 | assert.instanceOf(await app.container.make(InertiaMiddleware), InertiaMiddleware) 42 | }) 43 | 44 | test('register brisk route macro', async ({ assert, cleanup, expectTypeOf }) => { 45 | const ignitor = new IgnitorFactory() 46 | .merge({ 47 | rcFileContents: { 48 | providers: [ 49 | () => import('../providers/inertia_provider.js'), 50 | () => import('@adonisjs/vite/vite_provider'), 51 | ], 52 | }, 53 | }) 54 | .withCoreConfig() 55 | .withCoreProviders() 56 | .merge({ 57 | config: { inertia: defineConfig({ rootView: 'root' }), vite: viteDefineConfig({}) }, 58 | }) 59 | .create(BASE_URL, { importer: IMPORTER }) 60 | 61 | const app = ignitor.createApp('web') 62 | await app.init() 63 | await app.boot() 64 | 65 | cleanup(() => app.terminate()) 66 | 67 | const router = await app.container.make('router') 68 | 69 | assert.property(router.on('foo'), 'renderInertia') 70 | expectTypeOf(router.on('foo').renderInertia('/foo')).toEqualTypeOf() 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /tests/types.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | 12 | import { InferPageProps } from '../src/types.js' 13 | import { InertiaFactory } from '../factories/inertia_factory.js' 14 | 15 | test.group('Types', () => { 16 | test('assign interface to render', async () => { 17 | const inertia = await new InertiaFactory().create() 18 | 19 | interface MyRouteResponse { 20 | foo: string 21 | } 22 | 23 | inertia.render('foo', null as any).catch(() => {}) 24 | }) 25 | 26 | test('no ts error if generic is not passed', async () => { 27 | const inertia = await new InertiaFactory().create() 28 | 29 | inertia.render('foo', { foo: 1 }).catch(() => {}) 30 | }) 31 | 32 | test('ts error if page props doesnt match generic', async () => { 33 | const inertia = await new InertiaFactory().create() 34 | 35 | interface MyRouteResponse { 36 | foo: string 37 | } 38 | 39 | // @ts-expect-error props doesn't match generic 40 | inertia.render('foo', { foo: 1 }).catch(() => {}) 41 | }) 42 | 43 | test('ts error if view props doesnt match generic', async () => { 44 | const inertia = await new InertiaFactory().create() 45 | 46 | interface MyViewProps { 47 | metaTitle: string 48 | } 49 | 50 | // @ts-expect-error props doesn't match generic 51 | inertia.render('foo', { foo: 1 }, { foo: 32 }).catch(() => {}) 52 | }) 53 | 54 | test('able to extract PageProps from inertia.render', async ({ expectTypeOf }) => { 55 | const inertia = await new InertiaFactory().create() 56 | 57 | class Controller { 58 | index() { 59 | return inertia.render('foo', { foo: 1 }) 60 | } 61 | } 62 | 63 | type SentProps = Exclude>, string>['props'] 64 | expectTypeOf().toEqualTypeOf<{ foo: number }>() 65 | }) 66 | 67 | test('InferPageProps helper', async ({ expectTypeOf }) => { 68 | const inertia = await new InertiaFactory().create() 69 | 70 | class Controller { 71 | index() { 72 | return inertia.render('foo', { foo: 1 }) 73 | } 74 | } 75 | 76 | expectTypeOf>().toEqualTypeOf<{ foo: number }>() 77 | }) 78 | 79 | test('InferPageProps should serialize props', async ({ expectTypeOf }) => { 80 | const inertia = await new InertiaFactory().create() 81 | 82 | class Controller { 83 | index() { 84 | return inertia.render('foo', { foo: new Date() }) 85 | } 86 | } 87 | 88 | expectTypeOf>().toEqualTypeOf<{ foo: string }>() 89 | }) 90 | 91 | test('InferPageProps with optional props', async ({ expectTypeOf }) => { 92 | const inertia = await new InertiaFactory().create() 93 | 94 | class Controller { 95 | index() { 96 | return inertia.render('foo', { 97 | bar: inertia.optional(() => 'bar'), 98 | foo: inertia.optional(() => new Date()), 99 | bar2: 'bar2', 100 | }) 101 | } 102 | 103 | edit() { 104 | return inertia.render('foo', { 105 | bar: inertia.optional(() => 'bar'), 106 | }) 107 | } 108 | } 109 | 110 | expectTypeOf>().toEqualTypeOf<{ 111 | bar?: string 112 | foo?: string 113 | bar2: string 114 | }>() 115 | 116 | expectTypeOf>().toEqualTypeOf<{ 117 | bar?: string 118 | }>() 119 | }) 120 | 121 | test('inferPageProps with deferred, optional and mergeable props', async ({ expectTypeOf }) => { 122 | const inertia = await new InertiaFactory().create() 123 | 124 | class Controller { 125 | edit() { 126 | return inertia.render('foo', { 127 | optional: inertia.optional(() => 'bar'), 128 | deferred: inertia.defer(async () => 'deferred'), 129 | deferredMerged: inertia.defer(async () => 'deferred').merge(), 130 | mergeable: inertia.merge(async () => 'mergeable'), 131 | always: inertia.always(() => 'always'), 132 | }) 133 | } 134 | } 135 | 136 | expectTypeOf>().toEqualTypeOf<{ 137 | optional?: string | undefined 138 | deferred?: string | undefined 139 | deferredMerged?: string | undefined 140 | mergeable: string 141 | always: string 142 | }>() 143 | }) 144 | 145 | test('InferPageProps with empty props', async ({ expectTypeOf }) => { 146 | const inertia = await new InertiaFactory().create() 147 | 148 | class Controller { 149 | index() { 150 | return inertia.render('foo') 151 | } 152 | } 153 | 154 | type Props = InferPageProps 155 | expectTypeOf().toEqualTypeOf<{}>() 156 | }) 157 | 158 | test('multiple render calls', async ({ expectTypeOf }) => { 159 | const inertia = await new InertiaFactory().create() 160 | 161 | class Controller { 162 | index() { 163 | if (Math.random() > 0.5) { 164 | return inertia.render('foo', { bar: 1 }) 165 | } 166 | 167 | return inertia.render('foo', { foo: 1 }) 168 | } 169 | } 170 | 171 | expectTypeOf>().toEqualTypeOf< 172 | { bar: number } | { foo: number } 173 | >() 174 | }) 175 | 176 | test('ignore non-PageObject returns from controller', async ({ expectTypeOf }) => { 177 | const inertia = await new InertiaFactory().create() 178 | 179 | class Controller { 180 | index() { 181 | if (Math.random() > 0.5) return 182 | if (Math.random() > 0.5) return { foo: 1 } 183 | 184 | return inertia.render('foo', { foo: 1 }) 185 | } 186 | } 187 | 188 | expectTypeOf>().toEqualTypeOf<{ foo: number }>() 189 | }) 190 | 191 | test('infer page props with regular callback function', async ({ expectTypeOf }) => { 192 | const inertia = await new InertiaFactory().create() 193 | 194 | class Controller { 195 | index() { 196 | return inertia.render('foo', { 197 | user: 'jul', 198 | lazy: () => 'jul' as string, 199 | }) 200 | } 201 | } 202 | 203 | type Result = InferPageProps 204 | expectTypeOf().toEqualTypeOf<{ user: string; lazy: string }>() 205 | }) 206 | }) 207 | -------------------------------------------------------------------------------- /tests/version_cache.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/inertia 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { randomBytes } from 'node:crypto' 12 | 13 | import { VersionCache } from '../src/version_cache.js' 14 | 15 | test.group('Version Cache', () => { 16 | test('compute hash from manifest file', async ({ assert, fs }) => { 17 | const version = new VersionCache(fs.baseUrl) 18 | 19 | await fs.create('public/assets/.vite/manifest.json', randomBytes(1024 * 1024).toString('hex')) 20 | await version.computeVersion() 21 | 22 | assert.isDefined(version.getVersion()) 23 | }) 24 | 25 | test('hash is the same if manifest file does not change', async ({ assert, fs }) => { 26 | const version = new VersionCache(fs.baseUrl) 27 | 28 | await fs.create('public/assets/.vite/manifest.json', randomBytes(1024 * 1024).toString('hex')) 29 | 30 | await version.computeVersion() 31 | const r1 = version.getVersion() 32 | version.setVersion(undefined) 33 | await version.computeVersion() 34 | 35 | assert.equal(r1, version.getVersion()) 36 | }) 37 | 38 | test('hash is different if manifest file changes', async ({ assert, fs }) => { 39 | const version = new VersionCache(fs.baseUrl) 40 | 41 | await fs.create('public/assets/.vite/manifest.json', randomBytes(1024 * 1024).toString('hex')) 42 | 43 | await version.computeVersion() 44 | const r1 = version.getVersion() 45 | 46 | await fs.create('public/assets/.vite/manifest.json', randomBytes(1024 * 1024).toString('hex')) 47 | version.setVersion(undefined) 48 | await version.computeVersion() 49 | 50 | assert.notEqual(r1, version.getVersion()) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json", 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "./build" 6 | } 7 | } 8 | --------------------------------------------------------------------------------