Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 0 additions & 1 deletion create-arkstack/src/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 1 addition & 6 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/fastify-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 5 additions & 5 deletions docs/guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions docs/guide/inertia.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions docs/more/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 4 additions & 0 deletions packages/driver-fastify/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
27 changes: 17 additions & 10 deletions packages/driver-fastify/src/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<void> {
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)
}
}
}
}
1 change: 1 addition & 0 deletions packages/driver-fastify/src/middlewares/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './auth'
export * from './formdata'
export * from './inertia'
export * from './limiter'
export * from './request-logger'
export * from './resora'
45 changes: 45 additions & 0 deletions packages/driver-fastify/src/middlewares/inertia.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 3 additions & 1 deletion packages/driver-fastify/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
FastifyInstance,
FastifyPluginAsync,
FastifyPluginCallback,
FastifyRegisterOptions,
FastifyReply,
FastifyRequest,
} from 'fastify'
Expand All @@ -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<any>
| FastifyPluginCallback<any>
| [FastifyPluginAsync<any> | FastifyPluginCallback<any>, Record<string, any>]
| [FastifyPluginAsync<any> | FastifyPluginCallback<any>, FastifyRegisterOptions<any>]

export interface FastifyDriverOptions {
bindRouter: (app: FastifyInstance) => PromiseOrValue<void>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
throw new Error('broken routes')
13 changes: 13 additions & 0 deletions packages/driver-fastify/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions packages/driver-fastify/tests/inertia.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
18 changes: 13 additions & 5 deletions packages/driver-fastify/tests/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading