From 2ec7473391282230a410bb7faa906f55f4367b5c Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 <52163001+3m1n3nc3@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:53:24 +0000 Subject: [PATCH 1/5] fix(fastify): read plugin options when Fastify loads the plugin The template built its CORS options in the Application constructor, before the env global exists, so lean projects crashed on start. Plugin options can now be a function, and the template's middleware config type-checks. --- packages/driver-fastify/src/types.ts | 4 +++- packages/driver-fastify/tests/index.test.ts | 13 +++++++++++ templates/fastify/src/config/middleware.ts | 3 ++- templates/fastify/src/config/plugins.ts | 25 +++++++++++---------- 4 files changed, 31 insertions(+), 14 deletions(-) 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/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/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, - }, - ], - ] -} + } + }, + ], +] From 6e461856edc68dd39a7440f777bcbc07c66324a7 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 <52163001+3m1n3nc3@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:53:24 +0000 Subject: [PATCH 2/5] fix(driver-fastify): skip route files that do not exist Lean projects have no api routes, so a missing file is no longer logged as an error. --- packages/driver-fastify/src/Router.ts | 27 ++++++++++++------- .../tests/fixtures/broken/src/routes/api.ts | 1 + packages/driver-fastify/tests/router.test.ts | 18 +++++++++---- 3 files changed, 31 insertions(+), 15 deletions(-) create mode 100644 packages/driver-fastify/tests/fixtures/broken/src/routes/api.ts 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/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/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') }) }) From ae602115e6e9f32209a9558e94edad99395ce0a0 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 <52163001+3m1n3nc3@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:57:16 +0000 Subject: [PATCH 3/5] feat(driver-fastify): add the Inertia middleware --- packages/driver-fastify/package.json | 4 + .../driver-fastify/src/middlewares/index.ts | 1 + .../driver-fastify/src/middlewares/inertia.ts | 45 +++++++ packages/driver-fastify/tests/inertia.test.ts | 113 ++++++++++++++++++ pnpm-lock.yaml | 3 + 5 files changed, 166 insertions(+) create mode 100644 packages/driver-fastify/src/middlewares/inertia.ts create mode 100644 packages/driver-fastify/tests/inertia.test.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/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/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/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 From 5dd923fbd00065d41aa004349ea38b5cdbd1355d Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 <52163001+3m1n3nc3@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:57:17 +0000 Subject: [PATCH 4/5] feat(create-arkstack): offer Inertia for the Fastify template --- create-arkstack/src/templates.ts | 1 - docs/guide/fastify-runtime.md | 2 +- docs/guide/inertia.md | 13 +++++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) 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/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/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 From 94013efbc524067176a2d04c22faf10dc9545bc4 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 <52163001+3m1n3nc3@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:57:17 +0000 Subject: [PATCH 5/5] docs: mark Fastify stable and describe lean as a profile of every template --- README.md | 2 +- docs/api.md | 7 +------ docs/guide/getting-started.md | 10 +++++----- docs/more/roadmap.md | 3 +-- 4 files changed, 8 insertions(+), 14 deletions(-) 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/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/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/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`