diff --git a/README.md b/README.md index ad8551fe..9492f930 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ Switching frameworks should not require rewriting business logic. | --------- | ------- | | Express | Stable | | H3 | Stable | -| Fastify | Beta | +| Fastify | Stable | | Bun | Planned | New adapters can be added without affecting the application layer. diff --git a/create-arkstack/src/templates.ts b/create-arkstack/src/templates.ts index 31b6168d..6c83eff0 100644 --- a/create-arkstack/src/templates.ts +++ b/create-arkstack/src/templates.ts @@ -37,7 +37,6 @@ export const templates: Template[] = [ alias: 'fastify', hint: 'Arkstack application running on Fastify', source: 'github:arkstack-hq/arkstack', - inertia: false, }, // { // name: 'H3 Lean Runtime Template', diff --git a/docs/api.md b/docs/api.md index ddbd68a7..13e20b6e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -125,12 +125,7 @@ NODE_ENV=production pnpm exec tsdown ## Template Profiles -Arkstack currently provides four template aliases in `create-arkstack`: - -- `express` -- `express-lean` -- `h3` -- `h3-lean` +`create-arkstack` provides three runtime templates: `express`, `h3` and `fastify`. Each one can be scaffolded with the full or the lean profile. Pick the profile at the prompt, or pass `--lean` or `--full`. ### Full Profiles diff --git a/docs/guide/fastify-runtime.md b/docs/guide/fastify-runtime.md index 3ff6b60d..aaf235ca 100644 --- a/docs/guide/fastify-runtime.md +++ b/docs/guide/fastify-runtime.md @@ -128,6 +128,6 @@ Host, port, dev HTTPS (`APP_SECURE`) and tunnels (`TUNNEL`, `TUNNEL_URL`) work t ## Notes -- Inertia is not available on Fastify yet. +- Inertia works the same as on Express and H3. See [Inertia](/guide/inertia). - The error handler is set when the app is created, so routes added at any point use it. - `app.boot(port)` sets the app up once; later calls only start the server. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index e5c404ed..a683726b 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -59,11 +59,11 @@ $ yarn create arkstack@latest my-app ## Available Templates -- `express` - full Arkstack app on the Express driver with database features -- `express-lean` - lean Express-driver app stripped of database features and app scaffolding -- `h3` - full Arkstack app on the H3 driver with database features -- `h3-lean` - lean H3-driver app stripped of database features and app scaffolding -- `fastify` - full Arkstack app on the Fastify driver with database features (no Inertia support yet) +- `express` - Arkstack app on the Express driver +- `h3` - Arkstack app on the H3 driver +- `fastify` - Arkstack app on the Fastify driver + +Each template comes in two profiles. The full profile includes the app scaffolding and database features. The lean profile strips them out. Pick the profile at the prompt, or pass `--lean` or `--full`. ## Install and Run diff --git a/docs/guide/inertia.md b/docs/guide/inertia.md index b258b3d6..23a9f0b4 100644 --- a/docs/guide/inertia.md +++ b/docs/guide/inertia.md @@ -54,6 +54,19 @@ export default (): MiddlewareConfig => { } ``` +```ts [Fastify] +// src/config/middleware.ts +import { inertia, resora } from '@arkstack/driver-fastify/middlewares' + +import { MiddlewareConfig } from '@arkstack/driver-fastify/types' + +export default (): MiddlewareConfig => { + return { + before: [resora(), inertia()], + } +} +``` + ::: ### 2. Publish the config and root template diff --git a/docs/more/roadmap.md b/docs/more/roadmap.md index fb4399f4..86af5123 100644 --- a/docs/more/roadmap.md +++ b/docs/more/roadmap.md @@ -22,8 +22,7 @@ This roadmap tracks Arkstack progress and upcoming priorities. - `dev` - `build` - ✅ Lean and full starter profiles in create-arkstack - - `express`, `h3`, `fastify` - - `express-lean`, `h3-lean` + - `express`, `h3`, `fastify`, each as a lean or full profile - ✅ Lean profile stripping of app/api/database scaffolding - Removes `src/app` - Removes `src/routes/api.ts` diff --git a/packages/driver-fastify/package.json b/packages/driver-fastify/package.json index 2d619177..cb0b4347 100644 --- a/packages/driver-fastify/package.json +++ b/packages/driver-fastify/package.json @@ -55,11 +55,15 @@ "@arkstack/auth": "workspace:^", "@arkstack/common": "workspace:^", "@arkstack/foundry": "workspace:^", + "@arkstack/inertia": "workspace:^", "fastify": "catalog:" }, "peerDependenciesMeta": { "@arkstack/auth": { "optional": true + }, + "@arkstack/inertia": { + "optional": true } } } diff --git a/packages/driver-fastify/src/Router.ts b/packages/driver-fastify/src/Router.ts index a243cb0f..c63d721b 100644 --- a/packages/driver-fastify/src/Router.ts +++ b/packages/driver-fastify/src/Router.ts @@ -6,6 +6,7 @@ import { Router as ClearRouter } from 'clear-router/fastify' import { type Route } from 'clear-router' import { arkstackHttpPlugin } from '@arkstack/http' import { clearRouterFastifyPlugin } from '@resora/plugin-clear-router' +import { existsSync } from 'node:fs' import { registerPlugin } from 'resora' registerPlugin(clearRouterFastifyPlugin) @@ -41,19 +42,25 @@ export class Router extends ClearRouter { } /** - * Registers the routes declared in the api and web route files. + * Registers the routes declared in the api and web route files. A missing + * file is skipped, since lean projects have no api routes. */ static async load(): Promise { - try { - await ClearRouter.group('/api', resolveRuntimeModule('src/routes/api.ts')) - } catch (e: any) { - Logger.error('ERROR: Unable to load "api.ts" routes: ' + e.message, false) - } + const groups = [ + ['/api', 'api.ts'], + ['/', 'web.ts'], + ] as const + + for (const [prefix, file] of groups) { + const source = resolveRuntimeModule(`src/routes/${file}`) + + if (!existsSync(source)) continue - try { - await ClearRouter.group('/', resolveRuntimeModule('src/routes/web.ts')) - } catch (e: any) { - Logger.error('ERROR: Unable to load "web.ts" routes: ' + e.message, false) + try { + await ClearRouter.group(prefix, source) + } catch (e: any) { + Logger.error(`ERROR: Unable to load "${file}" routes: ` + e.message, false) + } } } } diff --git a/packages/driver-fastify/src/middlewares/index.ts b/packages/driver-fastify/src/middlewares/index.ts index 266f5743..d0eec653 100644 --- a/packages/driver-fastify/src/middlewares/index.ts +++ b/packages/driver-fastify/src/middlewares/index.ts @@ -1,5 +1,6 @@ export * from './auth' export * from './formdata' +export * from './inertia' export * from './limiter' export * from './request-logger' export * from './resora' diff --git a/packages/driver-fastify/src/middlewares/inertia.ts b/packages/driver-fastify/src/middlewares/inertia.ts new file mode 100644 index 00000000..60b82ae0 --- /dev/null +++ b/packages/driver-fastify/src/middlewares/inertia.ts @@ -0,0 +1,45 @@ +import type { FastifyReply, FastifyRequest } from 'fastify' + +/** + * Binds the Inertia request context for Fastify. Register it in the `before` + * middleware position. + * + * It runs the rest of the request inside Inertia's async-local context, so + * `inertia()` and `Inertia.*` resolve the active request, and upgrades `302` + * redirects to `303 See Other` for `PUT`, `PATCH` and `DELETE` Inertia visits, + * which the Inertia client needs to follow the redirect with a `GET`. + * + * `@arkstack/inertia` is imported dynamically so the package stays optional. + */ +export const inertia = () => { + return (req: FastifyRequest, reply: FastifyReply, done: (error?: Error) => void) => { + import('@arkstack/inertia') + .then(({ runInertia, shouldUpgradeRedirect }) => { + const request = { + method: String(req.method ?? 'GET').toUpperCase(), + url: req.url || '/', + header: (name: string) => { + const value = req.headers[name.toLowerCase()] + + return Array.isArray(value) ? value[0] : value + }, + } + + if (request.header('x-inertia') === 'true') { + const code = reply.code.bind(reply) + + reply.code = reply.status = (statusCode: number) => + code(shouldUpgradeRedirect(request.method, statusCode) ? 303 : statusCode) + } + + runInertia(request, () => done()) + }) + .catch(done) + } +} + +export class InertiaMiddleware { + handle(req: FastifyRequest, reply: FastifyReply, done: (error?: Error) => void) { + return inertia()(req, reply, done) + } +} diff --git a/packages/driver-fastify/src/types.ts b/packages/driver-fastify/src/types.ts index 250f7766..1ea1f9a8 100644 --- a/packages/driver-fastify/src/types.ts +++ b/packages/driver-fastify/src/types.ts @@ -3,6 +3,7 @@ import type { FastifyInstance, FastifyPluginAsync, FastifyPluginCallback, + FastifyRegisterOptions, FastifyReply, FastifyRequest, } from 'fastify' @@ -29,11 +30,12 @@ export type FastifyErrorHandler = ( /** * A Fastify plugin with its options, registered when the app is created. + * Options can be a function, which Fastify calls when it loads the plugin. */ export type FastifyPluginEntry = | FastifyPluginAsync | FastifyPluginCallback - | [FastifyPluginAsync | FastifyPluginCallback, Record] + | [FastifyPluginAsync | FastifyPluginCallback, FastifyRegisterOptions] export interface FastifyDriverOptions { bindRouter: (app: FastifyInstance) => PromiseOrValue diff --git a/packages/driver-fastify/tests/fixtures/broken/src/routes/api.ts b/packages/driver-fastify/tests/fixtures/broken/src/routes/api.ts new file mode 100644 index 00000000..dda59402 --- /dev/null +++ b/packages/driver-fastify/tests/fixtures/broken/src/routes/api.ts @@ -0,0 +1 @@ +throw new Error('broken routes') diff --git a/packages/driver-fastify/tests/index.test.ts b/packages/driver-fastify/tests/index.test.ts index c3b9e334..8b235435 100644 --- a/packages/driver-fastify/tests/index.test.ts +++ b/packages/driver-fastify/tests/index.test.ts @@ -47,6 +47,19 @@ describe('FastifyDriver', () => { expect(plugin.mock.calls[0][1]).toMatchObject({ a: 1 }) }) + it('reads plugin options from a function when the plugin loads', async () => { + const plugin = vi.fn(async () => {}) + const options = vi.fn(() => ({ a: 2 })) + + ;({ app } = makeApp(new FastifyDriver({ bindRouter: vi.fn(), plugins: [[plugin, options]] }))) + + expect(options).not.toHaveBeenCalled() + + await app.ready() + + expect(plugin.mock.calls[0][1]).toMatchObject({ a: 2 }) + }) + it('runs global, before and after middleware at their lifecycle points', async () => { const seen: string[] = [] let finished!: () => void diff --git a/packages/driver-fastify/tests/inertia.test.ts b/packages/driver-fastify/tests/inertia.test.ts new file mode 100644 index 00000000..d17a101d --- /dev/null +++ b/packages/driver-fastify/tests/inertia.test.ts @@ -0,0 +1,113 @@ +import { Inertia, flushShared, setVersion } from '@arkstack/inertia' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import type { FastifyInstance } from 'fastify' +import { FastifyDriver, Router } from '../src/index' +import { inertia } from '../src/middlewares/inertia' + +describe('Fastify Inertia middleware', () => { + let app: FastifyInstance + + beforeEach(() => { + Router.reset() + }) + + afterEach(async () => { + await app?.close() + flushShared() + setVersion(null) + }) + + const boot = async (routes: () => void) => { + const driver = new FastifyDriver({ bindRouter: (instance) => void Router.apply(instance) }) + + app = driver.createApp() + routes() + await driver.bindRouter(app) + driver.applyMiddleware(app, { global: [], before: [inertia()], after: [] }) + await app.ready() + } + + it('serves an HTML document with the page on a first visit', async () => { + await boot(() => { + Router.get('/', () => Inertia.render('Home', { greeting: 'hi', heavy: Inertia.lazy(() => 'H') })) + }) + + const res = await app.inject().get('/').headers({ accept: 'text/html' }) + + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toContain('text/html') + expect(res.body).toContain('"component":"Home"') + expect(res.body).not.toContain('heavy') + }) + + it('returns the page object on an Inertia visit', async () => { + await boot(() => { + Router.get('/users', () => Inertia.render('Users/Index', { users: ['ada'], stats: () => 42 })) + }) + + const res = await app.inject().get('/users?page=2').headers({ 'x-inertia': 'true' }) + + expect(res.headers['x-inertia']).toBe('true') + expect(res.json()).toMatchObject({ + component: 'Users/Index', + props: { users: ['ada'], stats: 42 }, + url: '/users?page=2', + }) + }) + + it('filters props on a partial reload', async () => { + await boot(() => { + Router.get('/users', () => Inertia.render('Users/Index', { users: ['ada'], stats: () => 42 })) + }) + + const res = await app.inject().get('/users').headers({ + 'x-inertia': 'true', + 'x-inertia-partial-component': 'Users/Index', + 'x-inertia-partial-data': 'stats', + }) + + expect(res.json().props).toEqual({ stats: 42 }) + }) + + it('keeps props shared during the request', async () => { + await boot(() => { + Router.get('/shared', () => { + Inertia.share('appName', 'Arkstack') + + return Inertia.render('Shared', {}) + }) + }) + + const res = await app.inject().get('/shared').headers({ 'x-inertia': 'true' }) + + expect(res.json().props).toEqual({ appName: 'Arkstack' }) + }) + + it('answers an asset version mismatch with 409', async () => { + setVersion('v2') + + await boot(() => { + Router.get('/', () => Inertia.render('Home', {})) + }) + + const res = await app.inject().get('/').headers({ 'x-inertia': 'true', 'x-inertia-version': 'v1' }) + + expect(res.statusCode).toBe(409) + expect(res.headers['x-inertia-location']).toBe('/') + }) + + it('upgrades redirects after an Inertia PUT, PATCH or DELETE to 303', async () => { + await boot(() => { + Router.put('/users/1', (({ reply }: { reply: { redirect: (url: string) => unknown } }) => + reply.redirect('/users')) as never) + }) + + const inertiaVisit = await app.inject().put('/users/1').headers({ 'x-inertia': 'true' }) + const plainVisit = await app.inject().put('/users/1') + + expect(inertiaVisit.statusCode).toBe(303) + expect(inertiaVisit.headers.location).toBe('/users') + expect(plainVisit.statusCode).toBe(302) + }) +}) diff --git a/packages/driver-fastify/tests/router.test.ts b/packages/driver-fastify/tests/router.test.ts index 642c96ea..c3adc24e 100644 --- a/packages/driver-fastify/tests/router.test.ts +++ b/packages/driver-fastify/tests/router.test.ts @@ -104,16 +104,24 @@ describe('Fastify Router', () => { expect(missing.json().message).toBe('Cannot find any route matching [GET] /api/missing') }) - it('logs route files that cannot be loaded', async () => { + it('skips route files that do not exist', async () => { vi.spyOn(Arkstack, 'rootDir').mockReturnValue(path.join(__dirname, 'fixtures/missing')) const error = vi.spyOn(Logger, 'error').mockImplementation(() => undefined as never) const routes = await Router.list() expect(routes).toEqual([]) - expect(error.mock.calls.map(([message]) => message)).toEqual([ - expect.stringContaining('Unable to load "api.ts" routes'), - expect.stringContaining('Unable to load "web.ts" routes'), - ]) + expect(error).not.toHaveBeenCalled() + }) + + it('logs route files that fail to load', async () => { + vi.spyOn(Arkstack, 'rootDir').mockReturnValue(path.join(__dirname, 'fixtures/broken')) + + const error = vi.spyOn(Logger, 'error').mockImplementation(() => undefined as never) + + await Router.list() + + expect(error).toHaveBeenCalledOnce() + expect(error.mock.calls[0][0]).toContain('Unable to load "api.ts" routes') }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2729cab1..81cd6b18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -541,6 +541,9 @@ importers: '@arkstack/http': specifier: workspace:^ version: link:../http + '@arkstack/inertia': + specifier: workspace:^ + version: link:../inertia '@fastify/formbody': specifier: ^8.0.2 version: 8.0.2 diff --git a/templates/fastify/src/config/middleware.ts b/templates/fastify/src/config/middleware.ts index 0d667b74..1cf7b339 100644 --- a/templates/fastify/src/config/middleware.ts +++ b/templates/fastify/src/config/middleware.ts @@ -1,5 +1,6 @@ import { formdata, requestLogger, resora } from '@arkstack/driver-fastify/middlewares' +import type { FastifyRequest } from 'fastify' import { MiddlewareConfig } from '@arkstack/driver-fastify/types' import { localeMiddleware } from '@arkstack/lang' import { useExpressUploadContext } from '@kanun-hq/plugin-file' @@ -11,7 +12,7 @@ export default (): MiddlewareConfig => { formdata(), resora(), localeMiddleware() as never, - async (req) => { + async (req: FastifyRequest) => { useExpressUploadContext({ files: req.uploads }) }, ], diff --git a/templates/fastify/src/config/plugins.ts b/templates/fastify/src/config/plugins.ts index 49da80fd..b7fa31d8 100644 --- a/templates/fastify/src/config/plugins.ts +++ b/templates/fastify/src/config/plugins.ts @@ -3,18 +3,19 @@ import corsConfig from './cors' import fastifyCors from '@fastify/cors' /** - * Fastify plugins registered when the app is created. + * Fastify plugins registered when the app is created. Options are given as + * functions so the config is read when Fastify loads the plugin, after boot. */ -export default (): FastifyPluginEntry[] => { - const cConf = corsConfig() +export default (): FastifyPluginEntry[] => [ + [ + fastifyCors, + () => { + const { allowed_origins } = corsConfig() - return [ - [ - fastifyCors, - { - origin: cConf.allowed_origins.length > 0 ? cConf.allowed_origins : true, + return { + origin: allowed_origins.length > 0 ? allowed_origins : true, credentials: true, - }, - ], - ] -} + } + }, + ], +]