diff --git a/factories/inertia_factory.ts b/factories/inertia_factory.ts index 99d35a0..0f8d3a1 100644 --- a/factories/inertia_factory.ts +++ b/factories/inertia_factory.ts @@ -7,10 +7,11 @@ * file that was distributed with this source code. */ +import type { ViteRuntime } from 'vite/runtime' import { HttpContext } from '@adonisjs/core/http' import { AppFactory } from '@adonisjs/core/factories/app' -import { HttpContextFactory } from '@adonisjs/core/factories/http' import { ApplicationService } from '@adonisjs/core/types' +import { HttpContextFactory } from '@adonisjs/core/factories/http' import { defineConfig } from '../index.js' import { Inertia } from '../src/inertia.js' @@ -29,6 +30,7 @@ export class InertiaFactory { #parameters: FactoryParameters = { ctx: new HttpContextFactory().create(), } + #viteRuntime?: ViteRuntime #getApp() { return new AppFactory().create(new URL('./', import.meta.url), () => {}) as ApplicationService @@ -50,6 +52,11 @@ export class InertiaFactory { return this } + withViteRuntime(runtime: { executeEntrypoint: (path: string) => Promise }) { + this.#viteRuntime = runtime as any + return this + } + withInertiaPartialData(data: string[]) { this.#parameters.ctx.request.request.headers['x-inertia-partial-data'] = data.join(',') return this @@ -62,6 +69,6 @@ export class InertiaFactory { async create() { const config = await defineConfig(this.#parameters.config || {}).resolver(this.#getApp()) - return new Inertia(this.#parameters.ctx, config) + return new Inertia(this.#parameters.ctx, config, this.#viteRuntime) } } diff --git a/package.json b/package.json index 213807e..d9c7832 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "./inertia_middleware": "./build/src/inertia_middleware.js", "./inertia_provider": "./build/providers/inertia_provider.js", "./plugins/edge": "./build/src/plugins/edge/plugin.js", - "./plugins/api_client": "./build/src/plugins/api_client.js" + "./plugins/api_client": "./build/src/plugins/japa/api_client.js", + "./client": "./build/src/plugins/vite.js" }, "scripts": { "clean": "del-cli build", @@ -36,39 +37,42 @@ "prepublishOnly": "npm run build" }, "devDependencies": { - "@adonisjs/assembler": "^7.0.0", - "@adonisjs/core": "6.2.0", + "@adonisjs/assembler": "^7.2.1", + "@adonisjs/core": "6.3.0", "@adonisjs/eslint-config": "^1.2.1", "@adonisjs/prettier-config": "^1.2.1", - "@adonisjs/session": "7.0.0", + "@adonisjs/session": "7.1.1", "@adonisjs/tsconfig": "^1.2.1", + "@adonisjs/vite": "^3.0.0-0", "@japa/api-client": "^2.0.2", "@japa/assert": "2.1.0", "@japa/expect-type": "^2.0.1", - "@japa/file-system": "^2.1.1", - "@japa/plugin-adonisjs": "^2.0.2", + "@japa/file-system": "^2.2.0", + "@japa/plugin-adonisjs": "^2.0.3", "@japa/runner": "3.1.1", - "@swc/core": "^1.3.102", - "@types/node": "^20.10.8", + "@swc/core": "^1.4.2", + "@types/node": "^20.11.20", "@types/qs": "^6.9.11", "@types/supertest": "^6.0.2", - "c8": "^9.0.0", + "@vavite/multibuild": "^4.1.1", + "c8": "^9.1.0", "copyfiles": "^2.4.1", "del-cli": "^5.1.0", "edge-parser": "^9.0.1", "edge.js": "^6.0.1", - "eslint": "^8.56.0", + "eslint": "^8.57.0", "get-port": "^7.0.0", "np": "^9.2.0", - "prettier": "^3.1.1", - "supertest": "^6.3.3", - "tinybench": "^2.5.1", + "prettier": "^3.2.5", + "supertest": "^6.3.4", + "tinybench": "^2.6.0", "ts-node": "^10.9.2", - "tsup": "^8.0.1", - "typescript": "~5.3.3" + "tsup": "^8.0.2", + "typescript": "~5.3.3", + "vite": "^5.1.4" }, "dependencies": { - "@poppinss/utils": "^6.7.0", + "@poppinss/utils": "^6.7.2", "crc-32": "^1.2.2", "edge-error": "^4.0.1", "html-entities": "^2.4.0", @@ -77,6 +81,7 @@ "peerDependencies": { "@adonisjs/core": "^6.2.0", "@adonisjs/session": "^7.0.0", + "@adonisjs/vite": "^2.0.2", "@japa/api-client": "^2.0.0", "edge.js": "^6.0.0" }, diff --git a/providers/inertia_provider.ts b/providers/inertia_provider.ts index 7041f7e..cc1d38d 100644 --- a/providers/inertia_provider.ts +++ b/providers/inertia_provider.ts @@ -7,6 +7,8 @@ * file that was distributed with this source code. */ +/// + import { configProvider } from '@adonisjs/core' import { RuntimeException } from '@poppinss/utils' import type { ApplicationService } from '@adonisjs/core/types' @@ -38,6 +40,7 @@ export default class InertiaProvider { this.app.container.singleton(InertiaMiddleware, async () => { const inertiaConfigProvider = this.app.config.get('inertia') const config = await configProvider.resolve(this.app, inertiaConfigProvider) + const vite = await this.app.container.make('vite') if (!config) { throw new RuntimeException( @@ -45,7 +48,7 @@ export default class InertiaProvider { ) } - return new InertiaMiddleware(config) + return new InertiaMiddleware(config, vite) }) await this.registerEdgePlugin() diff --git a/src/define_config.ts b/src/define_config.ts index 890e150..0a456bf 100644 --- a/src/define_config.ts +++ b/src/define_config.ts @@ -25,6 +25,12 @@ export function defineConfig(config: InertiaConfig): ConfigProvider +import type { Vite } from '@adonisjs/vite' import type { HttpContext } from '@adonisjs/core/http' + import type { Data, MaybePromise, PageProps, ResolvedConfig, SharedData } from './types.js' /** @@ -25,7 +27,8 @@ export class Inertia { constructor( protected ctx: HttpContext, - protected config: ResolvedConfig + protected config: ResolvedConfig, + protected viteRuntime?: ReturnType ) { this.#sharedData = config.sharedData } @@ -97,6 +100,41 @@ export class Inertia { } } + /** + * If the page should be rendered on the server + */ + #shouldRenderOnServer(component: string) { + const isSsrEnabled = this.config.ssr.enabled + const isSsrEnabledForPage = this.config.ssr.pages + ? this.config.ssr.pages.includes(component) + : true + + return isSsrEnabled && isSsrEnabledForPage + } + + /** + * Render the page on the server + * + * On development, we use the Vite Runtime API + * On production, we just import and use the SSR bundle generated by Vite + */ + async #renderOnServer(pageObject: any, viewProps?: Record) { + let render: { default: (page: any) => Promise<{ head: string; body: string }> } + + if (this.viteRuntime) { + render = await this.viteRuntime.executeEntrypoint(this.config.ssr.entrypoint!) + } else { + render = await import(this.config.ssr.bundle!) + } + + const result = await render.default(pageObject) + + return this.ctx.view.render(this.config.rootView, { + ...viewProps, + page: { ssrHead: result.head, ssrBody: result.body }, + }) + } + /** * Share data for the current request. * This data will override any shared data defined in the config. @@ -116,6 +154,9 @@ export class Inertia { const isInertiaRequest = !!this.ctx.request.header('x-inertia') if (!isInertiaRequest) { + const shouldRenderOnServer = this.#shouldRenderOnServer(component) + if (shouldRenderOnServer) return this.#renderOnServer(pageObject, viewProps) + return this.ctx.view.render(this.config.rootView, { ...viewProps, page: pageObject }) } diff --git a/src/inertia_middleware.ts b/src/inertia_middleware.ts index f9b5fe9..7ec2249 100644 --- a/src/inertia_middleware.ts +++ b/src/inertia_middleware.ts @@ -7,6 +7,7 @@ * file that was distributed with this source code. */ +import type { Vite } from '@adonisjs/vite' import type { HttpContext } from '@adonisjs/core/http' import type { NextFn } from '@adonisjs/core/types/http' @@ -27,12 +28,19 @@ declare module '@adonisjs/core/http' { * set appropriate headers/status */ export default class InertiaMiddleware { - constructor(protected config: ResolvedConfig) {} + #runtime: ReturnType | undefined + + constructor( + protected config: ResolvedConfig, + vite?: Vite + ) { + this.#runtime = vite?.getRuntime() + } async handle(ctx: HttpContext, next: NextFn) { const { response, request } = ctx - ctx.inertia = new Inertia(ctx, this.config) + ctx.inertia = new Inertia(ctx, this.config, this.#runtime) await next() diff --git a/src/plugins/vite.ts b/src/plugins/vite.ts new file mode 100644 index 0000000..f35e6f5 --- /dev/null +++ b/src/plugins/vite.ts @@ -0,0 +1,66 @@ +/* + * @adonisjs/inertia + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/// + +import type { PluginOption } from 'vite' + +export type InertiaPluginOptions = { + ssr?: + | { + /** + * Whether or not to enable server-side rendering + */ + enabled: true + + /** + * The entrypoint for the server-side rendering + */ + entrypoint: string + + /** + * The output directory for the server-side rendering bundle + */ + output?: string + } + | { enabled: false } +} + +/** + * Inertia plugin for Vite that is tailored for AdonisJS + */ +export default function inertia(options: InertiaPluginOptions): PluginOption { + return { + name: 'vite-plugin-inertia', + config: () => { + if (!options.ssr?.enabled) return {} + + return { + buildSteps: [ + { + name: 'build-client', + description: 'build inertia client bundle', + config: { build: { outDir: 'build/public/assets/' } }, + }, + { + name: 'build-ssr', + description: 'build inertia server bundle', + config: { + build: { + ssr: true, + outDir: options.ssr.output || 'build/ssr', + rollupOptions: { input: options.ssr.entrypoint }, + }, + }, + }, + ], + } + }, + } +} diff --git a/src/types.ts b/src/types.ts index a8f1e0c..760004e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,6 +46,31 @@ export interface InertiaConfig { * Data that should be shared with all rendered pages */ sharedData?: SharedData + + /** + * Options to configure SSR + */ + ssr?: { + /** + * Enable or disable SSR + */ + enabled: boolean + + /** + * List of components that should be rendered on the server + */ + pages?: string[] + + /** + * Path to the SSR entrypoint file + */ + entrypoint?: string + + /** + * Path to the SSR bundled file that will be used in production + */ + bundle?: string + } } /** @@ -55,4 +80,10 @@ export interface ResolvedConfig { rootView: string versionCache: VersionCache sharedData: SharedData + ssr: { + enabled: boolean + entrypoint: string + pages?: string[] + bundle: string + } } diff --git a/tests/configure.spec.ts b/tests/configure.spec.ts index 1b4bb5c..915c163 100644 --- a/tests/configure.spec.ts +++ b/tests/configure.spec.ts @@ -14,7 +14,6 @@ import { BASE_URL } from '../tests_helpers/index.js' import { FileSystem } from '@japa/file-system' async function setupApp() { - console.log(BASE_URL) const ignitor = new IgnitorFactory() .withCoreProviders() .withCoreConfig() diff --git a/tests/inertia.spec.ts b/tests/inertia.spec.ts index c865664..7c04ed2 100644 --- a/tests/inertia.spec.ts +++ b/tests/inertia.spec.ts @@ -7,6 +7,7 @@ * file that was distributed with this source code. */ +import { join } from 'node:path' import { test } from '@japa/runner' import { HttpContext } from '@adonisjs/core/http' import { HttpContextFactory, RequestFactory } from '@adonisjs/core/factories/http' @@ -255,3 +256,84 @@ test.group('Inertia', () => { assert.deepEqual(result.props, { foo: 'baz' }) }) }) + +test.group('Inertia | Ssr', () => { + test('if viteRuntime is available, use entrypoint file to render the page', async ({ + assert, + }) => { + setupViewMacroMock() + + const inertia = await new InertiaFactory() + .merge({ + config: { + ssr: { + enabled: true, + entrypoint: 'foo.ts', + }, + }, + }) + .withViteRuntime({ + async executeEntrypoint(path) { + return { default: () => ({ head: 'head', body: path }) } + }, + }) + .create() + + const result: any = await inertia.render('foo') + + assert.deepEqual(result.props.page.ssrHead, 'head') + assert.deepEqual(result.props.page.ssrBody, 'foo.ts') + }) + + test('if viteRuntime is not available, use bundle file to render the page', async ({ + assert, + fs, + }) => { + setupViewMacroMock() + + await fs.create('foo.js', 'export default () => ({ head: "head", body: "foo.ts" })') + + const inertia = await new InertiaFactory() + .merge({ + config: { + ssr: { + enabled: true, + bundle: join(fs.basePath, 'foo.js'), + }, + }, + }) + .create() + + const result: any = await inertia.render('foo') + + assert.deepEqual(result.props.page.ssrBody, 'foo.ts') + assert.deepEqual(result.props.page.ssrHead, 'head') + }) + + test('enable only for listed pages', async ({ assert }) => { + setupViewMacroMock() + + const inertia = await new InertiaFactory() + .withViteRuntime({ + async executeEntrypoint(path) { + return { default: () => ({ head: 'head', body: path }) } + }, + }) + .merge({ + config: { + ssr: { + enabled: true, + entrypoint: 'foo.ts', + pages: ['foo'], + }, + }, + }) + .create() + + const result: any = await inertia.render('foo') + const result2: any = await inertia.render('bar') + + assert.deepEqual(result.props.page.ssrBody, 'foo.ts') + assert.notExists(result2.props.page.ssrBody) + }) +}) diff --git a/tests/middleware.spec.ts b/tests/middleware.spec.ts index 6939026..e54b385 100644 --- a/tests/middleware.spec.ts +++ b/tests/middleware.spec.ts @@ -25,6 +25,7 @@ test.group('Middleware', () => { rootView: 'root', sharedData: {}, versionCache: new VersionCache(new URL(import.meta.url), '1'), + ssr: { enabled: false, bundle: '', entrypoint: '' }, }) await middleware.handle(ctx, () => {}) @@ -46,6 +47,7 @@ test.group('Middleware', () => { rootView: 'root', sharedData: {}, versionCache: new VersionCache(new URL(import.meta.url), '1'), + ssr: { enabled: false, bundle: '', entrypoint: '' }, }) await middleware.handle(ctx, () => { @@ -74,6 +76,7 @@ test.group('Middleware', () => { rootView: 'root', sharedData: {}, versionCache: new VersionCache(new URL(import.meta.url), '1'), + ssr: { enabled: false, bundle: '', entrypoint: '' }, }) await middleware.handle(ctx, () => { @@ -102,6 +105,7 @@ test.group('Middleware', () => { rootView: 'root', sharedData: {}, versionCache: new VersionCache(new URL(import.meta.url), '1'), + ssr: { enabled: false, bundle: '', entrypoint: '' }, }) await middleware.handle(ctx, () => { @@ -128,6 +132,7 @@ test.group('Middleware', () => { rootView: 'root', sharedData: {}, versionCache: new VersionCache(new URL(import.meta.url), '1'), + ssr: { enabled: false, bundle: '', entrypoint: '' }, }) await middleware.handle(ctx, () => {}) @@ -148,6 +153,7 @@ test.group('Middleware', () => { rootView: 'root', sharedData: {}, versionCache: version, + ssr: { enabled: false, bundle: '', entrypoint: '' }, }) const server = httpServer.create(async (req, res) => { @@ -178,6 +184,7 @@ test.group('Middleware', () => { rootView: 'root', sharedData: {}, versionCache: version, + ssr: { enabled: false, bundle: '', entrypoint: '' }, }) const server = httpServer.create(async (req, res) => { diff --git a/tests/plugins/api_client.spec.ts b/tests/plugins/api_client.spec.ts index 3d4e06a..34e4f14 100644 --- a/tests/plugins/api_client.spec.ts +++ b/tests/plugins/api_client.spec.ts @@ -74,6 +74,7 @@ test.group('Japa plugin | Api Client', (group) => { versionCache: new VersionCache(new URL(import.meta.url), '1'), rootView: 'root', sharedData: {}, + ssr: { enabled: false, bundle: '', entrypoint: '' }, }) await middleware.handle(ctx, async () => { @@ -114,6 +115,7 @@ test.group('Japa plugin | Api Client', (group) => { versionCache: new VersionCache(new URL(import.meta.url), '1'), rootView: 'root', sharedData: {}, + ssr: { enabled: false, bundle: '', entrypoint: '' }, }) await middleware.handle(ctx, async () => { @@ -147,6 +149,7 @@ test.group('Japa plugin | Api Client', (group) => { versionCache: new VersionCache(new URL(import.meta.url), '1'), rootView: 'root', sharedData: {}, + ssr: { enabled: false, bundle: '', entrypoint: '' }, }) await middleware.handle(ctx, async () => { diff --git a/tests/provider.spec.ts b/tests/provider.spec.ts index 41d773a..affd3fd 100644 --- a/tests/provider.spec.ts +++ b/tests/provider.spec.ts @@ -2,6 +2,7 @@ import { test } from '@japa/runner' import { IgnitorFactory } from '@adonisjs/core/factories' import { defineConfig } from '../index.js' +import { defineConfig as viteDefineConfig } from '@adonisjs/vite' import InertiaMiddleware from '../src/inertia_middleware.js' const BASE_URL = new URL('./tmp/', import.meta.url) @@ -15,10 +16,19 @@ const IMPORTER = (filePath: string) => { test.group('Inertia Provider', () => { test('register inertia middleware singleton', async ({ assert }) => { const ignitor = new IgnitorFactory() - .merge({ rcFileContents: { providers: [() => import('../providers/inertia_provider.js')] } }) + .merge({ + rcFileContents: { + providers: [ + () => import('../providers/inertia_provider.js'), + () => import('@adonisjs/vite/vite_provider'), + ], + }, + }) .withCoreConfig() .withCoreProviders() - .merge({ config: { inertia: defineConfig({ rootView: 'root' }) } }) + .merge({ + config: { inertia: defineConfig({ rootView: 'root' }), vite: viteDefineConfig({}) }, + }) .create(BASE_URL, { importer: IMPORTER }) const app = ignitor.createApp('web')