diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fd92b7a..62c50672 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,7 @@ jobs: run: | cd templates/express && pnpm prepare cd ../h3 && pnpm prepare + cd ../fastify && pnpm prepare - name: Run linter run: pnpm lint diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 04f3e7f2..36f006bc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -131,6 +131,7 @@ jobs: run: | cd templates/express && pnpm prepare cd ../h3 && pnpm prepare + cd ../fastify && pnpm prepare - name: Run linter run: pnpm lint diff --git a/AGENTS.md b/AGENTS.md index f5a3938f..91bfd176 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Read `SKILL.md` first, then use these workflows to combine the available skills - Start from the scaffolded app root. - Inspect the local `package.json` before running commands. - Use the package manager indicated by the lockfile. -- Detect whether the app is Express or H3 before editing routes, middleware, or runtime code. +- Detect whether the app is Express, H3 or Fastify before editing routes, middleware, or runtime code. - Detect whether the app is full or lean before using database, auth, resource, or controller workflows. - Prefer `ark` generators when the project structure supports them. - Ask before destructive database, storage, overwrite, delete, publish, or release operations. @@ -21,6 +21,7 @@ Read `SKILL.md` first, then use these workflows to combine the available skills 3. Identify the runtime: - Express: `@arkstack/driver-express`. - H3: `@arkstack/driver-h3`. + - Fastify: `@arkstack/driver-fastify`. 4. Identify the scope: - Full: has `src/app`, `src/database`, `src/routes/api.ts`, and `arkormx.config.ts`. - Lean: lacks those full-template files. diff --git a/README.md b/README.md index 827e9f08..ad8551fe 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ [![Console Slim Downloads](https://img.shields.io/npm/dt/@arkstack/console-slim?style=flat-square&label=@arkstack/console-slim&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F@arkstack/console-slim)](https://www.npmjs.com/package/@arkstack/console-slim) [![Console Slim Downloads](https://img.shields.io/npm/dt/@arkstack/filesystem?style=flat-square&label=@arkstack/filesystem&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F@arkstack/filesystem)](https://www.npmjs.com/package/@arkstack/filesystem) [![Driver Express Downloads](https://img.shields.io/npm/dt/@arkstack/driver-express?style=flat-square&label=@arkstack/driver-express&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F@arkstack/driver-express)](https://www.npmjs.com/package/@arkstack/driver-express) +[![Driver Fastify Downloads](https://img.shields.io/npm/dt/@arkstack/driver-fastify?style=flat-square&label=@arkstack/driver-fastify&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F@arkstack/driver-fastify)](https://www.npmjs.com/package/@arkstack/driver-fastify) [![Driver H3 Downloads](https://img.shields.io/npm/dt/@arkstack/driver-h3?style=flat-square&label=@arkstack/driver-h3&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F@arkstack/driver-h3)](https://www.npmjs.com/package/@arkstack/driver-h3) [![Auth Downloads](https://img.shields.io/npm/dt/@arkstack/auth?style=flat-square&label=@arkstack/auth&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F@arkstack/auth)](https://www.npmjs.com/package/@arkstack/auth) [![HTTP Downloads](https://img.shields.io/npm/dt/@arkstack/http?style=flat-square&label=@arkstack/http&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F@arkstack/http)](https://www.npmjs.com/package/@arkstack/http) @@ -26,7 +27,7 @@ Arkstack is a runtime-agnostic TypeScript backend framework for building structured, production-ready server applications. -It gives you a complete application layer with routing conventions, console commands, authentication, notifications, views, hooks, shared contracts, and runtime drivers for Express, H3, and future transports. +It gives you a complete application layer with routing conventions, console commands, authentication, notifications, views, hooks, shared contracts, and runtime drivers for Express, H3, Fastify, and future transports. Arkstack is opinionated about application architecture while staying flexible about the HTTP runtime beneath it. @@ -38,7 +39,7 @@ It prioritizes the Arkstack application model first, runtime adapters second. Most backend frameworks are tightly coupled to one HTTP runtime. Arkstack is designed around a stable application model, clean architecture, and transport-layer abstraction. -- Runtime driver support (Express, H3 — more coming) +- Runtime driver support (Express, H3, Fastify — more coming) - Opinionated framework conventions without runtime lock-in - Clean and scalable application structure - TypeScript native @@ -127,7 +128,7 @@ Switching frameworks should not require rewriting business logic. | --------- | ------- | | Express | Stable | | H3 | Stable | -| Fastify | Planned | +| Fastify | Beta | | Bun | Planned | New adapters can be added without affecting the application layer. @@ -141,7 +142,7 @@ New adapters can be added without affecting the application layer. - `@arkstack/encryption`: isomorphic AES-256-GCM and ECDH primitives shared by server and browser. - `@arkstack/console`: shared console runtime used by kits. -Each runtime kit (Express, H3, future Fastify/Bun) implements a framework-specific driver that conforms to the same contract. +Each runtime kit (Express, H3, Fastify, future Bun) implements a framework-specific driver that conforms to the same contract. --- @@ -216,7 +217,6 @@ npm start ## Roadmap -- Fastify adapter - Bun adapter - CLI scaffolding generators - Plugin system diff --git a/SKILL.md b/SKILL.md index 1c608814..cd73ce61 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2,7 +2,7 @@ This file is for AI agents working inside an application created with `pnpm create arkstack`, `npm init arkstack`, `yarn create arkstack`, or `npx create-arkstack`. -Assume the agent is in a scaffolded Arkstack project, not in the Arkstack framework monorepo. The project normally contains one runtime template, either Express or H3, plus any files the user has added. +Assume the agent is in a scaffolded Arkstack project, not in the Arkstack framework monorepo. The project normally contains one runtime template, Express, H3 or Fastify, plus any files the user has added. ## First Checks @@ -17,6 +17,7 @@ Before changing code: - Detect the runtime driver from dependencies and imports: - `@arkstack/driver-express`: Express runtime. - `@arkstack/driver-h3`: H3 runtime. + - `@arkstack/driver-fastify`: Fastify runtime. - Detect project scope: - Full project: has `src/app`, `src/database`, `src/routes/api.ts`, `arkormx.config.ts`, and database/auth dependencies. - Lean project: usually lacks `src/app`, `src/database`, `src/routes/api.ts`, `arkormx.config.ts`, and database/auth dependencies. @@ -122,6 +123,7 @@ Use the runtime-specific router import already present in the project: - Express apps import `Router` from `@arkstack/driver-express`. - H3 apps import `Router` from `@arkstack/driver-h3`. +- Fastify apps import `Router` from `@arkstack/driver-fastify`. Default route locations: @@ -244,6 +246,7 @@ Runtime-specific middleware imports differ: - Express apps use `@arkstack/driver-express/middlewares`. - H3 apps use `@arkstack/driver-h3/middlewares`. +- Fastify apps use `@arkstack/driver-fastify/middlewares`; Fastify plugins such as CORS go in `src/config/plugins.ts`. Add app-specific middleware under `src/app/http/middlewares` in full templates. In lean templates, keep middleware small inside config or create a local directory only if the task justifies it. diff --git a/create-arkstack/src/Commands/CreateArkstackCommand.ts b/create-arkstack/src/Commands/CreateArkstackCommand.ts index 12c65514..31dbaa5e 100644 --- a/create-arkstack/src/Commands/CreateArkstackCommand.ts +++ b/create-arkstack/src/Commands/CreateArkstackCommand.ts @@ -119,7 +119,7 @@ export class CreateArkstackCommand extends Command { })), ], default: 'none', - when: () => !options.stack, + when: () => !options.stack && templates.find((e) => e.alias === (options.kit || template))?.inertia !== false, }, ]) .catch((err) => { @@ -186,6 +186,11 @@ export class CreateArkstackCommand extends Command { process.exit(1) } + if (kit.inertia === false && stack !== 'none') { + this.error(`ERROR: Inertia is not available for the ${kit.name} kit yet`) + process.exit(1) + } + const kitName = (kit.baseAlias ?? kit.alias).replace(/-lean$/i, '') as KitName const source: string = pre && kit.prereleaseSource ? kit.prereleaseSource! : kit.source const actions = new Actions(join(process.cwd(), location), appName, description) diff --git a/create-arkstack/src/catalog.ts b/create-arkstack/src/catalog.ts index 29c555de..c6a276e1 100644 --- a/create-arkstack/src/catalog.ts +++ b/create-arkstack/src/catalog.ts @@ -10,19 +10,20 @@ export const catalog: Record = { "@h3ravel/collect.js": "^5.4.0", "@faker-js/faker": "^10.4.0", "@arkormx/plugin-clear-router": "^0.1.57", - "@resora/plugin-clear-router": "^1.0.72", + "@resora/plugin-clear-router": "^1.0.73", "@kanun-hq/plugin-file": "^0.1.12", "@kanun-hq/plugin-phone": "^0.1.8", "arkormx": "^2.12.8", - "clear-router": "^2.9.3", + "clear-router": "^2.9.4", "dotenv": "^17.4.2", "express": "^5.2.1", + "fastify": "^5.8.5", "h3": "^2.0.1-rc.22", "kysely": "^0.29.2", "kanun": "^1.2.0", "parasito": "^0.2.6", "pg": "^8.22.0", - "resora": "^1.3.35", + "resora": "^1.3.36", "tsdown": "^0.22.14" } diff --git a/create-arkstack/src/data.ts b/create-arkstack/src/data.ts index 6bf60641..83729527 100644 --- a/create-arkstack/src/data.ts +++ b/create-arkstack/src/data.ts @@ -56,6 +56,7 @@ export const depsList: Record = { '@arkstack/contract': '^0.21.1', '@arkstack/database': '^0.21.1', '@arkstack/driver-express': '^0.21.1', + '@arkstack/driver-fastify': '^0.21.1', '@arkstack/driver-h3': '^0.21.1', '@arkstack/filesystem': '^0.21.1', '@arkstack/http': '^0.21.1', diff --git a/create-arkstack/src/templates.ts b/create-arkstack/src/templates.ts index 1d697f17..31b6168d 100644 --- a/create-arkstack/src/templates.ts +++ b/create-arkstack/src/templates.ts @@ -32,6 +32,13 @@ export const templates: Template[] = [ hint: 'Arkstack application running on H3', source: 'github:arkstack-hq/arkstack', }, + { + name: 'Fastify', + alias: 'fastify', + hint: 'Arkstack application running on Fastify', + source: 'github:arkstack-hq/arkstack', + inertia: false, + }, // { // name: 'H3 Lean Runtime Template', // alias: 'h3-lean', diff --git a/create-arkstack/src/types.ts b/create-arkstack/src/types.ts index 048edf0e..c0d33c04 100644 --- a/create-arkstack/src/types.ts +++ b/create-arkstack/src/types.ts @@ -2,12 +2,14 @@ export type KitName = 'express' | 'h3' | 'fastify' | 'koa' | 'nest' | 'next' | ' export interface Template { name: string - alias: 'express' | 'h3' | 'express-lean' | 'h3-lean' + alias: 'express' | 'h3' | 'fastify' | 'express-lean' | 'h3-lean' hint: string source: string lean?: boolean locked?: boolean - baseAlias?: 'express' | 'h3' + baseAlias?: 'express' | 'h3' | 'fastify' + /** Set to false when the runtime has no Inertia adapter yet. */ + inertia?: boolean prereleaseSource?: string } diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index eaf68754..495f78e0 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -202,6 +202,7 @@ export default defineConfig({ items: [ { text: 'Express', link: '/guide/express-runtime' }, { text: 'H3', link: '/guide/h3-runtime' }, + { text: 'Fastify', link: '/guide/fastify-runtime' }, ], }, { diff --git a/docs/guide/fastify-runtime.md b/docs/guide/fastify-runtime.md new file mode 100644 index 00000000..3ff6b60d --- /dev/null +++ b/docs/guide/fastify-runtime.md @@ -0,0 +1,133 @@ +# Fastify Runtime Interaction + +Arkstack's Fastify runtime integration runs the Arkstack application model on a Fastify instance. Routes, controllers, resources, validation and the console work the same as on Express and H3; this page covers what is specific to Fastify. + +## Bootstrap Exports + +From `src/core/bootstrap.ts`: + +- `fastifyApp`: raw Fastify instance +- `app`: Arkstack `Application` instance + +```ts +import { app, fastifyApp } from 'src/core/bootstrap' +``` + +## Interaction Options + +### 1. Raw runtime: `fastifyApp` + +Use this for direct Fastify APIs. Anything that changes routes, hooks or plugins must happen before the server starts, because Fastify locks the instance once it is ready. + +```ts +import { fastifyApp } from 'src/core/bootstrap' + +fastifyApp.decorate('serviceName', 'api') +fastifyApp.addHook('onSend', async (_req, reply) => { + reply.header('x-runtime', 'fastify') +}) +``` + +### 2. Instance runtime accessor: `app.getAppInstance()` + +```ts +import { app } from 'src/core/bootstrap' + +const runtime = app.getAppInstance() +runtime.addHook('onRequest', async (req) => { + req.log.info({ url: req.url }, 'incoming request') +}) +``` + +### 3. Driver interaction: `app.getDriver()` + +```ts +import { app } from 'src/core/bootstrap' + +const driver = app.getDriver() +await driver.applyMiddleware(app.getAppInstance(), async (req, reply) => { + reply.header('x-request-id', req.id) +}) +``` + +### 4. Router contract interaction: `app.getRouter()` + +```ts +import { app } from 'src/core/bootstrap' + +const routes = await app.getRouter().list({ path: '/api' }) +console.log(routes) +``` + +## Middleware + +Arkstack middleware runs as Fastify hooks. The positions in `src/config/middleware.ts` map to: + +| Position | Fastify hook | Runs | +| -------- | ------------ | ------------------------------- | +| `global` | `onRequest` | before the body is parsed | +| `before` | `preHandler` | after the body is parsed | +| `after` | `onResponse` | once the response has been sent | + +A middleware can be an async `(req, reply)` function, a callback `(req, reply, done)` function, or a class with a `handle` method. A callback middleware that calls `done` inside an async context (such as `localeMiddleware()`) keeps that context for the route handler. + +```ts +import { formdata, requestLogger, resora } from '@arkstack/driver-fastify/middlewares' + +export default (): MiddlewareConfig => ({ + global: [], + before: [formdata(), resora()], + after: [requestLogger()], +}) +``` + +## Plugins + +Fastify plugins are registered when the app is created, from `src/config/plugins.ts`. CORS uses `@fastify/cors`, and the driver's `limiter()` returns a `@fastify/rate-limit` entry: + +```ts +import fastifyCors from '@fastify/cors' +import { limiter } from '@arkstack/driver-fastify/middlewares' + +export default (): FastifyPluginEntry[] => [[fastifyCors, { origin: true, credentials: true }], limiter(100, 900)] +``` + +JSON, urlencoded and multipart bodies are parsed out of the box. + +## Uploads + +`formdata()` reads multipart fields into `req.body` and files into `req.uploads`, shaped like multer files, so file validation works the same as on Express: + +```ts +;async (req) => { + useExpressUploadContext({ files: req.uploads }) +} +``` + +## Unified HTTP Context + +Clear Router handlers receive the native Fastify `req` and `reply` objects together with request-scoped Arkstack `clearRequest` and `clearResponse` objects: + +```ts +Router.get('/account', ({ req, reply, clearRequest }) => { + reply.header('x-request-id', req.id) + + return { token: clearRequest.bearerToken() } +}) +``` + +Resources returned from routes are sent through the reply, including the status and headers set in `withResponse()`. + +## Static Assets + +The `public` directory is served with `@fastify/static` during `app.boot(port)`, with `Cache-Control: public, max-age=31536000, immutable` and permissive CORS headers. Override `mountPublicAssets` when constructing `FastifyDriver` to change this. + +## Host & Port Binding + +Host, port, dev HTTPS (`APP_SECURE`) and tunnels (`TUNNEL`, `TUNNEL_URL`) work the same as on the [Express runtime](/guide/express-runtime#host-port-binding). + +## Notes + +- Inertia is not available on Fastify yet. +- 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 f80b6691..e5c404ed 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -2,7 +2,7 @@ Arkstack is a runtime-agnostic TypeScript backend framework for building structured, production-ready server applications. -It gives you a complete application layer with routing conventions, console commands, authentication, notifications, views, hooks, shared contracts, and runtime drivers for Express, H3, and future transports. +It gives you a complete application layer with routing conventions, console commands, authentication, notifications, views, hooks, shared contracts, and runtime drivers for Express, H3, Fastify, and future transports. Arkstack is opinionated about application architecture while staying flexible about the HTTP runtime beneath it. @@ -63,6 +63,7 @@ $ yarn create arkstack@latest my-app - `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) ## Install and Run diff --git a/docs/more/roadmap.md b/docs/more/roadmap.md index 5a896b91..fb4399f4 100644 --- a/docs/more/roadmap.md +++ b/docs/more/roadmap.md @@ -13,6 +13,7 @@ This roadmap tracks Arkstack progress and upcoming priorities. - ✅ Driver packages for current runtimes - `@arkstack/driver-express` - `@arkstack/driver-h3` + - `@arkstack/driver-fastify` - ✅ Shared console command surface - `route:list` - `make:controller` @@ -21,7 +22,7 @@ This roadmap tracks Arkstack progress and upcoming priorities. - `dev` - `build` - ✅ Lean and full starter profiles in create-arkstack - - `express`, `h3` + - `express`, `h3`, `fastify` - `express-lean`, `h3-lean` - ✅ Lean profile stripping of app/api/database scaffolding - Removes `src/app` @@ -35,11 +36,10 @@ This roadmap tracks Arkstack progress and upcoming priorities. ## Next Priorities -1. 🔲 Add first additional runtime driver (Fastify candidate) -2. 🔲 Add a plugin/extension model for driver and console integration -3. 🔲 Ship richer end-to-end command and scaffolding tests -4. 🔲 Publish package-level versioning/release workflow guidance -5. 🔲 Add migration and upgrade guides between template versions +1. 🔲 Add a plugin/extension model for driver and console integration +2. 🔲 Ship richer end-to-end command and scaffolding tests +3. 🔲 Publish package-level versioning/release workflow guidance +4. 🔲 Add migration and upgrade guides between template versions ## Future diff --git a/packages/common/package.json b/packages/common/package.json index a7027e4d..e7d09316 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -41,6 +41,7 @@ }, "dependencies": { "@arkstack/encryption": "workspace:^", + "@ngrok/ngrok": "^1.7.0", "@pictwo/faker": "^1.1.0", "bcryptjs": "^3.0.3", "chalk": "^5.6.2", @@ -64,10 +65,5 @@ "arkormx": { "optional": true } - }, - "inlinedDependencies": { - "clear-router": "2.9.3", - "dayjs": "1.11.20", - "kanun": "1.2.0" } } diff --git a/packages/common/src/ServerDriver.ts b/packages/common/src/ServerDriver.ts new file mode 100644 index 00000000..beb59e52 --- /dev/null +++ b/packages/common/src/ServerDriver.ts @@ -0,0 +1,205 @@ +import { ArkstackKitDriver, type ServerStartInfo } from '@arkstack/contract' +import { env } from './system' + +import { Hook } from '@arkstack/foundry' +import { Logger } from './Logger' +import { localNetworkAddress } from './tls' +import ngrok from '@ngrok/ngrok' + +/** + * Where and how a driver should bind its server, read from the environment. + */ +export interface ServerBinding { + host: string + port: number + scheme: ServerStartInfo['scheme'] + secure: boolean +} + +/** + * Base driver for runtimes that listen on a port. It resolves the bind address, + * opens the dev tunnel, prints the startup lines and runs the `server:start` hook, + * leaving only the runtime-specific listen call to each driver. + */ +export abstract class ServerDriver extends ArkstackKitDriver { + protected tunnel_url?: string + + /** + * If trafic has been proxied via ngrok, this will return the tunnel URL. + * + * @returns + */ + geTunnelUrl(): string | undefined { + return this.tunnel_url + } + + /** + * Resolves the bind address from `APP_HOST` (or `HOST`) and `APP_SECURE`. + * + * The host defaults to `0.0.0.0` so the server is reachable on all network + * interfaces, which platforms like Railway require for their healthcheck. + * + * @param port + * @returns + */ + protected binding(port: number): ServerBinding { + const secure = env('APP_SECURE', false) === true + + return { + host: env('APP_HOST', env('HOST', '0.0.0.0')), + port, + scheme: secure ? 'https' : 'http', + secure, + } + } + + /** + * Announces a server that is now listening: opens or records the tunnel, + * prints the startup lines and runs the `server:start` hook. + * + * @param binding + */ + protected async announce(binding: ServerBinding): Promise { + const { host, port, scheme, secure } = binding + const lines = this.startupLines(binding) + const tunnelUrl = await this.openTunnel(port) + + if (tunnelUrl) { + lines.push( + Logger.log( + [ + ['Traffic has been tunnelled to', 'white'], + [tunnelUrl, 'green'], + ], + ' ', + false, + ), + ) + } + + console.log(lines.join('\n')) + + await this.emitServerStart({ + host, + port, + scheme, + secure, + url: `${scheme}://${this.localHost(host)}:${port}`, + tunnelUrl, + }) + } + + /** + * Uses a preset `TUNNEL_URL`, or opens an ngrok tunnel when `TUNNEL` is set. + * + * @param port + * @returns The public tunnel URL, if any. + */ + protected async openTunnel(port: number): Promise { + let url: string | undefined = env('TUNNEL_URL') || undefined + + if (!url && env('TUNNEL', false) === true) { + url = await this.forward(port) + + if (url) process.env.TUNNEL_URL = url + } + + if (url) { + const tunnelUrl = url + + this.tunnel_url = tunnelUrl + globalThis.tunnelUrl = () => tunnelUrl + } + + return url + } + + /** + * Opens an ngrok tunnel to the given port. + * + * @param port + * @returns The public tunnel URL, if ngrok returned one. + */ + protected async forward(port: number): Promise { + const listener = await ngrok.forward({ + addr: port, + authtoken: env('NGROK_AUTHTOKEN'), + domain: env('NGROK_DOMAIN'), + }) + + return listener.url() || undefined + } + + /** + * Builds the "Server is running" lines, adding a local-network URL when the + * server is bound to all interfaces so other devices can reach it. + * + * @param binding + * @returns + */ + protected startupLines({ host, port, scheme }: ServerBinding): string[] { + const lines = [ + Logger.log( + [ + ['Server is running on', 'white'], + [`${scheme}://${this.localHost(host)}:${port}`, 'cyan'], + ], + ' ', + false, + ), + ] + + const address = this.bindsAll(host) ? localNetworkAddress() : undefined + + if (address) { + lines.push( + Logger.log( + [ + ['Network access via', 'white'], + [`${scheme}://${address}:${port}`, 'cyan'], + ], + ' ', + false, + ), + ) + } + + return lines + } + + /** + * Runs the `server:start` hook: `after` always, then `tunneled` when a tunnel + * is open. A failing listener is logged, never thrown, so it cannot take down + * a running server or skip the next position. + * + * @param info + */ + protected async emitServerStart(info: ServerStartInfo): Promise { + await this.emitPosition('after', () => Hook.emit('server:start', 'after', info)) + + const { tunnelUrl } = info + + if (tunnelUrl) { + await this.emitPosition('tunneled', () => Hook.emit('server:start', 'tunneled', { ...info, tunnelUrl })) + } + } + + private async emitPosition(position: string, run: () => unknown): Promise { + try { + await run() + } catch (error) { + Logger.error( + `server:start ${position} hook failed: ${error instanceof Error ? error.message : String(error)}`, + false, + ) + } + } + + private bindsAll(host: string): boolean { + return host === '0.0.0.0' || host === '::' + } + + private localHost(host: string): string { + return this.bindsAll(host) ? 'localhost' : host + } +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 2ccf8aef..eced4893 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -17,3 +17,4 @@ export * from './EnvLoader' export * from './ConfigLoader' export * from './tls' export * from './dev-server' +export * from './ServerDriver' diff --git a/packages/driver-express/package.json b/packages/driver-express/package.json index 54459d99..6ddb0143 100644 --- a/packages/driver-express/package.json +++ b/packages/driver-express/package.json @@ -44,7 +44,6 @@ "dependencies": { "@arkstack/contract": "workspace:^", "@arkstack/http": "workspace:^", - "@ngrok/ngrok": "^1.7.0", "@resora/plugin-clear-router": "catalog:", "clear-router": "catalog:", "express-rate-limit": "^8.4.1", diff --git a/packages/driver-express/src/ExpressDriver.ts b/packages/driver-express/src/ExpressDriver.ts index c00d40cc..0d2c7e3d 100644 --- a/packages/driver-express/src/ExpressDriver.ts +++ b/packages/driver-express/src/ExpressDriver.ts @@ -1,100 +1,19 @@ import express, { type ErrorRequestHandler, type Express, type Handler } from 'express' -import { ArkstackKitDriver, PromiseOrValue } from '@arkstack/contract' -import { Logger, devTlsCredentials, env, localNetworkAddress } from '@arkstack/common' -import https from 'node:https' -import { defaultErrorHandler } from './error-handler' import { ExpressDriverOptions, Middleware, MiddlewareConfig } from './types' -import { resolveMiddleware } from '@arkstack/http' -import ngrok from '@ngrok/ngrok' -import { Hook } from '@arkstack/foundry' -import type { ServerStartInfo } from '@arkstack/contract' - -/** - * Run one `server:start` position. A failing listener is logged, never thrown: it - * must not take down a server that is already up, nor skip the next position. - * - * @param position - * @param run - */ -const emitServerStartPosition = async (position: string, run: () => unknown): Promise => { - try { - await run() - } catch (error) { - Logger.error( - `server:start ${position} hook failed: ${error instanceof Error ? error.message : String(error)}`, - false, - ) - } -} - -/** - * Run the `server:start` hook once the server is listening: `after` always, then - * `tunneled` when the server is reachable through a tunnel. - * - * @param info - */ -const emitServerStart = async (info: ServerStartInfo): Promise => { - await emitServerStartPosition('after', () => Hook.emit('server:start', 'after', info)) - - const { tunnelUrl } = info - if (tunnelUrl) { - await emitServerStartPosition('tunneled', () => Hook.emit('server:start', 'tunneled', { ...info, tunnelUrl })) - } -} - -/** - * Build the "Server is running" startup lines, adding a local-network URL when - * the server is bound to all interfaces (`0.0.0.0`/`::`) so it is reachable from - * other devices. - * - * @param scheme - * @param host - * @param port - * @returns - */ -const startupLogLines = (scheme: string, host: string, port: number): string[] => { - const bindsAll = host === '0.0.0.0' || host === '::' - const localHost = bindsAll ? 'localhost' : host - - const lines = [ - Logger.log( - [ - ['Server is running on', 'white'], - [`${scheme}://${localHost}:${port}`, 'cyan'], - ], - ' ', - false, - ), - ] - - if (bindsAll) { - const address = localNetworkAddress() - - if (address) { - lines.push( - Logger.log( - [ - ['Network access via', 'white'], - [`${scheme}://${address}:${port}`, 'cyan'], - ], - ' ', - false, - ), - ) - } - } +import { Logger, ServerDriver, devTlsCredentials } from '@arkstack/common' - return lines -} +import { PromiseOrValue } from '@arkstack/contract' +import { defaultErrorHandler } from './error-handler' +import https from 'node:https' +import { resolveMiddleware } from '@arkstack/http' /** * The ExpressDriver class implements the ArkstackKitDriver * contract for the Express framework. */ -export class ExpressDriver extends ArkstackKitDriver { +export class ExpressDriver extends ServerDriver { readonly name = 'express' - private tunnel_url?: string private readonly options: ExpressDriverOptions /** @@ -195,98 +114,25 @@ export class ExpressDriver extends ArkstackKitDriver { app.use((this.options.errorHandler ?? defaultErrorHandler) as ErrorRequestHandler) } - /** - * If trafic has been proxied via ngrok, this will return the tunnel URL. - * - * @returns - */ - geTunnelUrl(): string | undefined { - return this.tunnel_url - } - /** * Starts the Express server on the specified port. * - * The bind host can be overridden with the `APP_HOST` (or `HOST`) env - * variable. It defaults to `0.0.0.0` so the server is reachable on all - * network interfaces, which platforms like Railway require for their - * healthcheck proxy to reach the app. - * * @param app * @param port */ async start(app: Express, port: number): Promise { - const host = env('APP_HOST', env('HOST', '0.0.0.0')) - const secure = env('APP_SECURE', false) === true - const tunneled = env('TUNNEL', false) - const tunnelUrl = env('TUNNEL_URL') - const scheme: ServerStartInfo['scheme'] = secure ? 'https' : 'http' - - const onListen = async () => { - let log = startupLogLines(scheme, host, port) - - if (tunnelUrl) { - log = log.concat( - Logger.log( - [ - ['Traffic has been tunnelled to', 'white'], - [tunnelUrl, 'green'], - ], - ' ', - false, - ), - ) - - this.tunnel_url = tunnelUrl - globalThis.tunnelUrl = () => tunnelUrl - } else if (tunneled === true) { - const listener = await ngrok.forward({ - addr: port, - authtoken: env('NGROK_AUTHTOKEN'), - domain: env('NGROK_DOMAIN'), - }) - - const url = listener.url() - - if (url) { - log = log.concat( - Logger.log( - [ - ['Traffic has been tunnelled to', 'white'], - [url, 'green'], - ], - ' ', - false, - ), - ) - - process.env.TUNNEL_URL = url - this.tunnel_url = url - globalThis.tunnelUrl = () => url - } - } - - console.log(log.join('\n')) - - await emitServerStart({ - host, - port, - scheme, - secure, - url: `${scheme}://${host === '0.0.0.0' || host === '::' ? 'localhost' : host}:${port}`, - tunnelUrl: this.tunnel_url || undefined, - }) - } + const binding = this.binding(port) + const onListen = () => this.announce(binding) - if (secure) { + if (binding.secure) { // Dev HTTPS: serve with an in-memory self-signed certificate. const credentials = await devTlsCredentials() - https.createServer({ key: credentials.key, cert: credentials.cert }, app).listen(port, host, onListen) + https.createServer({ key: credentials.key, cert: credentials.cert }, app).listen(port, binding.host, onListen) return } - app.listen(port, host, onListen) + app.listen(port, binding.host, onListen) } } diff --git a/packages/driver-express/tests/server-start-hook.test.ts b/packages/driver-express/tests/server-start-hook.test.ts index c6bdf303..79033c53 100644 --- a/packages/driver-express/tests/server-start-hook.test.ts +++ b/packages/driver-express/tests/server-start-hook.test.ts @@ -4,10 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Express } from 'express' import { ExpressDriver } from '../src/index' import { Hook } from '@arkstack/foundry' +import { ServerDriver } from '@arkstack/common' -vi.mock('@ngrok/ngrok', () => ({ - default: { forward: vi.fn(async () => ({ url: () => 'https://opened.ngrok.app' })) }, -})) +/** The driver's ngrok call, stubbed so no tunnel is opened. */ +type Tunnel = { forward: (port: number) => Promise } /** A fake app whose `listen` "binds" immediately, so no real port is opened. */ const fakeApp = () => ({ listen: vi.fn((_port, _host, onListen: () => void) => onListen()) }) as unknown as Express @@ -24,6 +24,7 @@ describe('ExpressDriver server:start hook', () => { beforeEach(() => { Hook.clear() + vi.spyOn(ServerDriver.prototype as unknown as Tunnel, 'forward').mockResolvedValue('https://opened.ngrok.app') vi.spyOn(console, 'log').mockImplementation(() => {}) for (const key of ['APP_HOST', 'HOST', 'APP_SECURE', 'TUNNEL', 'TUNNEL_URL']) delete process.env[key] }) diff --git a/packages/driver-fastify/README.md b/packages/driver-fastify/README.md new file mode 100644 index 00000000..b320d8e9 --- /dev/null +++ b/packages/driver-fastify/README.md @@ -0,0 +1,45 @@ +# @arkstack/driver-fastify + +[![@arkstack/driver-fastify](https://img.shields.io/npm/dt/@arkstack/driver-fastify?style=flat-square&label=@arkstack/driver-fastify&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F@arkstack/driver-fastify)](https://www.npmjs.com/package/@arkstack/driver-fastify) + +Fastify driver for Arkstack, providing Fastify-based runtime integration for the framework. + +## Middleware Positions + +Arkstack middleware runs as Fastify hooks: + +| Position | Fastify hook | Runs | +| -------- | ------------ | ------------------------------- | +| `global` | `onRequest` | before the body is parsed | +| `before` | `preHandler` | after the body is parsed | +| `after` | `onResponse` | once the response has been sent | + +A middleware can be an async `(req, reply)` function, a callback `(req, reply, done)` function, or a class with a `handle` method. + +## Plugins + +Fastify plugins such as `@fastify/cors` are passed to the driver and registered when the app is created. JSON, urlencoded and multipart bodies are parsed out of the box. + +```ts +import fastifyCors from '@fastify/cors' +import { limiter } from '@arkstack/driver-fastify/middlewares' + +new FastifyDriver({ + bindRouter: (app) => Router.bind(app), + plugins: [[fastifyCors, { origin: true }], limiter(100, 900)], +}) +``` + +## Uploads + +The `formdata()` middleware reads multipart fields into `req.body` and files into `req.uploads`, shaped like multer files. + +## Auth Middleware + +```ts +import { auth } from '@arkstack/driver-fastify/middlewares' + +app.get('/account', { preHandler: auth }, async (req) => ({ user: req.authUser })) +``` + +The middleware expects `Authorization: Bearer ` and attaches `user`, `authUser`, and `authToken` to the request. diff --git a/packages/driver-fastify/package.json b/packages/driver-fastify/package.json new file mode 100644 index 00000000..2d619177 --- /dev/null +++ b/packages/driver-fastify/package.json @@ -0,0 +1,65 @@ +{ + "name": "@arkstack/driver-fastify", + "version": "0.21.1", + "type": "module", + "description": "Fastify driver for Arkstack, providing Fastify-based runtime integration for the framework.", + "homepage": "https://arkstack.toneflix.net", + "repository": { + "type": "git", + "url": "git+https://github.com/arkstack-hq/arkstack.git", + "directory": "packages/driver-fastify" + }, + "keywords": [ + "fastify", + "driver", + "integration", + "routing", + "middleware", + "arkstack", + "http", + "server", + "rest", + "api", + "framework" + ], + "files": [ + "dist", + "stubs" + ], + "scripts": { + "build": "tsdown --config-loader unrun", + "version:patch": "pnpm version patch", + "prepublishOnly": "node ../../scripts/prepublish-only.mjs" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": "./dist/index.js", + "./middlewares": "./dist/middlewares/index.js", + "./types": "./dist/types.js", + "./package.json": "./package.json" + }, + "dependencies": { + "@arkstack/contract": "workspace:^", + "@arkstack/http": "workspace:^", + "@fastify/formbody": "^8.0.2", + "@fastify/multipart": "^9.0.3", + "@fastify/rate-limit": "^10.3.0", + "@fastify/static": "^8.2.0", + "@resora/plugin-clear-router": "catalog:", + "clear-router": "catalog:", + "resora": "catalog:" + }, + "peerDependencies": { + "@arkstack/auth": "workspace:^", + "@arkstack/common": "workspace:^", + "@arkstack/foundry": "workspace:^", + "fastify": "catalog:" + }, + "peerDependenciesMeta": { + "@arkstack/auth": { + "optional": true + } + } +} diff --git a/packages/driver-fastify/src/Exceptions/RateLimitExceededException.ts b/packages/driver-fastify/src/Exceptions/RateLimitExceededException.ts new file mode 100644 index 00000000..d0213e66 --- /dev/null +++ b/packages/driver-fastify/src/Exceptions/RateLimitExceededException.ts @@ -0,0 +1,11 @@ +import { Exception } from '@arkstack/common' + +export class RateLimitExceededException extends Exception { + statusCode: number = 429 + name: string = 'RateLimitExceededException' + + constructor(message: string = 'Too many requests, please try again later.', statusCode: number = 429) { + super(message) + this.statusCode = statusCode + } +} diff --git a/packages/driver-fastify/src/FastifyDriver.ts b/packages/driver-fastify/src/FastifyDriver.ts new file mode 100644 index 00000000..60d3e805 --- /dev/null +++ b/packages/driver-fastify/src/FastifyDriver.ts @@ -0,0 +1,203 @@ +import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify' +import type { FastifyDriverOptions, FastifyMiddleware, FastifyPluginEntry, Middleware, MiddlewareConfig } from './types' +import { Logger, ServerDriver, devTlsCredentials } from '@arkstack/common' + +import { PromiseOrValue } from '@arkstack/contract' +import { defaultErrorHandler } from './error-handler' +import fastifyFormbody from '@fastify/formbody' +import fastifyMultipart from '@fastify/multipart' +import fastifyStatic from '@fastify/static' +import https from 'node:https' +import { resolveMiddleware } from '@arkstack/http' + +/** + * Where each Arkstack middleware position runs in Fastify's request lifecycle. + */ +const lifecycleHooks = { + global: 'onRequest', + before: 'preHandler', + after: 'onResponse', +} as const + +/** + * Creates the HTTPS server for dev HTTPS. The certificate is attached on start. + * + * @param handler + */ +const secureServer = (handler: (req: any, res: any) => void) => https.createServer(handler) + +/** + * The FastifyDriver class implements the ArkstackKitDriver + * contract for the Fastify framework. + */ +export class FastifyDriver extends ServerDriver { + readonly name = 'fastify' + private readonly options: FastifyDriverOptions + private readonly errorHandled = new WeakSet() + + /** + * Creates an instance of FastifyDriver. + * + * @param options + */ + constructor(options: FastifyDriverOptions) { + super() + this.options = options + } + + /** + * Creates a Fastify instance with the error handler, form and multipart body + * parsing, plus any plugins passed in the driver options. + * + * @returns + */ + createApp(): FastifyInstance { + const app = this.options.createApp?.() ?? Fastify(this.binding(0).secure ? { serverFactory: secureServer } : {}) + + // Routes capture the error handler when they are built, so it is set before any route exists. + this.registerErrorHandler(app) + this.registerPlugins(app, [fastifyFormbody, fastifyMultipart, ...(this.options.plugins ?? [])]) + + return app + } + + /** + * Registers Fastify plugins on the given instance. + * + * @param app + * @param plugins + */ + registerPlugins(app: FastifyInstance, plugins: FastifyPluginEntry[]): void { + for (const entry of plugins) { + const [plugin, options] = Array.isArray(entry) ? entry : [entry, {}] + + app.register(plugin, options) + } + } + + /** + * Serves static assets from the specified public path. + * + * @param app + * @param publicPath + */ + mountPublicAssets(app: FastifyInstance, publicPath: string): PromiseOrValue { + if (this.options.mountPublicAssets) { + return this.options.mountPublicAssets(app, publicPath) + } + + app.register(fastifyStatic, { + root: publicPath, + maxAge: '1y', + immutable: true, + setHeaders: (res) => { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') + }, + }) + } + + /** + * Binds the router to the Fastify application using the provided bindRouter function. + * + * @param app + */ + bindRouter(app: FastifyInstance): PromiseOrValue { + return this.options.bindRouter(app) + } + + /** + * Applies middleware as Fastify lifecycle hooks: `global` runs on request, + * `before` runs once the body is parsed, and `after` runs once the response is sent. + * + * @param app + * @param middleware + */ + applyMiddleware(app: FastifyInstance, middleware: Middleware | MiddlewareConfig): void { + if (!middleware) return + + if (typeof middleware === 'function') { + app.addHook('onRequest', resolveMiddleware(middleware) as never) + + return + } + + for (const [pos, entries] of Object.entries(middleware) as [keyof MiddlewareConfig, Middleware[]][]) { + const hook = lifecycleHooks[pos] + + for (const instance of entries) { + const entry = resolveMiddleware(instance) as FastifyMiddleware + + if (pos === 'after') { + app.addHook(hook, async (req: FastifyRequest, reply: FastifyReply) => { + try { + await this.runMiddleware(entry, req, reply) + } catch (error) { + Logger.error(error instanceof Error ? error.message : String(error), false) + } + }) + } else if (entry.length >= 3) { + // Callback middleware calls `done` itself, so any async context it opens + // around `done` (locale, request scope) carries on into the handler. + app.addHook(hook, (req: FastifyRequest, reply: FastifyReply, done: (error?: Error) => void) => { + Promise.resolve(entry(req, reply, done)).catch(done) + }) + } else { + app.addHook(hook, async (req: FastifyRequest, reply: FastifyReply) => { + await entry(req, reply, () => undefined) + }) + } + } + } + } + + /** + * Registers the error handler from the options, or the built-in one, once per app. + * + * @param app + */ + registerErrorHandler(app: FastifyInstance): void { + if (this.errorHandled.has(app)) return + + this.errorHandled.add(app) + app.setErrorHandler(this.options.errorHandler ?? defaultErrorHandler) + } + + /** + * Starts the Fastify server on the specified port. + * + * @param app + * @param port + */ + async start(app: FastifyInstance, port: number): Promise { + const binding = this.binding(port) + + if (binding.secure && app.server instanceof https.Server) { + // Dev HTTPS: serve with an in-memory self-signed certificate. + const credentials = await devTlsCredentials() + + app.server.setSecureContext({ key: credentials.key, cert: credentials.cert }) + } + + await app.listen({ port, host: binding.host }) + await this.announce(binding) + } + + /** + * Runs a middleware in either async or callback form and settles once it is done. + * + * @param middleware + * @param req + * @param reply + */ + private runMiddleware(middleware: FastifyMiddleware, req: FastifyRequest, reply: FastifyReply): Promise { + if (middleware.length < 3) { + return Promise.resolve((middleware as (req: FastifyRequest, reply: FastifyReply) => unknown)(req, reply)) + } + + return new Promise((resolve, reject) => { + middleware(req, reply, (error?: Error) => (error ? reject(error) : resolve(undefined))) + }) + } +} diff --git a/packages/driver-fastify/src/Router.ts b/packages/driver-fastify/src/Router.ts new file mode 100644 index 00000000..a243cb0f --- /dev/null +++ b/packages/driver-fastify/src/Router.ts @@ -0,0 +1,59 @@ +import type { FastifyApp, Handler, HttpContext, Middleware } from 'clear-router/types/fastify' +import { Logger, RequestException, resolveRuntimeModule } from '@arkstack/common' + +import { ArkstackRouteListOptions } from '@arkstack/contract' +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 { registerPlugin } from 'resora' + +registerPlugin(clearRouterFastifyPlugin) +void ClearRouter.use(arkstackHttpPlugin) +ClearRouter.configure({ + inferParamName: true, +}) + +export class Router extends ClearRouter { + /** + * Loads the api and web route files, applies them to the Fastify app and + * turns unmatched requests into a 404 RequestException. + * + * @param app + * @returns + */ + static async bind(app: FastifyApp): Promise { + await this.load() + + ClearRouter.apply(app) + + app.setNotFoundHandler((req) => { + throw new RequestException(`Cannot find any route matching [${req.method}] ${req.url}`, 404) + }) + + return app + } + + static async list(_options: ArkstackRouteListOptions = {}): Promise>> { + await this.load() + + return this.allRoutes() as never + } + + /** + * Registers the routes declared in the api and web route files. + */ + 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) + } + + try { + await ClearRouter.group('/', resolveRuntimeModule('src/routes/web.ts')) + } catch (e: any) { + Logger.error('ERROR: Unable to load "web.ts" routes: ' + e.message, false) + } + } +} diff --git a/packages/driver-fastify/src/app.d.ts b/packages/driver-fastify/src/app.d.ts new file mode 100644 index 00000000..bb56fbd9 --- /dev/null +++ b/packages/driver-fastify/src/app.d.ts @@ -0,0 +1,30 @@ +import type { FastifyReply, FastifyRequest } from 'fastify' + +import type { UploadedFile } from './types' + +// Typed hook names are declared on @arkstack/foundry, which owns `Hook`. +declare module '@arkstack/foundry' { + interface HookRegistry { + 'middleware:auth': { + before: (ctx: { req: FastifyRequest; reply: FastifyReply }) => Promise + after: (ctx: { req: FastifyRequest; reply: FastifyReply }) => Promise + error: (error: unknown, ctx: { req: FastifyRequest; reply: FastifyReply }) => Promise + } + } +} + +declare module 'fastify' { + interface FastifyRequest { + user?: any + auth?: any + authUser?: any + authToken?: string | undefined + session?: any + rawBody?: Buffer + uploads?: UploadedFile[] + } +} + +declare global { + var tunnelUrl: () => string +} diff --git a/packages/driver-fastify/src/error-handler.ts b/packages/driver-fastify/src/error-handler.ts new file mode 100644 index 00000000..3efe54ec --- /dev/null +++ b/packages/driver-fastify/src/error-handler.ts @@ -0,0 +1,95 @@ +import type { FastifyReply, FastifyRequest } from 'fastify' +import { ErrorHandler, renderError } from '@arkstack/common' + +const webMiddlewareKey = Symbol.for('arkstack:http:web') + +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null +} + +const isWebRequest = (value: unknown): boolean => { + if (!isRecord(value)) { + return false + } + + return ( + value[webMiddlewareKey] === true || + value.arkstackWeb === true || + isWebRequest(value.context) || + isWebRequest(value.req) + ) +} + +const redirectBackTarget = (req: FastifyRequest) => { + const referer = req.headers.referer ?? req.headers.referrer + const value = Array.isArray(referer) ? referer[0] : referer + + return typeof value === 'string' && value ? value : '/' +} + +const flashValidationState = async (err: unknown, req: Record, errors?: unknown) => { + const session = + req.httpSession ?? + (isRecord(req.session) && typeof req.session.addValidationErrors === 'function' ? req.session : undefined) + + if (!session) { + return + } + + if (errors) { + session.addErrors?.(errors) + } else { + session.addValidationErrors?.(err) + } + + await session.save?.() +} + +export const defaultErrorHandler = async (err: Error, req: FastifyRequest, reply: FastifyReply) => { + const responseBody = ErrorHandler.createErrorPayload(err) + + if (ErrorHandler.shouldLogError(err)) { + ErrorHandler.logUnhandledError( + err, + { + headers: req.headers, + method: req.method, + url: req.url, + }, + 'Unhandled Fastify request error', + ) + } + + if (process.env.NODE_ENV === 'development') console.error(responseBody) + + if (reply.sent) { + return + } + + const acceptsHeader = req.headers.accept ?? '' + const expectsJson = acceptsHeader.includes('application/json') || req.url.startsWith('/api/') + const code = ErrorHandler.normalizeStatusCode(responseBody.code) + + if (code === 422 && !expectsJson && isWebRequest(req)) { + await flashValidationState(err, req as never, responseBody.errors) + + return reply.redirect(redirectBackTarget(req), 302) + } + + if (expectsJson) { + return reply.code(code).send(responseBody) + } + + return reply + .code(code) + .type('text/html') + .send( + renderError({ + message: String(responseBody.message), + stack: typeof responseBody.stack === 'string' ? responseBody.stack : undefined, + code, + }), + ) +} + +export default defaultErrorHandler diff --git a/packages/driver-fastify/src/index.ts b/packages/driver-fastify/src/index.ts new file mode 100644 index 00000000..b7da6879 --- /dev/null +++ b/packages/driver-fastify/src/index.ts @@ -0,0 +1,3 @@ +export * from './FastifyDriver' +export * from './error-handler' +export * from './Router' diff --git a/packages/driver-fastify/src/middlewares/auth.ts b/packages/driver-fastify/src/middlewares/auth.ts new file mode 100644 index 00000000..2a023c56 --- /dev/null +++ b/packages/driver-fastify/src/middlewares/auth.ts @@ -0,0 +1,56 @@ +import type { FastifyReply, FastifyRequest } from 'fastify' + +import { Hook } from '@arkstack/foundry' + +/** + * Authorizes the request's bearer token and attaches the user, auth and + * session to the request. + * + * @param req + * @param reply + */ +export const auth = async (req: FastifyRequest, reply: FastifyReply): Promise => { + const { Auth, AuthenticationException } = await import('@arkstack/auth') + + try { + await Hook.emit('middleware:auth', 'before', { req, reply }) + + const token = readBearerToken(req.headers.authorization) + + if (!token) { + throw new AuthenticationException('Unauthenticated', { + req, + status: 401, + }) + } + + const auth = Auth.make().setRequest(req as never) + const user = await auth.authorizeToken(token) + + req.user = user + req.auth = auth + req.authUser = user + req.session = auth.session() + req.authToken = token + + await Hook.emit('middleware:auth', 'after', { req, reply }) + } catch (error) { + await Hook.emit('middleware:auth', 'error', error, { req, reply }) + + throw error + } +} + +export class AuthMiddleware { + handle(req: FastifyRequest, reply: FastifyReply) { + return auth(req, reply) + } +} + +const readBearerToken = (authorization: string | undefined) => { + if (!authorization?.startsWith('Bearer ')) { + return null + } + + return authorization.substring(7) +} diff --git a/packages/driver-fastify/src/middlewares/formdata.ts b/packages/driver-fastify/src/middlewares/formdata.ts new file mode 100644 index 00000000..0510b7ba --- /dev/null +++ b/packages/driver-fastify/src/middlewares/formdata.ts @@ -0,0 +1,54 @@ +import type { FastifyReply, FastifyRequest } from 'fastify' + +import type { UploadedFile } from '../types' + +/** + * Reads a multipart body into plain `req.body` fields and multer-shaped files + * on `req.uploads`. Requests that are not multipart pass through untouched. + */ +export const formdata = () => async (req: FastifyRequest, _reply: FastifyReply) => { + if (!req.isMultipart()) return + + const body: Record = {} + const uploads: UploadedFile[] = [] + + for await (const part of req.parts()) { + if (part.type === 'file') { + const buffer = await part.toBuffer() + + uploads.push({ + buffer, + encoding: part.encoding, + fieldname: part.fieldname, + mimetype: part.mimetype, + originalname: part.filename, + size: buffer.byteLength, + }) + + continue + } + + appendField(body, part.fieldname, part.value) + } + + req.body = body + req.uploads = uploads +} + +export class FormDataMiddleware { + handle(req: FastifyRequest, reply: FastifyReply) { + return formdata()(req, reply) + } +} + +const appendField = (body: Record, name: string, value: unknown) => { + if (!(name in body)) { + body[name] = value + + return + } + + const current = body[name] + + body[name] = Array.isArray(current) ? [...current, value] : [current, value] +} diff --git a/packages/driver-fastify/src/middlewares/index.ts b/packages/driver-fastify/src/middlewares/index.ts new file mode 100644 index 00000000..266f5743 --- /dev/null +++ b/packages/driver-fastify/src/middlewares/index.ts @@ -0,0 +1,5 @@ +export * from './auth' +export * from './formdata' +export * from './limiter' +export * from './request-logger' +export * from './resora' diff --git a/packages/driver-fastify/src/middlewares/limiter.ts b/packages/driver-fastify/src/middlewares/limiter.ts new file mode 100644 index 00000000..b36a9da9 --- /dev/null +++ b/packages/driver-fastify/src/middlewares/limiter.ts @@ -0,0 +1,35 @@ +import type { FastifyPluginEntry } from '../types' +import type { RateLimitPluginOptions } from '@fastify/rate-limit' +import { RateLimitExceededException } from '../Exceptions/RateLimitExceededException' +import { env } from '@arkstack/common' +import fastifyRateLimit from '@fastify/rate-limit' + +/** + * Creates a rate limiter plugin entry for the driver's `plugins` option. + * + * A limiter translates to [Allow N `(requests)` per N seconds `(windowSeconds)` per IP], or + * more explicitly `Allow 100 requests per 30 seconds per IP.` + * + * @param requests number of requests allowed per `windowSeconds` + * @param windowSeconds number of seconds for the window + * @param message custom message returned when the limit is exceeded + * @returns + */ +export const limiter = ( + requests: RateLimitPluginOptions['max'] = 100, + windowSeconds: number = 900, + message?: string, +): FastifyPluginEntry => { + const timeWindow = (env('NODE_ENV') === 'production' ? windowSeconds : 30) * 1000 + + return [ + fastifyRateLimit, + { + max: requests, + timeWindow, + errorResponseBuilder: (_req: unknown, context: { statusCode: number }) => { + return new RateLimitExceededException(message, context.statusCode) + }, + } satisfies RateLimitPluginOptions, + ] +} diff --git a/packages/driver-fastify/src/middlewares/request-logger.ts b/packages/driver-fastify/src/middlewares/request-logger.ts new file mode 100644 index 00000000..4381fbea --- /dev/null +++ b/packages/driver-fastify/src/middlewares/request-logger.ts @@ -0,0 +1,48 @@ +import type { FastifyReply, FastifyRequest } from 'fastify' +import { Logger, nodeEnv } from '@arkstack/common' + +const colors: Record = { + GET: 'green', + POST: 'blue', + PUT: 'yellow', + DELETE: 'red', + PATCH: 'cyan', +} + +/** + * Logs each finished request with its status and response time. Register it + * in the `after` middleware position. + * + * @param config + * @param config.allowInProduction Also log in production. Defaults to false. + * @returns + */ +export const requestLogger = + ({ allowInProduction = false }: { allowInProduction?: boolean } = {}) => + async (req: FastifyRequest, reply: FastifyReply) => { + const VERBOSE = process.env.VERBOSITY != '0' + + if ((nodeEnv() === 'prod' && !allowInProduction) || !VERBOSE) return + if (process.env.NODE_ENV === 'test' || process.env.VITEST) return + + const status = reply.statusCode || 200 + const duration = Math.round(reply.elapsedTime * 100) / 100 + + Logger.log( + [ + [`[${req.method}]`, colors[req.method] || 'white'], + [req.url, 'cyan'], + [status.toString(), status >= 500 ? 'red' : status >= 400 ? 'yellow' : 'green'], + [`- ${duration}ms`, 'dim'], + ], + ' ', + ) + } + +export class RequestLoggerMiddleware { + constructor(private options: { allowInProduction?: boolean } = {}) {} + + handle(req: FastifyRequest, reply: FastifyReply) { + return requestLogger(this.options)(req, reply) + } +} diff --git a/packages/driver-fastify/src/middlewares/resora.ts b/packages/driver-fastify/src/middlewares/resora.ts new file mode 100644 index 00000000..fda5844a --- /dev/null +++ b/packages/driver-fastify/src/middlewares/resora.ts @@ -0,0 +1,34 @@ +import { applyRuntimeConfig, getDefaultConfig, setCtx } from 'resora' +import type { FastifyReply, FastifyRequest } from 'fastify' +import { config, resolveRuntimeDir } from '@arkstack/common' + +/** + * Applies the application's resora configuration (`src/config/resources.ts`) + * and binds the `{ req, reply }` context so Resources can build URLs and + * pagination links. Route handlers run inside resora's request context + * through the clear-router plugin. + */ +export const resora = () => { + let resources: Record | undefined + + return async (req: FastifyRequest, reply: FastifyReply) => { + try { + if (!resources) { + resources = { + ...getDefaultConfig(), + ...config('resources', {}), + } as Record + + if (typeof resources.resourcesDir === 'string') { + resources.resourcesDir = resolveRuntimeDir(resources.resourcesDir) + } + } + + applyRuntimeConfig(resources as never) + } catch { + /** No resources config; resora falls back to its defaults. */ + } + + setCtx({ req, res: reply }) + } +} diff --git a/packages/driver-fastify/src/types.ts b/packages/driver-fastify/src/types.ts new file mode 100644 index 00000000..250f7766 --- /dev/null +++ b/packages/driver-fastify/src/types.ts @@ -0,0 +1,56 @@ +import type { + FastifyError, + FastifyInstance, + FastifyPluginAsync, + FastifyPluginCallback, + FastifyReply, + FastifyRequest, +} from 'fastify' +import type { ArkstackMiddlewareConfig, PromiseOrValue } from '@arkstack/contract' + +import type { ClassMiddleware } from 'clear-router/types/basic' + +/** + * A Fastify hook handler, in async `(req, reply)` or callback `(req, reply, done)` form. + */ +export type FastifyMiddleware = + | ((req: FastifyRequest, reply: FastifyReply) => PromiseOrValue) + | ((req: FastifyRequest, reply: FastifyReply, done: (error?: Error) => void) => void) + +export type Middleware = FastifyMiddleware | ClassMiddleware + +export type MiddlewareConfig = ArkstackMiddlewareConfig + +export type FastifyErrorHandler = ( + error: FastifyError | Error, + req: FastifyRequest, + reply: FastifyReply, +) => PromiseOrValue + +/** + * A Fastify plugin with its options, registered when the app is created. + */ +export type FastifyPluginEntry = + | FastifyPluginAsync + | FastifyPluginCallback + | [FastifyPluginAsync | FastifyPluginCallback, Record] + +export interface FastifyDriverOptions { + bindRouter: (app: FastifyInstance) => PromiseOrValue + createApp?: () => FastifyInstance + mountPublicAssets?: (app: FastifyInstance, publicPath: string) => PromiseOrValue + errorHandler?: FastifyErrorHandler + plugins?: FastifyPluginEntry[] +} + +/** + * A file read from a multipart request, shaped like a multer file. + */ +export interface UploadedFile { + buffer: Buffer + encoding: string + fieldname: string + mimetype: string + originalname: string + size: number +} diff --git a/packages/driver-fastify/stubs/controller.api.resource.stub b/packages/driver-fastify/stubs/controller.api.resource.stub new file mode 100644 index 00000000..d7ecd086 --- /dev/null +++ b/packages/driver-fastify/stubs/controller.api.resource.stub @@ -0,0 +1,65 @@ +import { BaseController } from '@controllers/BaseController' +import {{ResourceName}} from '@app/http/resources/{{ResourceName}}' +import {{CollectionResourceName}} from '@app/http/resources/{{CollectionResourceName}}' + +import { Resource, ResourceCollection } from 'resora' + +/** + * {{ControllerName}} + */ +export default class {{ControllerName}} extends BaseController { + /** + * Get all resources + */ + async index () { + return await new {{CollectionResourceName}}([]).additional({ + status: 'success', + message: 'OK', + code: 200, + }).response().setStatusCode(200) + } + + /** + * Get a specific resource + */ + async show () { + return new {{ResourceName}}({}).additional({ + status: 'success', + message: 'OK', + code: 200, + }).response().setStatusCode(200) + } + + /** + * Create a resource + */ + async create () { + return new {{ResourceName}}({}).additional({ + status: 'success', + message: 'New {{Name}} created successfully', + code: 201, + }).response().setStatusCode(201) + } + + /** + * Update a specific resource + */ + async update () { + return new {{ResourceName}}({}).additional({ + status: 'success', + message: '{{Name}} updated successfully', + code: 202, + }).response().setStatusCode(202) + } + + /** + * Delete a specific resource + */ + async destroy () { + return new {{ResourceName}}({}).additional({ + status: 'success', + message: '{{Name}} deleted successfully', + code: 202, + }).response().setStatusCode(202) + } +} diff --git a/packages/driver-fastify/stubs/controller.api.stub b/packages/driver-fastify/stubs/controller.api.stub new file mode 100644 index 00000000..7ea59924 --- /dev/null +++ b/packages/driver-fastify/stubs/controller.api.stub @@ -0,0 +1,62 @@ +import { BaseController } from '@controllers/BaseController' +import { Resource, ResourceCollection } from 'resora' + +/** + * {{ControllerName}} + */ +export default class {{ControllerName}} extends BaseController { + /** + * Get all resources + */ + async index () { + return new ResourceCollection({ data: [] }).additional({ + status: 'success', + message: 'OK', + code: 200, + }).response().setStatusCode(200) + } + + /** + * Get a specific resource + */ + async show () { + return new Resource({ data: {} }).additional({ + status: 'success', + message: 'OK', + code: 200, + }).response().setStatusCode(200) + } + + /** + * Create a resource + */ + async create () { + return new Resource({ data: {} }).additional({ + status: 'success', + message: 'New {{Name}} created successfully', + code: 201, + }).response().setStatusCode(201) + } + + /** + * Update a specific resource + */ + async update () { + return new Resource({ data: {} }).additional({ + status: 'success', + message: '{{Name}} updated successfully', + code: 202, + }).response().setStatusCode(202) + } + + /** + * Delete a specific resource + */ + async destroy () { + return new Resource({ data: {}} ).additional({ + status: 'success', + message: '{{Name}} deleted successfully', + code: 202, + }).response().setStatusCode(202) + } +} diff --git a/packages/driver-fastify/stubs/controller.model.stub b/packages/driver-fastify/stubs/controller.model.stub new file mode 100644 index 00000000..30874a08 --- /dev/null +++ b/packages/driver-fastify/stubs/controller.model.stub @@ -0,0 +1,87 @@ +import { BaseController } from '@controllers/BaseController' +import { Bind } from 'clear-router/decorators' +import { Resource, ResourceCollection } from 'resora' +import { {{Model}} } from '@app/models/{{Model}}' +import { Request } from '@arkstack/http' + +/** + * {{ControllerName}} + */ +export default class {{ControllerName}} extends BaseController { + /** + * Get all resource from the database + */ + async index () { + return new ResourceCollection(await {{Model}}.query().orderBy({ id: 'asc' }).get()).additional({ + status: 'success', + message: 'OK', + code: 200, + }).response().setStatusCode(200) + } + + /** + * Get a specific resource from the database + * + * @param {{Param}} + */ + @Bind() + async show ({{Param}}: {{Model}}) { + return new Resource({{Param}}).additional({ + status: 'success', + message: 'OK', + code: 200, + }).response().setStatusCode(200) + } + + /** + * Create a new resource in the database + * + * @param req + */ + @Bind() + async create (req: Request) { + const data = await {{Model}}.query().create({ + data: req.body, + }) + + return new Resource({ data }).additional({ + status: 'success', + message: 'New {{ModelName}} created successfully', + code: 201, + }).response().setStatusCode(201) + } + + /** + * Update a specific resource in the database + * + * @param {{Param}} + * @param req + */ + @Bind() + async update (req: Request, {{Param}}: {{Model}}) { + await {{Param}}.update({ + data: req.body, + }) + + return new Resource({{Param}}).additional({ + status: 'success', + message: '{{ModelName}} updated successfully', + code: 202, + }).response().setStatusCode(202) + } + + /** + * Delete a specific resource from the database + * + * @param {{Param}} + */ + async destroy ({{Param}}: {{Model}}) { + await {{Param}}.delete() + + return new Resource({ data: {} }).additional({ + status: 'success', + message: '{{ModelName}} deleted successfully', + code: 202, + }).response().setStatusCode(202) + } +} diff --git a/packages/driver-fastify/stubs/controller.stub b/packages/driver-fastify/stubs/controller.stub new file mode 100644 index 00000000..8e51a09d --- /dev/null +++ b/packages/driver-fastify/stubs/controller.stub @@ -0,0 +1,10 @@ +import { BaseController } from '@controllers/BaseController' + +/** + * {{ControllerName}} + */ +export default class {{ControllerName}} extends BaseController { + async index () { + return '

Hello!

' + } +} diff --git a/packages/driver-fastify/tests/fixtures/app/.gitignore b/packages/driver-fastify/tests/fixtures/app/.gitignore new file mode 100644 index 00000000..34ff768e --- /dev/null +++ b/packages/driver-fastify/tests/fixtures/app/.gitignore @@ -0,0 +1 @@ +storage/ diff --git a/packages/driver-fastify/tests/fixtures/app/src/routes/api.ts b/packages/driver-fastify/tests/fixtures/app/src/routes/api.ts new file mode 100644 index 00000000..79803615 --- /dev/null +++ b/packages/driver-fastify/tests/fixtures/app/src/routes/api.ts @@ -0,0 +1,3 @@ +import { Router } from '../../../../../src/index' + +Router.get('/status', () => ({ status: 'OK' })) diff --git a/packages/driver-fastify/tests/fixtures/app/src/routes/web.ts b/packages/driver-fastify/tests/fixtures/app/src/routes/web.ts new file mode 100644 index 00000000..1a06843e --- /dev/null +++ b/packages/driver-fastify/tests/fixtures/app/src/routes/web.ts @@ -0,0 +1,3 @@ +import { Router } from '../../../../../src/index' + +Router.get('/welcome', () => 'Welcome') diff --git a/packages/driver-fastify/tests/index.test.ts b/packages/driver-fastify/tests/index.test.ts new file mode 100644 index 00000000..c3b9e334 --- /dev/null +++ b/packages/driver-fastify/tests/index.test.ts @@ -0,0 +1,287 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' +import { AsyncLocalStorage } from 'node:async_hooks' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, writeFileSync } from 'node:fs' + +import { FastifyDriver } from '../src/index' +import { RequestException } from '@arkstack/common' +import { formdata } from '../src/middlewares' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +const makeApp = (driver = new FastifyDriver({ bindRouter: vi.fn() })) => { + const app = driver.createApp() + + driver.registerErrorHandler(app) + + return { app, driver } +} + +describe('FastifyDriver', () => { + let app: FastifyInstance + + afterEach(async () => { + await app?.close() + }) + + it('parses urlencoded bodies out of the box', async () => { + ;({ app } = makeApp()) + app.post('/form', async (req) => req.body) + + const res = await app.inject({ + method: 'POST', + url: '/form', + payload: 'name=Ada&role=admin', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + }) + + expect(res.json()).toEqual({ name: 'Ada', role: 'admin' }) + }) + + it('registers plugins from the driver options', async () => { + const plugin = vi.fn(async () => {}) + + ;({ app } = makeApp(new FastifyDriver({ bindRouter: vi.fn(), plugins: [[plugin, { a: 1 }]] }))) + await app.ready() + + expect(plugin.mock.calls[0][1]).toMatchObject({ a: 1 }) + }) + + it('runs global, before and after middleware at their lifecycle points', async () => { + const seen: string[] = [] + let finished!: () => void + const done = new Promise((resolve) => (finished = resolve)) + + ;({ app } = makeApp()) + new FastifyDriver({ bindRouter: vi.fn() }).applyMiddleware(app, { + global: [async (req: FastifyRequest) => void seen.push(`global:${req.body === undefined}`)], + before: [ + (req: FastifyRequest, _reply: FastifyReply, done: (error?: Error) => void) => { + seen.push(`before:${JSON.stringify(req.body)}`) + done() + }, + ], + after: [ + async (_req: FastifyRequest, reply: FastifyReply) => { + seen.push(`after:${reply.statusCode}`) + finished() + }, + ], + }) + app.post('/thing', async () => { + seen.push('handler') + + return { ok: true } + }) + + const res = await app.inject({ method: 'POST', url: '/thing', payload: { a: 1 } }) + await done + + expect(res.statusCode).toBe(200) + expect(seen).toEqual(['global:true', 'before:{"a":1}', 'handler', 'after:200']) + }) + + it('keeps the async context a callback middleware opens around done', async () => { + const storage = new AsyncLocalStorage() + + ;({ app } = makeApp()) + new FastifyDriver({ bindRouter: vi.fn() }).applyMiddleware(app, { + global: [], + before: [ + async (_req: FastifyRequest, _reply: FastifyReply, done: (error?: Error) => void) => { + await Promise.resolve() + storage.run('fr', () => done()) + }, + ], + after: [], + }) + app.get('/locale', async () => { + await new Promise((resolve) => setTimeout(resolve, 1)) + + return { locale: storage.getStore() } + }) + + const res = await app.inject().get('/locale') + + expect(res.json()).toEqual({ locale: 'fr' }) + }) + + it('resolves class middleware through its handle method', async () => { + class TagMiddleware { + handle(_req: FastifyRequest, reply: FastifyReply) { + reply.header('x-tag', 'set') + } + } + + ;({ app } = makeApp()) + new FastifyDriver({ bindRouter: vi.fn() }).applyMiddleware(app, { + global: [], + before: [TagMiddleware as never], + after: [], + }) + app.get('/thing', async () => ({ ok: true })) + + const res = await app.inject().get('/thing') + + expect(res.headers['x-tag']).toBe('set') + }) + + it('logs a failing after middleware instead of failing the request', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + let failed!: () => void + const done = new Promise((resolve) => (failed = resolve)) + + ;({ app } = makeApp()) + new FastifyDriver({ bindRouter: vi.fn() }).applyMiddleware(app, { + global: [], + before: [], + after: [ + async () => { + setTimeout(failed, 0) + throw new Error('after blew up') + }, + ], + }) + app.get('/thing', async () => ({ ok: true })) + + const res = await app.inject().get('/thing') + await done + + expect(res.statusCode).toBe(200) + expect(errors.mock.calls.flat().join(' ')).toContain('after blew up') + errors.mockRestore() + }) + + it('renders request errors as JSON for api requests and HTML otherwise', async () => { + vi.stubGlobal('view', () => ({ renderSync: () => '

Not Found

' })) + ;({ app } = makeApp()) + app.get('/api/missing', async () => { + throw new RequestException('Profile not found', 404) + }) + app.get('/missing', async () => { + throw new RequestException('Profile not found', 404) + }) + + const api = await app.inject().get('/api/missing') + const web = await app.inject().get('/missing') + + expect(api.statusCode).toBe(404) + expect(api.json()).toMatchObject({ status: 'error', message: 'Profile not found', code: 404 }) + expect(web.statusCode).toBe(404) + expect(web.headers['content-type']).toContain('text/html') + expect(web.body).toBe('

Not Found

') + vi.unstubAllGlobals() + }) + + it('handles errors of routes added after an awaited setup step', async () => { + const app = new FastifyDriver({ bindRouter: vi.fn() }).createApp() + + await new Promise((resolve) => setTimeout(resolve, 5)) + app.get('/api/late', async () => { + throw new RequestException('Late failure', 409) + }) + + const res = await app.inject().get('/api/late') + + expect(res.json()).toMatchObject({ status: 'error', message: 'Late failure', code: 409 }) + await app.close() + }) + + it('uses a custom error handler when provided', async () => { + const errorHandler = vi.fn((_err: Error, _req: FastifyRequest, reply: FastifyReply) => + reply.code(418).send('teapot'), + ) + + ;({ app } = makeApp(new FastifyDriver({ bindRouter: vi.fn(), errorHandler }))) + app.get('/boom', async () => { + throw new Error('boom') + }) + + const res = await app.inject().get('/boom') + + expect(res.statusCode).toBe(418) + expect(errorHandler).toHaveBeenCalledOnce() + }) + + it('serves public assets with cache and cors headers', async () => { + const dir = mkdtempSync(join(tmpdir(), 'arkstack-fastify-')) + writeFileSync(join(dir, 'app.css'), 'body{}') + + ;({ app } = makeApp()) + await new FastifyDriver({ bindRouter: vi.fn() }).mountPublicAssets(app, dir) + + const res = await app.inject().get('/app.css') + + expect(res.statusCode).toBe(200) + expect(res.body).toBe('body{}') + expect(res.headers['cache-control']).toContain('immutable') + expect(res.headers['access-control-allow-origin']).toBe('*') + }) + + it('uses a custom mountPublicAssets override when provided', async () => { + const mountPublicAssets = vi.fn() + + ;({ app } = makeApp()) + await new FastifyDriver({ bindRouter: vi.fn(), mountPublicAssets }).mountPublicAssets(app, '/tmp/public') + + expect(mountPublicAssets.mock.calls[0][0]).toBe(app) + expect(mountPublicAssets.mock.calls[0][1]).toBe('/tmp/public') + }) +}) + +describe('formdata middleware', () => { + let app: FastifyInstance + + beforeEach(() => { + app = new FastifyDriver({ bindRouter: vi.fn() }).createApp() + app.addHook('preHandler', formdata()) + app.post('/upload', async (req) => ({ + body: req.body, + uploads: req.uploads?.map(({ fieldname, originalname, mimetype, size }) => ({ + fieldname, + originalname, + mimetype, + size, + })), + })) + }) + + afterEach(async () => { + await app.close() + }) + + it('reads fields into the body and files into uploads', async () => { + const boundary = 'arkstack' + const payload = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="name"', + '', + 'Ada', + `--${boundary}`, + 'Content-Disposition: form-data; name="avatar"; filename="a.txt"', + 'Content-Type: text/plain', + '', + 'hello', + `--${boundary}--`, + '', + ].join('\r\n') + + const res = await app.inject({ + method: 'POST', + url: '/upload', + payload, + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, + }) + + expect(res.json()).toEqual({ + body: { name: 'Ada' }, + uploads: [{ fieldname: 'avatar', originalname: 'a.txt', mimetype: 'text/plain', size: 5 }], + }) + }) + + it('leaves non-multipart requests alone', async () => { + const res = await app.inject({ method: 'POST', url: '/upload', payload: { name: 'Ada' } }) + + expect(res.json()).toEqual({ body: { name: 'Ada' } }) + }) +}) diff --git a/packages/driver-fastify/tests/middlewares.test.ts b/packages/driver-fastify/tests/middlewares.test.ts new file mode 100644 index 00000000..89b7dbb3 --- /dev/null +++ b/packages/driver-fastify/tests/middlewares.test.ts @@ -0,0 +1,188 @@ +import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify' +import { AuthMiddleware, auth } from '../src/middlewares/auth' +import { RequestLoggerMiddleware, requestLogger } from '../src/middlewares/request-logger' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { Logger } from '@arkstack/common' +import { defaultErrorHandler } from '../src/error-handler' +import { getCtx } from 'resora' +import { limiter } from '../src/middlewares/limiter' +import { resora } from '../src/middlewares/resora' + +const authorizeToken = vi.fn(async (token: string) => ({ id: 1, token })) + +vi.mock('@arkstack/auth', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + Auth: { + make: () => ({ + setRequest() { + return this + }, + authorizeToken, + session: () => ({ id: 'session' }), + }), + }, + } +}) + +const makeApp = () => { + const app = Fastify() + + app.setErrorHandler(defaultErrorHandler) + + return app +} + +describe('Fastify middlewares', () => { + let app: FastifyInstance + + afterEach(async () => { + await app?.close() + vi.unstubAllEnvs() + vi.restoreAllMocks() + }) + + it('rejects requests without a bearer token', async () => { + app = makeApp() + app.get('/me', { preHandler: auth }, () => ({ ok: true })) + + const res = await app.inject().get('/me').headers({ accept: 'application/json' }) + + expect(res.statusCode).toBe(401) + expect(authorizeToken).not.toHaveBeenCalled() + }) + + it('attaches the authorized user, auth and session to the request', async () => { + app = makeApp() + app.get('/me', { preHandler: (req, reply) => new AuthMiddleware().handle(req, reply) }, (req) => ({ + user: req.user, + token: req.authToken, + session: req.session, + })) + + const res = await app.inject().get('/me').headers({ authorization: 'Bearer secret' }) + + expect(res.json()).toEqual({ user: { id: 1, token: 'secret' }, token: 'secret', session: { id: 'session' } }) + expect(authorizeToken).toHaveBeenCalledWith('secret') + }) + + it('limits requests with the rate limiter plugin entry', async () => { + app = makeApp() + + const [plugin, options] = limiter(1, 60, 'Slow down') as [never, object] + + await app.register(plugin, options) + app.get('/limited', () => ({ ok: true })) + + const first = await app.inject().get('/limited') + const second = await app.inject().get('/limited').headers({ accept: 'application/json' }) + + expect(first.statusCode).toBe(200) + expect(second.statusCode).toBe(429) + expect(second.json().message).toBe('Slow down') + }) + + it('logs finished requests outside of tests', async () => { + vi.stubEnv('VITEST', '') + vi.stubEnv('NODE_ENV', 'development') + vi.stubEnv('VERBOSITY', '1') + + const log = vi.spyOn(Logger, 'log').mockReturnValue('') + + app = makeApp() + app.addHook('onResponse', (req, reply) => new RequestLoggerMiddleware().handle(req, reply)) + app.get('/logged', () => ({ ok: true })) + + await app.inject().get('/logged') + + expect(log).toHaveBeenCalledOnce() + expect(log.mock.calls[0][0]).toEqual(expect.arrayContaining([['[GET]', 'green'], ['/logged', 'cyan']])) + }) + + it('skips request logging in tests', async () => { + const log = vi.spyOn(Logger, 'log').mockReturnValue('') + + await requestLogger()({ method: 'GET', url: '/' } as FastifyRequest, { statusCode: 200 } as FastifyReply) + + expect(log).not.toHaveBeenCalled() + }) + + it('binds the request and reply as the resora context', async () => { + app = makeApp() + app.addHook('preHandler', resora()) + app.get('/ctx', (req, reply) => { + const ctx = getCtx() as { req: unknown; res: unknown } + + return { req: ctx.req === req, res: ctx.res === reply } + }) + + const res = await app.inject().get('/ctx') + + expect(res.json()).toEqual({ req: true, res: true }) + }) +}) + +describe('Fastify error handler', () => { + let app: FastifyInstance + + afterEach(async () => { + await app?.close() + }) + + const validationError = () => + Object.assign(new Error('The given data was invalid.'), { + statusCode: 422, + errors: () => ({ email: ['The email field is required.'] }), + }) + + it('redirects failed web validation back and flashes the errors', async () => { + const session = { addErrors: vi.fn(), addValidationErrors: vi.fn(), save: vi.fn() } + + app = makeApp() + app.addHook('onRequest', async (req) => { + Object.assign(req, { arkstackWeb: true, session }) + }) + app.post('/register', () => { + throw validationError() + }) + + const res = await app.inject().post('/register').headers({ referer: '/register' }) + + expect(res.statusCode).toBe(302) + expect(res.headers.location).toBe('/register') + expect(session.addErrors).toHaveBeenCalledWith({ email: ['The email field is required.'] }) + expect(session.save).toHaveBeenCalledOnce() + }) + + it('redirects home when a failed web request has no referer', async () => { + app = makeApp() + app.addHook('onRequest', async (req) => { + Object.assign(req, { arkstackWeb: true }) + }) + app.post('/register', () => { + throw validationError() + }) + + const res = await app.inject().post('/register') + + expect(res.statusCode).toBe(302) + expect(res.headers.location).toBe('/') + }) + + it('leaves a reply that was already sent alone', async () => { + app = makeApp() + app.get('/sent', async (_req, reply) => { + await reply.send({ ok: true }) + + throw new Error('after send') + }) + + const res = await app.inject().get('/sent') + + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ ok: true }) + }) +}) diff --git a/packages/driver-fastify/tests/router.test.ts b/packages/driver-fastify/tests/router.test.ts new file mode 100644 index 00000000..642c96ea --- /dev/null +++ b/packages/driver-fastify/tests/router.test.ts @@ -0,0 +1,119 @@ +import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { FastifyDriver, Router } from '../src/index' +import { Arkstack } from '@arkstack/contract' +import { Logger } from '@arkstack/common' +import path from 'node:path' +import { Resource } from 'resora' + +describe('Fastify Router', () => { + let app: FastifyInstance + let warnings: string[] + + beforeEach(() => { + warnings = [] + Router.reset() + }) + + afterEach(async () => { + await app?.close() + vi.restoreAllMocks() + }) + + const boot = async (routes: () => void, before: Array<(req: FastifyRequest) => unknown> = []) => { + const driver = new FastifyDriver({ + bindRouter: (instance) => void Router.apply(instance), + createApp: () => Fastify({ logger: { level: 'warn', stream: { write: (line: string) => warnings.push(line) } } }), + }) + + app = driver.createApp() + routes() + await driver.bindRouter(app) + driver.applyMiddleware(app, { global: [], before, after: [] }) + driver.registerErrorHandler(app) + await app.ready() + } + + it('dispatches resora resources returned from routes once', async () => { + await boot(() => { + Router.get('/users/1', () => new Resource({ id: 1, name: 'Ada' })) + }) + + const res = await app.inject().get('/users/1') + + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ data: { id: 1, name: 'Ada' } }) + expect(warnings.join('')).not.toContain('FST_ERR_REP_ALREADY_SENT') + }) + + it('runs app middleware for routes applied before it', async () => { + const before = vi.fn() + + await boot(() => { + Router.get('/ping', () => ({ ok: true })) + }, [before]) + + const res = await app.inject().get('/ping') + + expect(res.json()).toEqual({ ok: true }) + expect(before).toHaveBeenCalledOnce() + }) + + it('serves api resources, including method overrides', async () => { + class UserController { + show = () => ({ action: 'show' }) + update = () => ({ action: 'update' }) + destroy = () => ({ action: 'destroy' }) + } + + await boot(() => { + Router.apiResource('/users', UserController, { only: ['show', 'update', 'destroy'] }) + }) + + const show = await app.inject().get('/users/1') + const update = await app.inject().post('/users/1').payload({ _method: 'PUT' }) + const destroy = await app.inject().post('/users/1').payload({ _method: 'DELETE' }) + + expect(show.json()).toEqual({ action: 'show' }) + expect(update.json()).toEqual({ action: 'update' }) + expect(destroy.json()).toEqual({ action: 'destroy' }) + }) + + it('lists the registered routes', async () => { + Router.get('/listed', () => ({ ok: true })) + + const routes = Router.allRoutes() + + expect(routes.map((route) => route.path)).toContain('/listed') + }) + it('binds the api and web route files and answers unmatched requests with a 404', async () => { + vi.spyOn(Arkstack, 'rootDir').mockReturnValue(path.join(__dirname, 'fixtures/app')) + + app = new FastifyDriver({ bindRouter: (instance) => void Router.bind(instance) }).createApp() + await Router.bind(app) + await app.ready() + + const api = await app.inject().get('/api/status') + const web = await app.inject().get('/welcome') + const missing = await app.inject().get('/api/missing') + + expect(api.json()).toEqual({ status: 'OK' }) + expect(web.body).toBe('Welcome') + expect(missing.statusCode).toBe(404) + expect(missing.json().message).toBe('Cannot find any route matching [GET] /api/missing') + }) + + it('logs route files that cannot be loaded', 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'), + ]) + }) +}) diff --git a/packages/driver-fastify/tests/server-start-hook.test.ts b/packages/driver-fastify/tests/server-start-hook.test.ts new file mode 100644 index 00000000..89e0bb05 --- /dev/null +++ b/packages/driver-fastify/tests/server-start-hook.test.ts @@ -0,0 +1,142 @@ +import type { ServerStartInfo } from '@arkstack/contract' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { FastifyInstance } from 'fastify' +import { FastifyDriver } from '../src/index' +import { Hook } from '@arkstack/foundry' +import { ServerDriver } from '@arkstack/common' + +/** The driver's ngrok call, stubbed so no tunnel is opened. */ +type Tunnel = { forward: (port: number) => Promise } + +/** A fake app whose `listen` "binds" immediately, so no real port is opened. */ +const fakeApp = () => ({ listen: vi.fn(async () => 'http://localhost') }) as unknown as FastifyInstance + +/** Start the driver and resolve with what the `server:start` hook received. */ +const startAndCapture = (port = 4321) => + new Promise((resolve) => { + Hook.on('server:start', { after: (info) => resolve(info) }) + new FastifyDriver({ bindRouter: vi.fn() }).start(fakeApp(), port) + }) + +describe('FastifyDriver server:start hook', () => { + const saved = { ...process.env } + + beforeEach(() => { + Hook.clear() + vi.spyOn(ServerDriver.prototype as unknown as Tunnel, 'forward').mockResolvedValue('https://opened.ngrok.app') + vi.spyOn(console, 'log').mockImplementation(() => {}) + for (const key of ['APP_HOST', 'HOST', 'APP_SECURE', 'TUNNEL', 'TUNNEL_URL']) delete process.env[key] + }) + + afterEach(() => { + process.env = { ...saved } + vi.restoreAllMocks() + }) + + it('reports the bound address and a browsable local url', async () => { + const info = await startAndCapture(4321) + + expect(info).toEqual({ + host: '0.0.0.0', + port: 4321, + scheme: 'http', + secure: false, + url: 'http://localhost:4321', + tunnelUrl: undefined, + }) + }) + + it('passes the url of a tunnel it opened (not the unset TUNNEL_URL)', async () => { + process.env.TUNNEL = 'true' + + const info = await startAndCapture() + + expect(info.tunnelUrl).toBe('https://opened.ngrok.app') + }) + + it('passes a pre-set TUNNEL_URL through', async () => { + process.env.TUNNEL_URL = 'https://preset.example.com' + + const info = await startAndCapture() + + expect(info.tunnelUrl).toBe('https://preset.example.com') + }) + + it('logs a failing listener instead of crashing the started server', async () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + let settled!: () => void + const done = new Promise((resolve) => (settled = resolve)) + + Hook.on('server:start', { + after: async () => { + setTimeout(settled, 0) + throw new Error('listener blew up') + }, + }) + + await new FastifyDriver({ bindRouter: vi.fn() }).start(fakeApp(), 4321) + await done + + expect(errors.mock.calls.flat().join(' ')).toContain('listener blew up') + }) + + describe('tunneled position', () => { + /** Start the driver; resolve with the order positions ran and the tunneled payload. */ + const startAndWatch = () => + new Promise<{ order: string[]; tunneled?: ServerStartInfo }>((resolve) => { + const order: string[] = [] + Hook.on('server:start', { + after: () => { + order.push('after') + // No tunnel: `tunneled` never runs, so settle once `after` has. + if (!process.env.TUNNEL && !process.env.TUNNEL_URL) setTimeout(() => resolve({ order }), 0) + }, + tunneled: (info) => { + order.push('tunneled') + resolve({ order, tunneled: info }) + }, + }) + new FastifyDriver({ bindRouter: vi.fn() }).start(fakeApp(), 4321) + }) + + it('runs after `after` with the tunnel it opened', async () => { + process.env.TUNNEL = 'true' + + const { order, tunneled } = await startAndWatch() + + expect(order).toEqual(['after', 'tunneled']) + expect(tunneled?.tunnelUrl).toBe('https://opened.ngrok.app') + }) + + it('runs for a preset TUNNEL_URL', async () => { + process.env.TUNNEL_URL = 'https://preset.example.com' + + expect((await startAndWatch()).tunneled?.tunnelUrl).toBe('https://preset.example.com') + }) + + it('does not run without a tunnel', async () => { + const { order, tunneled } = await startAndWatch() + + expect(order).toEqual(['after']) + expect(tunneled).toBeUndefined() + }) + + it('still runs when `after` throws', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + process.env.TUNNEL = 'true' + + const tunneled = new Promise((resolve) => + Hook.on('server:start', { + after: () => { + throw new Error('after blew up') + }, + tunneled: ({ tunnelUrl }) => resolve(tunnelUrl), + }), + ) + new FastifyDriver({ bindRouter: vi.fn() }).start(fakeApp(), 4321) + + expect(await tunneled).toBe('https://opened.ngrok.app') + }) + }) +}) diff --git a/packages/driver-fastify/tsdown.config.ts b/packages/driver-fastify/tsdown.config.ts new file mode 100644 index 00000000..ebbfed60 --- /dev/null +++ b/packages/driver-fastify/tsdown.config.ts @@ -0,0 +1,22 @@ +import config from '../../tsdown.config' +import { defineConfig } from 'tsdown' + +export default defineConfig({ + hooks: config.hooks, + entry: ['src/index.ts', 'src/middlewares/index.ts', 'src/types.ts'], + exports: true, + format: 'esm', + dts: true, + clean: true, + outDir: 'dist', + deps: { + skipNodeModulesBundle: true, + neverBundle: [/^@h3ravel\/.*/gi, /^@arkstack\/.*/gi], + }, + outExtensions() { + return { + js: '.js', + 'd.ts': '.ts', + } + }, +}) diff --git a/packages/driver-h3/package.json b/packages/driver-h3/package.json index c26fcfc6..08b656c4 100644 --- a/packages/driver-h3/package.json +++ b/packages/driver-h3/package.json @@ -44,7 +44,6 @@ "dependencies": { "@arkstack/contract": "workspace:^", "@arkstack/http": "workspace:^", - "@ngrok/ngrok": "^1.7.0", "@resora/plugin-clear-router": "catalog:", "clear-router": "catalog:", "resora": "catalog:" diff --git a/packages/driver-h3/src/H3Driver.ts b/packages/driver-h3/src/H3Driver.ts index 47bd221b..281e2f27 100644 --- a/packages/driver-h3/src/H3Driver.ts +++ b/packages/driver-h3/src/H3Driver.ts @@ -1,94 +1,12 @@ -import { ArkstackKitDriver, PromiseOrValue } from '@arkstack/contract' import { H3, serve, toResponse } from 'h3' import { H3DriverOptions, H3Middleware, Middleware, MiddlewareConfig } from './types' -import { Logger, devTlsCredentials, env, localNetworkAddress } from '@arkstack/common' +import { ServerDriver, devTlsCredentials } from '@arkstack/common' -import { Hook } from '@arkstack/foundry' -import type { ServerStartInfo } from '@arkstack/contract' +import { PromiseOrValue } from '@arkstack/contract' import { defaultErrorHandler } from './error-handler' -import ngrok from '@ngrok/ngrok' import { resolveMiddleware } from '@arkstack/http' import { staticAssetHandler } from './middlewares' -/** - * Run one `server:start` position. A failing listener is logged, never thrown: it - * must not take down a server that is already up, nor skip the next position. - * - * @param position - * @param run - */ -const emitServerStartPosition = async (position: string, run: () => unknown): Promise => { - try { - await run() - } catch (error) { - Logger.error( - `server:start ${position} hook failed: ${error instanceof Error ? error.message : String(error)}`, - false, - ) - } -} - -/** - * Run the `server:start` hook once the server is listening: `after` always, then - * `tunneled` when the server is reachable through a tunnel. - * - * @param info - */ -const emitServerStart = async (info: ServerStartInfo): Promise => { - await emitServerStartPosition('after', () => Hook.emit('server:start', 'after', info)) - - const { tunnelUrl } = info - - if (tunnelUrl) { - await emitServerStartPosition('tunneled', () => Hook.emit('server:start', 'tunneled', { ...info, tunnelUrl })) - } -} - -/** - * Build the "Server is running" startup lines, adding a local-network URL when - * the server is bound to all interfaces (`0.0.0.0`/`::`) so it is reachable from - * other devices. - * - * @param scheme - * @param host - * @param port - * @returns - */ -const startupLogLines = (scheme: string, host: string, port: number): string[] => { - const bindsAll = host === '0.0.0.0' || host === '::' - const localHost = bindsAll ? 'localhost' : host - - const lines = [ - Logger.log( - [ - ['Server is running on', 'white'], - [`${scheme}://${localHost}:${port}`, 'cyan'], - ], - ' ', - false, - ), - ] - - if (bindsAll) { - const address = localNetworkAddress() - - if (address) { - lines.push( - Logger.log( - [ - ['Network access via', 'white'], - [`${scheme}://${address}:${port}`, 'cyan'], - ], - ' ', - false, - ), - ) - } - } - - return lines -} - export class H3EventResponse { status: number = 200 statusText?: string @@ -106,9 +24,8 @@ export class H3EventResponse { /** * The H3Driver class implements the ArkstackKitDriver contract for the H3 framework. */ -export class H3Driver extends ArkstackKitDriver { +export class H3Driver extends ServerDriver { readonly name = 'h3' - private tunnel_url?: string private readonly options: H3DriverOptions /** @@ -196,95 +113,25 @@ export class H3Driver extends ArkstackKitDriver { } } - /** - * If trafic has been proxied via ngrok, this will return the tunnel URL. - * - * @returns - */ - geTunnelUrl(): string | undefined { - return this.tunnel_url - } - /** * Starts the H3 server on the specified port. * - * The bind host can be overridden with the `APP_HOST` (or `HOST`) env - * variable. It defaults to `0.0.0.0` so the server is reachable on all - * network interfaces, which platforms like Railway require for their - * healthcheck proxy to reach the app. - * * @param app * @param port */ async start(app: H3, port: number): Promise { - const host = env('APP_HOST', env('HOST', '0.0.0.0')) - const secure = env('APP_SECURE', false) === true - const tunneled = env('TUNNEL', false) - const tunnelUrl = env('TUNNEL_URL') - const scheme: ServerStartInfo['scheme'] = secure ? 'https' : 'http' + const binding = this.binding(port) // Dev HTTPS: serve with an in-memory self-signed certificate. - const tls = secure ? await devTlsCredentials() : undefined + const tls = binding.secure ? await devTlsCredentials() : undefined await serve(app, { port, - hostname: host, + hostname: binding.host, silent: true, ...(tls ? { protocol: 'https', tls: { cert: tls.cert, key: tls.key } } : {}), } as never).ready() - let log = startupLogLines(scheme, host, port) - - if (tunnelUrl) { - log = log.concat( - Logger.log( - [ - ['Traffic has been tunnelled to', 'white'], - [tunnelUrl, 'green'], - ], - ' ', - false, - ), - ) - - this.tunnel_url = tunnelUrl - globalThis.tunnelUrl = () => tunnelUrl - } else if (tunneled === true) { - const listener = await ngrok.forward({ - addr: port, - authtoken: env('NGROK_AUTHTOKEN'), - domain: env('NGROK_DOMAIN'), - }) - - const url = listener.url() - - if (url) { - log = log.concat( - Logger.log( - [ - ['Traffic has been tunnelled to', 'white'], - [url, 'green'], - ], - ' ', - false, - ), - ) - - process.env.TUNNEL_URL = url - this.tunnel_url = url - globalThis.tunnelUrl = () => url - } - } - - console.log(log.join('\n')) - - await emitServerStart({ - host, - port, - scheme, - secure, - url: `${scheme}://${host === '0.0.0.0' || host === '::' ? 'localhost' : host}:${port}`, - tunnelUrl: this.tunnel_url || undefined, - }) + await this.announce(binding) } } diff --git a/packages/driver-h3/tests/server-start-hook.test.ts b/packages/driver-h3/tests/server-start-hook.test.ts index a90329c4..6c2838ca 100644 --- a/packages/driver-h3/tests/server-start-hook.test.ts +++ b/packages/driver-h3/tests/server-start-hook.test.ts @@ -4,10 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { H3 } from 'h3' import { H3Driver } from '../src/index' import { Hook } from '@arkstack/foundry' - -vi.mock('@ngrok/ngrok', () => ({ - default: { forward: vi.fn(async () => ({ url: () => 'https://opened.ngrok.app' })) }, -})) +import { ServerDriver } from '@arkstack/common' // `serve` "binds" immediately, so no real port is opened. vi.mock('h3', async (original) => ({ @@ -15,6 +12,9 @@ vi.mock('h3', async (original) => ({ serve: vi.fn(() => ({ ready: async () => undefined })), })) +/** The driver's ngrok call, stubbed so no tunnel is opened. */ +type Tunnel = { forward: (port: number) => Promise } + /** Start the driver and return what the `server:start` hook received. */ const startAndCapture = async (port = 4321) => { let received: ServerStartInfo | undefined @@ -31,6 +31,7 @@ describe('H3Driver server:start hook', () => { beforeEach(() => { Hook.clear() + vi.spyOn(ServerDriver.prototype as unknown as Tunnel, 'forward').mockResolvedValue('https://opened.ngrok.app') vi.spyOn(console, 'log').mockImplementation(() => {}) for (const key of ['APP_HOST', 'HOST', 'APP_SECURE', 'TUNNEL', 'TUNNEL_URL']) delete process.env[key] }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b1a55cc..2729cab1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,20 +31,23 @@ catalogs: specifier: ^0.1.8 version: 0.1.8 '@resora/plugin-clear-router': - specifier: ^1.0.72 - version: 1.0.72 + specifier: ^1.0.73 + version: 1.0.73 arkormx: specifier: ^2.12.8 version: 2.12.8 clear-router: - specifier: ^2.9.3 - version: 2.9.3 + specifier: ^2.9.4 + version: 2.9.4 dotenv: specifier: ^17.4.2 version: 17.4.2 express: specifier: ^5.2.1 version: 5.2.1 + fastify: + specifier: ^5.8.5 + version: 5.12.8 h3: specifier: ^2.0.1-rc.22 version: 2.0.1-rc.22 @@ -61,8 +64,8 @@ catalogs: specifier: ^8.22.0 version: 8.22.0 resora: - specifier: ^1.3.35 - version: 1.3.35 + specifier: ^1.3.36 + version: 1.3.36 tsdown: specifier: ^0.22.14 version: 0.22.14 @@ -137,7 +140,7 @@ importers: version: 2.12.8(@types/node@25.6.2)(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) clear-router: specifier: 'catalog:' - version: 2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) + version: 2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) dotenv: specifier: 'catalog:' version: 17.4.2 @@ -188,7 +191,7 @@ importers: version: 0.2.2 resora: specifier: 'catalog:' - version: 1.3.35(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) + version: 1.3.36(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) sass-embedded: specifier: ^1.99.0 version: 1.99.0 @@ -337,6 +340,9 @@ importers: '@h3ravel/support': specifier: 'catalog:' version: 2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22) + '@ngrok/ngrok': + specifier: ^1.7.0 + version: 1.7.0 '@pictwo/faker': specifier: ^1.1.0 version: 1.1.0 @@ -400,10 +406,10 @@ importers: version: 5.6.2 clear-router: specifier: 'catalog:' - version: 2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) + version: 2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) resora: specifier: 'catalog:' - version: 1.3.35(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) + version: 1.3.36(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) ts-morph: specifier: ^28.0.0 version: 28.0.0 @@ -424,10 +430,10 @@ importers: version: 5.6.2 clear-router: specifier: 'catalog:' - version: 2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) + version: 2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) resora: specifier: 'catalog:' - version: 1.3.35(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) + version: 1.3.36(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) packages/contract: dependencies: @@ -439,7 +445,7 @@ importers: dependencies: '@arkormx/plugin-clear-router': specifier: 'catalog:' - version: 0.1.57(@types/node@25.6.2)(clear-router@2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2))(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) + version: 0.1.57(@types/node@25.6.2)(clear-router@2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2))(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) '@arkstack/common': specifier: workspace:^ version: link:../common @@ -451,7 +457,7 @@ importers: version: 2.12.8(@types/node@25.6.2)(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) clear-router: specifier: 'catalog:' - version: 2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) + version: 2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) dotenv: specifier: 'catalog:' version: 17.4.2 @@ -466,7 +472,7 @@ importers: version: 8.22.0 resora: specifier: 'catalog:' - version: 1.3.35(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) + version: 1.3.36(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) devDependencies: '@types/pg': specifier: ^8.16.0 @@ -492,15 +498,12 @@ importers: '@arkstack/inertia': specifier: workspace:^ version: link:../inertia - '@ngrok/ngrok': - specifier: ^1.7.0 - version: 1.7.0 '@resora/plugin-clear-router': specifier: 'catalog:' - version: 1.0.72(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)))(clear-router@2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2)) + version: 1.0.73(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)))(clear-router@2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2)) clear-router: specifier: 'catalog:' - version: 2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) + version: 2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) express: specifier: 'catalog:' version: 5.2.1 @@ -512,7 +515,7 @@ importers: version: 2.1.1 resora: specifier: 'catalog:' - version: 1.3.35(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) + version: 1.3.36(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) devDependencies: '@types/express': specifier: ^5.0.6 @@ -521,6 +524,48 @@ importers: specifier: ^2.1.0 version: 2.1.0 + packages/driver-fastify: + dependencies: + '@arkstack/auth': + specifier: workspace:^ + version: link:../auth + '@arkstack/common': + specifier: workspace:^ + version: link:../common + '@arkstack/contract': + specifier: workspace:^ + version: link:../contract + '@arkstack/foundry': + specifier: workspace:^ + version: link:../foundry + '@arkstack/http': + specifier: workspace:^ + version: link:../http + '@fastify/formbody': + specifier: ^8.0.2 + version: 8.0.2 + '@fastify/multipart': + specifier: ^9.0.3 + version: 9.4.0 + '@fastify/rate-limit': + specifier: ^10.3.0 + version: 10.3.0 + '@fastify/static': + specifier: ^8.2.0 + version: 8.3.0 + '@resora/plugin-clear-router': + specifier: 'catalog:' + version: 1.0.73(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)))(clear-router@2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2)) + clear-router: + specifier: 'catalog:' + version: 2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) + fastify: + specifier: 'catalog:' + version: 5.12.8 + resora: + specifier: 'catalog:' + version: 1.3.36(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) + packages/driver-h3: dependencies: '@arkstack/auth': @@ -541,21 +586,18 @@ importers: '@arkstack/inertia': specifier: workspace:^ version: link:../inertia - '@ngrok/ngrok': - specifier: ^1.7.0 - version: 1.7.0 '@resora/plugin-clear-router': specifier: 'catalog:' - version: 1.0.72(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)))(clear-router@2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2)) + version: 1.0.73(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)))(clear-router@2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2)) clear-router: specifier: 'catalog:' - version: 2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) + version: 2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) h3: specifier: 'catalog:' version: 2.0.1-rc.22 resora: specifier: 'catalog:' - version: 1.3.35(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) + version: 1.3.36(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) packages/encryption: devDependencies: @@ -612,7 +654,7 @@ importers: version: 2.12.8(@types/node@25.6.2)(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) clear-router: specifier: 'catalog:' - version: 2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) + version: 2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) dotenv: specifier: 'catalog:' version: 17.4.2 @@ -792,7 +834,7 @@ importers: version: 2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) clear-router: specifier: 'catalog:' - version: 2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) + version: 2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) edge.js: specifier: ^6.5.0 version: 6.5.0 @@ -861,7 +903,7 @@ importers: version: 2.12.8(@types/node@25.6.2)(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) clear-router: specifier: 'catalog:' - version: 2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) + version: 2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) cors: specifier: ^2.8.6 version: 2.8.6 @@ -882,7 +924,7 @@ importers: version: 8.22.0 resora: specifier: 'catalog:' - version: 1.3.35(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) + version: 1.3.36(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -1000,7 +1042,7 @@ importers: version: 2.12.8(@types/node@25.6.2)(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) clear-router: specifier: 'catalog:' - version: 2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) + version: 2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) cors: specifier: ^2.8.6 version: 2.8.6 @@ -1027,7 +1069,7 @@ importers: version: 19.2.7(react@19.2.7) resora: specifier: 'catalog:' - version: 1.3.35(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) + version: 1.3.36(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) vite: specifier: ^8.1.0 version: 8.1.0(@types/node@25.6.2)(esbuild@0.27.3)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.21.0) @@ -1090,6 +1132,136 @@ importers: specifier: ^4.1.5 version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(vite@8.1.0(@types/node@25.6.2)(esbuild@0.27.3)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.21.0)) + templates/fastify: + dependencies: + '@arkstack/auth': + specifier: workspace:^ + version: link:../../packages/auth + '@arkstack/cache': + specifier: workspace:^ + version: link:../../packages/cache + '@arkstack/common': + specifier: workspace:^ + version: link:../../packages/common + '@arkstack/console': + specifier: workspace:^ + version: link:../../packages/console + '@arkstack/contract': + specifier: workspace:^ + version: link:../../packages/contract + '@arkstack/database': + specifier: workspace:^ + version: link:../../packages/database + '@arkstack/driver-fastify': + specifier: workspace:^ + version: link:../../packages/driver-fastify + '@arkstack/filesystem': + specifier: workspace:^ + version: link:../../packages/filesystem + '@arkstack/foundry': + specifier: workspace:^ + version: link:../../packages/foundry + '@arkstack/http': + specifier: workspace:^ + version: link:../../packages/http + '@arkstack/jobs': + specifier: workspace:^ + version: link:../../packages/jobs + '@arkstack/lang': + specifier: workspace:^ + version: link:../../packages/lang + '@arkstack/notifications': + specifier: workspace:^ + version: link:../../packages/notifications + '@arkstack/queue': + specifier: workspace:^ + version: link:../../packages/queue + '@arkstack/scheduler': + specifier: workspace:^ + version: link:../../packages/scheduler + '@arkstack/view': + specifier: workspace:^ + version: link:../../packages/view + '@fastify/cors': + specifier: ^11.1.0 + version: 11.4.0 + '@h3ravel/musket': + specifier: 'catalog:' + version: 2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) + '@h3ravel/support': + specifier: 'catalog:' + version: 2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22) + '@kanun-hq/plugin-file': + specifier: 'catalog:' + version: 0.1.12(kanun@1.2.0) + arkormx: + specifier: 'catalog:' + version: 2.12.8(@types/node@25.6.2)(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) + clear-router: + specifier: 'catalog:' + version: 2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) + dotenv: + specifier: 'catalog:' + version: 17.4.2 + fastify: + specifier: 'catalog:' + version: 5.12.8 + kanun: + specifier: 'catalog:' + version: 1.2.0 + kysely: + specifier: 'catalog:' + version: 0.29.2 + pg: + specifier: 'catalog:' + version: 8.22.0 + resora: + specifier: 'catalog:' + version: 1.3.36(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.3.0(jiti@2.7.0)) + '@eslint/markdown': + specifier: ^8.0.1 + version: 8.0.1 + '@faker-js/faker': + specifier: ^10.4.0 + version: 10.4.0 + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + '@types/pg': + specifier: ^8.16.0 + version: 8.16.0 + eslint: + specifier: ^10.3.0 + version: 10.3.0(jiti@2.7.0) + parasito: + specifier: 'catalog:' + version: 0.2.6 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + source-map-support: + specifier: ^0.5.21 + version: 0.5.21 + tsdown: + specifier: 'catalog:' + version: 0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0) + typescript: + specifier: ^6.0.0 + version: 6.0.3 + typescript-eslint: + specifier: ^8.59.2 + version: 8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3) + unrun: + specifier: ^0.3.0 + version: 0.3.0 + vitest: + specifier: ^4.1.5 + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(vite@8.1.0(@types/node@25.6.2)(esbuild@0.27.3)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.21.0)) + templates/h3: dependencies: '@arkstack/auth': @@ -1154,7 +1326,7 @@ importers: version: 2.12.8(@types/node@25.6.2)(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) clear-router: specifier: 'catalog:' - version: 2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) + version: 2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) dotenv: specifier: 'catalog:' version: 17.4.2 @@ -1172,7 +1344,7 @@ importers: version: 8.22.0 resora: specifier: 'catalog:' - version: 1.3.35(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) + version: 1.3.36(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -1738,9 +1910,51 @@ packages: resolution: {integrity: sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} + '@fastify/accept-negotiator@2.1.2': + resolution: {integrity: sha512-LV7MZqTwXTEt3GxBT6BU8ACztWnv++hSkMEsP3usIHiI0tA4F5EO4THhDCOr5J/ginM9DCnLCmZ6u3JLA9x8Iw==} + + '@fastify/ajv-compiler@4.0.7': + resolution: {integrity: sha512-VP0yIfwlc1ohj733keYDqyHm1VWeP9VQxYomunhr90jkC4aNNibbkrRiaNgPgqj1ORxUenILR4qzA/ppYBNAtQ==} + '@fastify/busboy@3.2.0': resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + '@fastify/cors@11.4.0': + resolution: {integrity: sha512-+P4+RIRx5I3rkC1QttZ9gqSJ3AHrsM4PrZF6bpgbElt86n7gOBiHL3J0H3moVSyfc+vvvNto+Ay1LmG7ZQ37pA==} + + '@fastify/deepmerge@3.2.4': + resolution: {integrity: sha512-kc8GQq5gX9RRhw0rov3DMrN8wS8yRlxJRdDzsYwwdBV/wazuVgc/1K7CKL90tlvu3RlHan6Y0R0aE3nJtBz2eQ==} + + '@fastify/error@4.2.1': + resolution: {integrity: sha512-MYBumuIwP+5MIpXSyKm6/o1Eigcoij186lWUssX3LH5U8KWqU0ktZhzC4CErIX9EJeQeRGI+zJZQWakBlFbUcg==} + + '@fastify/fast-json-stringify-compiler@5.1.1': + resolution: {integrity: sha512-pyxRKk7UpUnnrztP9pF4+ZYxusEQ4LifTyzRdiXmyql1QezNHxO02D25MVU2o4kMeAMYWAjG6RKRlojgkbiDAw==} + + '@fastify/formbody@8.0.2': + resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==} + + '@fastify/forwarded@3.1.2': + resolution: {integrity: sha512-89WiRG+hW3VQSfCwg4Hb8b43PeyEX15K5+lEfsZ4EUth/FyOjS/lv8HsVuswUNXWiSnxMGNouE/fq/0w3VumHQ==} + + '@fastify/merge-json-schemas@0.2.2': + resolution: {integrity: sha512-d/gx9sRbUCVgGRGzma46wLcwiY8noTqO2Nmjg/vbTARxHRE5bdh5PIL8s97Lt1X7ScRWRW52eVYxwIoPwIdVUA==} + + '@fastify/multipart@9.4.0': + resolution: {integrity: sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==} + + '@fastify/proxy-addr@5.1.2': + resolution: {integrity: sha512-uqB4QsgVoro0XDC+DIftIMGXHdBPn+lK6Ek3xXAIxPMCJfqxnbtyEoMCm2KPUVOuIosd7Cyw9HawEqMFM/UA+g==} + + '@fastify/rate-limit@10.3.0': + resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} + + '@fastify/send@4.2.1': + resolution: {integrity: sha512-zOB8L9z444PnGbd1Nd7gNXPz/7bnDR8Z4KNTNSwLVQuE3cTGsU9qUj1N4SjXKK0Tq4cauZYTwB+tqvlqosuKsw==} + + '@fastify/static@8.3.0': + resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==} + '@firebase/ai@1.4.1': resolution: {integrity: sha512-bcusQfA/tHjUjBTnMx6jdoPMpDl3r8K15Z+snHz9wq0Foox0F/V+kNLXucEOHoTL2hTc9l+onZCyBJs2QoIC3g==} engines: {node: '>=18.0.0'} @@ -2284,6 +2498,10 @@ packages: peerDependencies: kanun: ^1.2.0 + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -2553,8 +2771,8 @@ packages: '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} - '@resora/plugin-clear-router@1.0.72': - resolution: {integrity: sha512-OMNYCi2mG7uesXeZ1PwLUon8XyuqdjzRXFkzw2NdTWdtjgqUMzA+tXqgrb5czn/55xgCat1jzOj8udgh9MhELQ==} + '@resora/plugin-clear-router@1.0.73': + resolution: {integrity: sha512-CjhKfwf8ip/7AgF/0hNZJvACC8dSNqNyyU5VDHgNEaOBzEAe+p2XaSwkISADgf7z3Ea7t1fhSJPppnKX4ZhruA==} peerDependencies: clear-router: ^2.9.2 @@ -3729,6 +3947,9 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -3763,9 +3984,20 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -3846,6 +4078,9 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + avvio@9.3.1: + resolution: {integrity: sha512-QyTKW61/qlba+cUQBm/sT9X8lri4UdomqSDwCnrnj1IvLVDRRiXRkz4CgBRWB+/cmfC7SK5WQc8T1vSdGvaUcQ==} + axios@1.15.0: resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} @@ -3972,8 +4207,8 @@ packages: citty@0.2.1: resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} - clear-router@2.9.3: - resolution: {integrity: sha512-iMHcTxm+We7rllXVRWQBKWHbJkCFrelN8dKlVMyMt9tm+ckWWCTSffd7u1azqjwqFUCdtAJ6C8xEodVlf+r6MQ==} + clear-router@2.9.4: + resolution: {integrity: sha512-yIr7s8AvteeSc1MKl2UZDlQ9AHDxzcAvUWfS86v1W9SmVaCTVEgOpiZ9LzMLPcJ5DxMYd/ckYgeKnY9Zj1RAoA==} engines: {node: '>=20.0.0'} peerDependencies: '@h3ravel/support': ^2.2.0 @@ -4071,6 +4306,10 @@ packages: console-table-printer@2.16.1: resolution: {integrity: sha512-Sc9FRJ4O9xKGNrvulNdPfK5SyBcZ6lcaRnDE4AQ/uw6IDtjHhsqyzzqcnMikjyGaiOOF2tNOKoBhbVjRvFy9Lw==} + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -4090,6 +4329,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} @@ -4422,6 +4665,9 @@ packages: resolution: {integrity: sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==} engines: {node: '>=18.0.0'} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -4432,12 +4678,24 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-json-stringify@7.2.1: + resolution: {integrity: sha512-GU16K+1IH8XSJ/suUaX/ONlJ5glCFvmBdra2nGI2ZGpBBXzqM/r/lzO02gjHYcRad1KKj6WvC+/RROggpg6acg==} + fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} + + fast-uri@4.2.3: + resolution: {integrity: sha512-I1PXwP5eAhlNlcdpjt9CgB59op2ZvZfxw+BkqGFVIUOgNpCVTcTqp3HhTX7brxj5vsip+nSY4nTvff+eIA76hg==} + fast-xml-builder@1.1.4: resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} @@ -4445,6 +4703,15 @@ packages: resolution: {integrity: sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==} hasBin: true + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify-plugin@6.0.1: + resolution: {integrity: sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==} + + fastify@5.12.8: + resolution: {integrity: sha512-moP8exb2sCHLUbJkwp+I6C0padhy4D8lp0SdmzY7HhSdZuqm7O/u3o2aq7u8okaQ//33QDzj3G/u+FsSdB8Qzw==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -4492,6 +4759,10 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-my-way@9.10.0: + resolution: {integrity: sha512-eru+uCPQ2g+nLBkf84MHP/KyNNMar0BDy0j+i8Fb72GF9/YHBZ58q7FODUtfVV7XU9nfRUHXSmaM8NmZcIbZ4Q==} + engines: {node: '>=20'} + find-up-simple@1.0.1: resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} engines: {node: '>=18'} @@ -4849,6 +5120,10 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + is-base64@1.1.0: resolution: {integrity: sha512-Nlhg7Z2dVC4/PTvIFkgVVNvPHSO2eR/Yd0XzhGiXCXEvWnptXlXa/clQ8aePPiMuxEGcWfzWbGw2Fe3d+Y3v1g==} hasBin: true @@ -4960,9 +5235,15 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-ref-resolver@3.0.1: + resolution: {integrity: sha512-w+HwgljXpVwZ3IidoUR1qPZxSOQ+NRQWuVe6a5pe8XbHsNZE4UAwVPUvzZagn7yBKbJtt6UJnRFYbNTRPZI45Q==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -5021,6 +5302,9 @@ packages: libphonenumber-js@1.13.8: resolution: {integrity: sha512-80xal1m93rADejw2pMp2MSzFhHCPLEspjHxnH2UtqI+DgAmElsbmLMiqk9niwH9NWAfjsRtaJI+qBrOEmRx9nQ==} + light-my-request@6.8.0: + resolution: {integrity: sha512-5Q8PjoSEdtGIB8fikZsTem5vgRo4F2mR6vUKTDW/H1g6MtAt8e3duQbg/QxL5yzg6BZhor2KOUm3hhWMWLTZwg==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -5732,6 +6016,9 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process-warning@5.1.1: + resolution: {integrity: sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==} + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -5835,6 +6122,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5846,8 +6137,8 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resora@1.3.35: - resolution: {integrity: sha512-60IhnJp6Q3WxY6b2Vsnk+PWojl7N7iJi9+oA4hWaIVCfXFTXWwV0DP8HTLZt+yym1a5RcnBVjD6Jj/K943l1lA==} + resora@1.3.36: + resolution: {integrity: sha512-NTC61R8OVVPJGYPoOc4R3+P7jTmgHBgJ0vXYhfsZUyEY1RrFCZte2lQzFfDAZDd8hrPVg0A6gVDrqY9ZovzIdw==} engines: {node: '>=22.0.0'} hasBin: true peerDependencies: @@ -5860,6 +6151,10 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + ret@0.5.1: + resolution: {integrity: sha512-Z/nyvABwcRnRD2mMP7Kz6G1dFZZqX1mNErOnxF42OCQKE07b9k9hK9SQxHL3LFEZUdzQaLM4dId1E/E6Vm4WhA==} + engines: {node: '>=10'} + retry-request@7.0.2: resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==} engines: {node: '>=14'} @@ -5872,6 +6167,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rolldown-plugin-dts@0.27.14: resolution: {integrity: sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw==} engines: {node: ^22.18.0 || >=24.11.0} @@ -5930,6 +6228,10 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex2@5.1.2: + resolution: {integrity: sha512-tAtCGFPPP02VroDVzp4daGi6bC7jWG+41D47GKf18FeH/ubvKQHT9VmhAkvtjNOmJwqtApzWeUpEhkyEYULvmw==} + hasBin: true + safe-stable-stringify@2.5.0: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} @@ -6058,6 +6360,9 @@ packages: resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==} deprecated: Just use Node.js's crypto.timingSafeEqual() + secure-json-parse@4.1.1: + resolution: {integrity: sha512-lnd81MQEZKjhW/Gd2TQqdd4XP6QYNdDW/EP+I7nTrFHP9y2LmaGPIHk9xsnzcOiEr1c39c/e84xFgGviQKBTRw==} + selfsigned@2.4.1: resolution: {integrity: sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==} engines: {node: '>=10'} @@ -6075,6 +6380,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-cookie-parser@3.2.2: + resolution: {integrity: sha512-df017qMeYuXo4emL09nlyMpi2i2MtEIKS/6jtXH3vmjFgIRsOi852/5RmhJ4EkvN3ZF8jXadM/EkTPXgc8yPIw==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -6288,6 +6596,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toad-cache@3.7.4: + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} + engines: {node: '>=20'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -6774,10 +7086,10 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.2.4 - '@arkormx/plugin-clear-router@0.1.57(@types/node@25.6.2)(clear-router@2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2))(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))': + '@arkormx/plugin-clear-router@0.1.57(@types/node@25.6.2)(clear-router@2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2))(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))': dependencies: arkormx: 2.12.8(@types/node@25.6.2)(h3@2.0.1-rc.22)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) - clear-router: 2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) + clear-router: 2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) reflect-metadata: 0.2.2 transitivePeerDependencies: - '@types/node' @@ -7619,8 +7931,76 @@ snapshots: '@faker-js/faker@10.4.0': {} + '@fastify/accept-negotiator@2.1.2': {} + + '@fastify/ajv-compiler@4.0.7': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.2.3 + '@fastify/busboy@3.2.0': {} + '@fastify/cors@11.4.0': + dependencies: + fastify-plugin: 6.0.1 + toad-cache: 3.7.4 + + '@fastify/deepmerge@3.2.4': {} + + '@fastify/error@4.2.1': {} + + '@fastify/fast-json-stringify-compiler@5.1.1': + dependencies: + fast-json-stringify: 7.2.1 + + '@fastify/formbody@8.0.2': + dependencies: + fast-querystring: 1.1.2 + fastify-plugin: 5.1.0 + + '@fastify/forwarded@3.1.2': {} + + '@fastify/merge-json-schemas@0.2.2': + dependencies: + dequal: 2.0.3 + + '@fastify/multipart@9.4.0': + dependencies: + '@fastify/busboy': 3.2.0 + '@fastify/deepmerge': 3.2.4 + '@fastify/error': 4.2.1 + fastify-plugin: 5.1.0 + secure-json-parse: 4.1.1 + + '@fastify/proxy-addr@5.1.2': + dependencies: + '@fastify/forwarded': 3.1.2 + ipaddr.js: 2.5.0 + + '@fastify/rate-limit@10.3.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 5.1.0 + toad-cache: 3.7.4 + + '@fastify/send@4.2.1': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@8.3.0': + dependencies: + '@fastify/accept-negotiator': 2.1.2 + '@fastify/send': 4.2.1 + content-disposition: 0.5.4 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.6 + '@firebase/ai@1.4.1(@firebase/app-types@0.9.3)(@firebase/app@0.13.2)': dependencies: '@firebase/app': 0.13.2 @@ -8343,6 +8723,8 @@ snapshots: kanun: 1.2.0 libphonenumber-js: 1.13.8 + '@lukeed/ms@2.0.2': {} + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.28.6 @@ -8566,10 +8948,10 @@ snapshots: dependencies: quansync: 1.0.0 - '@resora/plugin-clear-router@1.0.72(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)))(clear-router@2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2))': + '@resora/plugin-clear-router@1.0.73(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)))(clear-router@2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2))': dependencies: - clear-router: 2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) - resora: 1.3.35(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) + clear-router: 2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2) + resora: 1.3.36(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))) transitivePeerDependencies: - '@h3ravel/musket' @@ -9701,6 +10083,8 @@ snapshots: dependencies: event-target-shim: 5.0.1 + abstract-logging@2.0.1: {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -9735,6 +10119,10 @@ snapshots: agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -9742,6 +10130,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.9 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-escapes@4.3.2: @@ -9817,6 +10212,11 @@ snapshots: atomic-sleep@1.0.0: {} + avvio@9.3.1: + dependencies: + '@fastify/error': 4.2.1 + fastq: 1.20.1 + axios@1.15.0: dependencies: follow-redirects: 1.16.0 @@ -9943,12 +10343,13 @@ snapshots: citty@0.2.1: {} - clear-router@2.9.3(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2): + clear-router@2.9.4(@h3ravel/support@2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22))(express@5.2.1)(fastify@5.12.8)(h3@2.0.1-rc.22)(hono@4.12.10)(reflect-metadata@0.2.2): dependencies: jiti: 2.7.0 optionalDependencies: '@h3ravel/support': 2.2.7(@types/node@25.6.2)(h3@2.0.1-rc.22) express: 5.2.1 + fastify: 5.12.8 h3: 2.0.1-rc.22 hono: 4.12.10 reflect-metadata: 0.2.2 @@ -10008,6 +10409,10 @@ snapshots: dependencies: simple-wcswidth: 1.1.2 + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -10018,6 +10423,8 @@ snapshots: cookie@0.7.2: {} + cookie@2.0.1: {} + cookiejar@2.1.4: {} cors@2.8.6: @@ -10417,6 +10824,8 @@ snapshots: farmhash-modern@1.1.0: {} + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -10429,10 +10838,27 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-json-stringify@7.2.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.2 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.2.3 + json-schema-ref-resolver: 3.0.1 + rfdc: 1.4.1 + fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + fast-safe-stringify@2.1.1: {} + fast-uri@3.1.9: {} + + fast-uri@4.2.3: {} + fast-xml-builder@1.1.4: dependencies: path-expression-matcher: 1.1.3 @@ -10442,6 +10868,28 @@ snapshots: fast-xml-builder: 1.1.4 strnum: 2.2.0 + fastify-plugin@5.1.0: {} + + fastify-plugin@6.0.1: {} + + fastify@5.12.8: + dependencies: + '@fastify/ajv-compiler': 4.0.7 + '@fastify/error': 4.2.1 + '@fastify/fast-json-stringify-compiler': 5.1.1 + '@fastify/proxy-addr': 5.1.2 + abstract-logging: 2.0.1 + avvio: 9.3.1 + fast-json-stringify: 7.2.1 + find-my-way: 9.10.0 + light-my-request: 6.8.0 + pino: 10.3.1 + process-warning: 5.1.1 + rfdc: 1.4.1 + secure-json-parse: 4.1.1 + semver: 7.8.5 + toad-cache: 3.7.4 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -10495,6 +10943,12 @@ snapshots: transitivePeerDependencies: - supports-color + find-my-way@9.10.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.2 + find-up-simple@1.0.1: {} find-up@4.1.0: @@ -10954,6 +11408,8 @@ snapshots: ipaddr.js@1.9.1: {} + ipaddr.js@2.5.0: {} + is-base64@1.1.0: {} is-extglob@2.1.1: {} @@ -11042,8 +11498,14 @@ snapshots: json-buffer@3.0.1: {} + json-schema-ref-resolver@3.0.1: + dependencies: + dequal: 2.0.3 + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -11115,6 +11577,13 @@ snapshots: libphonenumber-js@1.13.8: {} + light-my-request@6.8.0: + dependencies: + '@fastify/deepmerge': 3.2.4 + cookie: 2.0.1 + process-warning: 5.1.1 + set-cookie-parser: 3.2.2 + lightningcss-android-arm64@1.32.0: optional: true @@ -11929,6 +12398,8 @@ snapshots: process-warning@5.0.0: {} + process-warning@5.1.1: {} + property-information@7.1.0: {} proto3-json-serializer@2.0.2: @@ -12040,13 +12511,15 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} - resora@1.3.35(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))): + resora@1.3.36(@h3ravel/musket@2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0))): optionalDependencies: '@h3ravel/musket': 2.2.13(@types/node@25.6.2)(tsdown@0.22.14(tsx@4.21.0)(typescript@6.0.3)(unrun@0.3.0)) @@ -12055,6 +12528,8 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + ret@0.5.1: {} + retry-request@7.0.2: dependencies: '@types/request': 2.48.13 @@ -12068,6 +12543,8 @@ snapshots: reusify@1.1.0: {} + rfdc@1.4.1: {} + rolldown-plugin-dts@0.27.14(rolldown@1.2.2)(typescript@6.0.3): dependencies: dts-resolver: 3.0.0 @@ -12180,6 +12657,10 @@ snapshots: safe-buffer@5.2.1: {} + safe-regex2@5.1.2: + dependencies: + ret: 0.5.1 + safe-stable-stringify@2.5.0: {} safer-buffer@2.1.2: {} @@ -12284,6 +12765,8 @@ snapshots: scmp@2.1.0: {} + secure-json-parse@4.1.1: {} + selfsigned@2.4.1: dependencies: '@types/node-forge': 1.3.14 @@ -12316,6 +12799,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@3.2.2: {} + setprototypeof@1.2.0: {} shebang-command@2.0.0: @@ -12534,6 +13019,8 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.4: {} + toidentifier@1.0.1: {} tr46@0.0.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 195fb441..34507261 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,17 +16,18 @@ catalog: '@h3ravel/collect.js': ^5.4.0 '@faker-js/faker': ^10.4.0 '@arkormx/plugin-clear-router': ^0.1.57 - '@resora/plugin-clear-router': ^1.0.72 + '@resora/plugin-clear-router': ^1.0.73 '@kanun-hq/plugin-file': ^0.1.12 '@kanun-hq/plugin-phone': ^0.1.8 arkormx: ^2.12.8 - clear-router: ^2.9.3 + clear-router: ^2.9.4 dotenv: ^17.4.2 express: ^5.2.1 + fastify: ^5.8.5 h3: ^2.0.1-rc.22 kysely: ^0.29.2 kanun: ^1.2.0 parasito: ^0.2.6 pg: ^8.22.0 - resora: ^1.3.35 + resora: ^1.3.36 tsdown: ^0.22.14 diff --git a/scripts/prepare-release-version.mjs b/scripts/prepare-release-version.mjs index 2b0b8b8a..baf08514 100644 --- a/scripts/prepare-release-version.mjs +++ b/scripts/prepare-release-version.mjs @@ -12,6 +12,7 @@ const packageJsonPaths = [ 'create-arkstack/package.json', 'templates/express/package.json', 'templates/h3/package.json', + 'templates/fastify/package.json', ...(await packagePaths('packages')), ] diff --git a/templates/fastify/.env.example b/templates/fastify/.env.example new file mode 100644 index 00000000..46af2dee --- /dev/null +++ b/templates/fastify/.env.example @@ -0,0 +1,40 @@ +APP_NAME=Arkstack +APP_URL=http://localhost:3000 +APP_PORT=3000 +APP_HOST=localhost +FILESYSTEM_DISK=local + +CACHE_STORE=memory +QUEUE_CONNECTION=sync + +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD= + +APP_KEY= +JWT_EXPIRES_IN=1h + +NGROK_AUTHTOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxx + +DATABASE_URL="postgres://postgres:postgres@localhost:5432/arkstack?schema=public" +DB_CONNECTION=pgsql +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_DATABASE=arkstack +DB_USERNAME=postgres +DB_PASSWORD=postgres + +MAIL_HOST=localhost +MAIL_PORT=1025 +MAIL_SECURE=false +MAIL_USERNAME=no-reply@example.com +MAIL_PASSWORD=password +MAIL_FROM_ADDRESS=no-reply@example.com +MAIL_TEST_ADDRESS= + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION= +AWS_BUCKET= +AWS_URL= +AWS_ENDPOINT= \ No newline at end of file diff --git a/templates/fastify/.gitignore b/templates/fastify/.gitignore new file mode 100644 index 00000000..3d00fc29 --- /dev/null +++ b/templates/fastify/.gitignore @@ -0,0 +1,20 @@ +node_modules +dist +build +public/build +# Keep environment variables out of version control +.env + +/src/models/**/*.ts +/database/**/*.ts + +/public/storage +/storage/*.key +/storage/framework + +# ArkStack specific files and directories +.arkstack +.arkormx +.DS_Store +.idea +.vscode \ No newline at end of file diff --git a/templates/fastify/README.md b/templates/fastify/README.md new file mode 100644 index 00000000..12f075bc --- /dev/null +++ b/templates/fastify/README.md @@ -0,0 +1,28 @@ +# Arkstack Fastify + +Fastify app scaffolded with Toneflix's Arkstack. + +Full framework documentations are available from [https://arkstack.toneflix.net](https://arkstack.toneflix.net/). + +- Runtime interaction guide: [guide/fastify-runtime](https://arkstack.toneflix.net/guide/fastify-runtime) +- Getting started guide: [guide/getting-started](https://arkstack.toneflix.net/guide/getting-started) + +## Developing + +### Install dependencies + +```sh +npm install +``` + +### Run migrations + +```sh +npx ark migrate dev +``` + +### Start dev server + +```sh +npm run dev +``` diff --git a/templates/fastify/eslint.config.js b/templates/fastify/eslint.config.js new file mode 100644 index 00000000..b7f24597 --- /dev/null +++ b/templates/fastify/eslint.config.js @@ -0,0 +1,74 @@ +import { defineConfig } from 'eslint/config' +import eslintConfigPrettier from 'eslint-config-prettier' +import { globalIgnores } from 'eslint/config' +import js from '@eslint/js' +import markdown from '@eslint/markdown' +import tseslint from 'typescript-eslint' + +export default defineConfig( + { + languageOptions: { + globals: { + console: 'readonly', + process: 'readonly', + }, + parserOptions: { + tsconfigRootDir: process.cwd(), + }, + }, + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['docs/**/*.md', 'README.md'], + plugins: { + markdown, + }, + extends: ['markdown/recommended'], + rules: { + 'no-irregular-whitespace': 'off', + 'markdown/no-missing-label-refs': 'off', + }, + }, + [ + globalIgnores([ + 'docs/.vitepress/**', + 'bin/**', + 'dist/**', + 'build/**', + '.arkormx/**', + '.arkstack/**', + 'node_modules/**', + ]), + ], + { + rules: { + 'brace-style': ['error', '1tbs', { allowSingleLine: false }], + 'no-console': 'off', + 'no-thenable': 'off', + // 'no-ternary': 'error', + 'newline-before-return': 'error', + semi: ['error', 'never'], + quotes: ['error', 'single', { avoidEscape: true }], + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_|_', + vars: 'all', + args: 'after-used', + ignoreRestSiblings: false, + varsIgnorePattern: '^I[A-Z]|^_', + }, + ], + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/triple-slash-reference': [ + 'error', + { + path: 'always', + }, + ], + }, + }, + eslintConfigPrettier, +) diff --git a/templates/fastify/package.json b/templates/fastify/package.json new file mode 100644 index 00000000..f45be648 --- /dev/null +++ b/templates/fastify/package.json @@ -0,0 +1,64 @@ +{ + "name": "fastify-app", + "description": "Arkstack framework application running on the Fastify driver.", + "type": "module", + "version": "0.21.1", + "scripts": { + "predev": "pnpm -w run build:packages --log-level silent", + "prebuild": "pnpm -w run build:packages --log-level silent", + "precmd": "pnpm -w run build:packages --log-level silent", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "test": "vitest run", + "test:watch": "vitest", + "dev": "tsdown --log-level silent", + "build": "NODE_ENV=production tsdown", + "cmd": "NODE_OPTIONS=--enable-source-maps tsx ./node_modules/@arkstack/console/dist/index.js" + }, + "dependencies": { + "@arkstack/auth": "workspace:^", + "@arkstack/cache": "workspace:^", + "@arkstack/common": "workspace:^", + "@arkstack/console": "workspace:^", + "@arkstack/contract": "workspace:^", + "@arkstack/database": "workspace:^", + "@arkstack/driver-fastify": "workspace:^", + "@arkstack/filesystem": "workspace:^", + "@arkstack/foundry": "workspace:^", + "@arkstack/http": "workspace:^", + "@arkstack/jobs": "workspace:^", + "@arkstack/lang": "workspace:^", + "@arkstack/notifications": "workspace:^", + "@arkstack/queue": "workspace:^", + "@arkstack/scheduler": "workspace:^", + "@arkstack/view": "workspace:^", + "@h3ravel/musket": "catalog:", + "@h3ravel/support": "catalog:", + "@fastify/cors": "^11.1.0", + "@kanun-hq/plugin-file": "catalog:", + "arkormx": "catalog:", + "clear-router": "catalog:", + "dotenv": "catalog:", + "fastify": "catalog:", + "kanun": "catalog:", + "kysely": "catalog:", + "pg": "catalog:", + "resora": "catalog:" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@eslint/markdown": "^8.0.1", + "@faker-js/faker": "^10.4.0", + "@types/node": "^25.6.2", + "@types/pg": "^8.16.0", + "eslint": "^10.3.0", + "parasito": "catalog:", + "reflect-metadata": "^0.2.2", + "source-map-support": "^0.5.21", + "tsdown": "catalog:", + "typescript": "^6.0.0", + "typescript-eslint": "^8.59.2", + "unrun": "^0.3.0", + "vitest": "^4.1.5" + } +} diff --git a/templates/fastify/public/index.json b/templates/fastify/public/index.json new file mode 100644 index 00000000..9d163840 --- /dev/null +++ b/templates/fastify/public/index.json @@ -0,0 +1,21 @@ +{ + "html": { + "lang": "en", + "head": { + "title": "Weyham API", + "meta": [ + { + "charset": "utf-8" + }, + { + "name": "viewport", + "content": "width=device-width, initial-scale=1" + } + ] + }, + "body": { + "h1": "Hello World!", + "p": "See the world through our own eyes." + } + } +} diff --git a/templates/fastify/src/app/console/commands/.gitkeep b/templates/fastify/src/app/console/commands/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/templates/fastify/src/app/http/controllers/BaseController.ts b/templates/fastify/src/app/http/controllers/BaseController.ts new file mode 100644 index 00000000..b5527059 --- /dev/null +++ b/templates/fastify/src/app/http/controllers/BaseController.ts @@ -0,0 +1,23 @@ +import { RulesForData, Validator } from 'kanun' + +import { Controller } from 'clear-router' +import { HttpContext } from 'clear-router/types/fastify' + +export class BaseController extends Controller { + async validate, R extends RulesForData>(rules: R, camelizeKeys = false) { + const data = await Validator.make(this.body, rules).validate() + + if (camelizeKeys) { + return Object.keys(data).reduce( + (result, key) => { + result[str(key).camel().toString()] = data[key as never] + + return result + }, + {} as Record, + ) as { [K in keyof R]: any } + } + + return data as { [K in keyof R]: any } + } +} diff --git a/templates/fastify/src/app/http/controllers/UserController.ts b/templates/fastify/src/app/http/controllers/UserController.ts new file mode 100644 index 00000000..88aec5bf --- /dev/null +++ b/templates/fastify/src/app/http/controllers/UserController.ts @@ -0,0 +1,96 @@ +import { Resource, ResourceCollection } from 'resora' + +import { BaseController } from '@controllers/BaseController' +import { Bind } from 'clear-router/decorators' +import { User } from 'src/app/models/User' + +/** + * UserController + */ +export default class UserController extends BaseController { + /** + * Get all resources + * + * @param req + */ + async index() { + const users = await User.query().paginate(5) + + return new ResourceCollection(users) + .additional({ + status: 'success', + message: 'OK', + code: 200, + }) + .response() + .setStatusCode(200) + } + + /** + * Get a specific resource + * + * @param res + */ + @Bind() + async show(user: User) { + return new Resource(user) + .additional({ + status: 'success', + message: 'OK', + code: 200, + }) + .response() + .setStatusCode(200) + } + + /** + * Create a resource + * + * @param res + */ + async create() { + return new Resource({ data: {} }) + .additional({ + status: 'success', + message: 'New User created successfully', + code: 201, + }) + .response() + .setStatusCode(201) + } + + /** + * Update a specific resource + * + * @param res + */ + async update() { + const data = await this.validate({ + name: 'string|required', + age: 'numeric|required|min:30', + }) + + return new Resource({ data }) + .additional({ + status: 'success', + message: 'User updated successfully', + code: 202, + }) + .response() + .setStatusCode(202) + } + + /** + * Delete a specific resource + */ + async destroy() { + return new Resource({}) + .additional({ + status: 'success', + message: 'User deleted successfully', + code: 202, + }) + .response() + .setStatusCode(202) + } +} diff --git a/templates/fastify/src/app/http/middlewares/.gitkeep b/templates/fastify/src/app/http/middlewares/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/templates/fastify/src/app/http/resources/.gitkeep b/templates/fastify/src/app/http/resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/templates/fastify/src/app/http/resources/UserCollection.ts b/templates/fastify/src/app/http/resources/UserCollection.ts new file mode 100644 index 00000000..eedc2686 --- /dev/null +++ b/templates/fastify/src/app/http/resources/UserCollection.ts @@ -0,0 +1,16 @@ +import { ResourceCollection } from 'resora' +import { UserResource } from './UserResource' + +/** + * UserCollection + */ +export class UserCollection extends ResourceCollection { + collects = UserResource + /** + * Build the response object + * @returns this + */ + data() { + return this.toObject() + } +} diff --git a/templates/fastify/src/app/http/resources/UserResource.ts b/templates/fastify/src/app/http/resources/UserResource.ts new file mode 100644 index 00000000..e5247c06 --- /dev/null +++ b/templates/fastify/src/app/http/resources/UserResource.ts @@ -0,0 +1,14 @@ +import { Resource } from 'resora' + +/** + * UserResource + */ +export class UserResource extends Resource { + /** + * Build the response object + * @returns this + */ + data() { + return this.toObject() + } +} diff --git a/templates/fastify/src/app/models/PasswordReset.ts b/templates/fastify/src/app/models/PasswordReset.ts new file mode 100644 index 00000000..a83961a1 --- /dev/null +++ b/templates/fastify/src/app/models/PasswordReset.ts @@ -0,0 +1,31 @@ +import { Model } from 'arkormx' + +export class PasswordReset extends Model { + declare id: string + declare email: string | null + declare phone: string | null + declare token: string + declare createdAt: Date + declare updatedAt: Date + + static fromToken(token: string) { + if (token.length > 6) { + token = Buffer.from(token, 'base64url').toString().split('-')[0] + } + + return this.query().where({ token }).firstOrFail() + } + + /** + * get the password reset link + */ + link() { + const b64Token = Buffer.from( + str(this.token) + .append('-' + Math.random()) + .toString(), + ).toString('base64url') + + return `${config('app.frontend_url')}/auth/reset-password?token=${b64Token}` + } +} diff --git a/templates/fastify/src/app/models/PersonalAccessToken.ts b/templates/fastify/src/app/models/PersonalAccessToken.ts new file mode 100644 index 00000000..48f9e60e --- /dev/null +++ b/templates/fastify/src/app/models/PersonalAccessToken.ts @@ -0,0 +1,26 @@ +import { PersonalAccessToken as BasePersonalAccessToken } from '@arkstack/auth' +import { User } from './User' + +export class PersonalAccessToken extends BasePersonalAccessToken { + declare name: string + declare token: string + declare abilities: string[] + declare deviceInfo: Record + declare lastUsedAt: Date + declare expiresAt: Date + declare createdAt: Date + declare updatedAt: Date + + protected static columns = { + userId: 'user_id', + deviceInfo: 'device_info', + lastUsedAt: 'last_used_at', + expiresAt: 'expires_at', + createdAt: 'created_at', + updatedAt: 'updated_at', + } + + user() { + return this.belongsTo(User, 'userId') + } +} diff --git a/templates/fastify/src/app/models/User.ts b/templates/fastify/src/app/models/User.ts new file mode 100644 index 00000000..bf3b144c --- /dev/null +++ b/templates/fastify/src/app/models/User.ts @@ -0,0 +1,34 @@ +import { User as BaseUser } from '@arkstack/auth' +import { PersonalAccessToken } from './PersonalAccessToken' +import { UserNotification } from './UserNotification' +import { UserTwoFactor } from './UserTwoFactor' +import { UserFactory } from 'src/database/factories/UserFactory' + +export class User extends BaseUser { + declare email: string + declare password: string + declare name: string + declare createdAt: Date + declare updatedAt: Date + + protected static override factoryClass = UserFactory + + protected static override columns = { + createdAt: 'created_at', + updatedAt: 'updated_at', + } + + protected override hidden = ['password'] + + personalAccessTokens() { + return this.hasMany(PersonalAccessToken, 'userId') + } + + twoFactor() { + return this.hasOne(UserTwoFactor, 'userId') + } + + notifications() { + return this.hasMany(UserNotification, 'userId') + } +} diff --git a/templates/fastify/src/app/models/UserNotification.ts b/templates/fastify/src/app/models/UserNotification.ts new file mode 100644 index 00000000..a9481b41 --- /dev/null +++ b/templates/fastify/src/app/models/UserNotification.ts @@ -0,0 +1,17 @@ +import { UserNotification as BaseUserNotification } from '@arkstack/notifications' +import { User } from './User' + +export class UserNotification extends BaseUserNotification { + protected static columns = { + userId: 'user_id', + actionText: 'action_text', + actionLink: 'action_link', + readAt: 'read_at', + createdAt: 'created_at', + updatedAt: 'updated_at', + } + + user() { + return this.belongsTo(User, 'userId', 'id') + } +} diff --git a/templates/fastify/src/app/models/UserTwoFactor.ts b/templates/fastify/src/app/models/UserTwoFactor.ts new file mode 100644 index 00000000..8186c98a --- /dev/null +++ b/templates/fastify/src/app/models/UserTwoFactor.ts @@ -0,0 +1,20 @@ +import { UserTwoFactor as BaseUserTwoFactor } from '@arkstack/auth' +import { User } from './User' + +export class UserTwoFactor extends BaseUserTwoFactor { + protected static columns = { + userId: 'user_id', + secretCiphertext: 'secret_ciphertext', + smsCodeHash: 'sms_code_hash', + smsCodeExpiresAt: 'sms_code_expires_at', + smsCodePurpose: 'sms_code_purpose', + enabledAt: 'enabled_at', + recoveryCodeHashes: 'recovery_code_hashes', + createdAt: 'created_at', + updatedAt: 'updated_at', + } + + user() { + return this.belongsTo(User, 'userId', 'id') + } +} diff --git a/templates/fastify/src/config/app.ts b/templates/fastify/src/config/app.ts new file mode 100644 index 00000000..f8cf46f0 --- /dev/null +++ b/templates/fastify/src/config/app.ts @@ -0,0 +1,17 @@ +import { AppConfig } from '@arkstack/common' + +export default (): AppConfig => { + return { + env: env('APP_ENV', 'local'), + key: env('APP_KEY', 'change-me'), + url: env('APP_URL', 'http://localhost'), + host: env('APP_HOST', '0.0.0.0'), + name: env('APP_NAME', 'Arkstack'), + frontend_url: env('FRONTEND_URL', 'http://localhost:3000'), + debug: env('APP_DEBUG', false), + timezone: env('APP_TIMEZONE', 'UTC'), + locale: env('APP_LOCALE', 'en'), + fallback_locale: env('APP_FALLBACK_LOCALE', 'en'), + faker_locale: env('APP_FAKER_LOCALE', 'en_US'), + } +} diff --git a/templates/fastify/src/config/cache.ts b/templates/fastify/src/config/cache.ts new file mode 100644 index 00000000..0787ab2f --- /dev/null +++ b/templates/fastify/src/config/cache.ts @@ -0,0 +1,50 @@ +import { Arkstack } from '@arkstack/contract' +import type { CacheConfig } from '@arkstack/cache' +import path from 'node:path' + +export default (): CacheConfig => { + return { + /** + * Default Cache Store + * + * The store used by the framework whenever a store is not explicitly + * requested. Supported drivers: memory, file, redis, database. + */ + default: env('CACHE_STORE', 'memory'), + + /** + * Cache Key Prefix + * + * Prepended to every cache key to avoid collisions between applications + * that share the same backing store. + */ + prefix: env('CACHE_PREFIX', 'arkstack_cache_'), + + /** + * Cache Stores + * + * Configure as many stores as you like. Each is referenced by name via + * `Cache.store('')`. + */ + stores: { + memory: { + driver: 'memory', + }, + file: { + driver: 'file', + path: path.join(Arkstack.rootDir(), './storage/framework/cache'), + }, + redis: { + driver: 'redis', + host: env('REDIS_HOST', '127.0.0.1'), + port: env('REDIS_PORT', 6379), + password: env('REDIS_PASSWORD'), + db: env('REDIS_CACHE_DB', 1), + }, + database: { + driver: 'database', + table: env('CACHE_TABLE', 'cache'), + }, + }, + } +} diff --git a/templates/fastify/src/config/cors.ts b/templates/fastify/src/config/cors.ts new file mode 100644 index 00000000..17ed2515 --- /dev/null +++ b/templates/fastify/src/config/cors.ts @@ -0,0 +1,15 @@ +import appConfig from './app' + +export default () => { + const app = appConfig() + + return { + allowed_origins: [ + app.url, + app.frontend_url, + ...env('CORS_ALLOWED_ORIGINS', '') + .split(',') + .map((origin: string) => origin.trim()), + ].filter(Boolean), + } +} diff --git a/templates/fastify/src/config/database.ts b/templates/fastify/src/config/database.ts new file mode 100644 index 00000000..46c7f535 --- /dev/null +++ b/templates/fastify/src/config/database.ts @@ -0,0 +1,31 @@ +import { DatabaseConfig } from '@arkstack/database' + +export default (): DatabaseConfig => { + return { + /** + * Default Database Connection + * + * The connection used whenever one is not explicitly requested. + * Supported drivers: postgres. + */ + default: env('DB_CONNECTION', 'pgsql'), + + /** + * Database Connections + * + * Credentials are provided as discrete fields. When `url` (or + * `DATABASE_URL`) is set it takes precedence over the discrete fields. + */ + connections: { + pgsql: { + driver: 'postgres', + url: env('DATABASE_URL'), + host: env('DB_HOST', '127.0.0.1'), + port: env('DB_PORT', 5432), + user: env('DB_USERNAME', 'postgres'), + database: env('DB_DATABASE', 'arkstack'), + password: env('DB_PASSWORD', 'postgres'), + }, + }, + } +} diff --git a/templates/fastify/src/config/filesystem.ts b/templates/fastify/src/config/filesystem.ts new file mode 100644 index 00000000..ac637d79 --- /dev/null +++ b/templates/fastify/src/config/filesystem.ts @@ -0,0 +1,86 @@ +import { Arkstack } from '@arkstack/contract' +import { FilesystemConfig } from '@arkstack/filesystem' +import path from 'node:path' + +export default (): FilesystemConfig => { + return { + /** + * Default filesystem disk to be used by the framework. + */ + default: env('FILESYSTEM_DISK', 'local'), + + /** + * Available disks and their configurations. + * + * You can configure as many disks as you want, and even have multiple disks + * using the same driver. + * + * Supported drivers: local, ftp, s3 + */ + disks: { + local: { + driver: 'local', + root: path.join(Arkstack.rootDir(), './storage/app'), + visibility: 'public', + }, + public: { + driver: 'local', + root: path.join(Arkstack.rootDir(), './storage/app/public'), + visibility: 'public', + }, + ftp: { + driver: 'ftp', + host: env('FTP_HOST'), + username: env('FTP_USERNAME'), + password: env('FTP_PASSWORD'), + port: env('FTP_PORT', 21), + verbose: env('FTP_VERBOSE', false), + privateKey: env('FTP_PRIVATE_KEY'), + }, + s3: { + driver: 's3', + key: env('AWS_ACCESS_KEY_ID'), + secret: env('AWS_SECRET_ACCESS_KEY'), + region: env('AWS_DEFAULT_REGION'), + bucket: env('AWS_BUCKET'), + url: env('AWS_URL'), + endpoint: env('AWS_ENDPOINT'), + visibility: 'public', + }, + gcs: { + driver: 'gcs', + projectId: env('GOOGLE_CLOUD_PROJECT'), + keyFilename: env('GOOGLE_APPLICATION_CREDENTIALS'), + bucket: env('GOOGLE_CLOUD_STORAGE_BUCKET'), + visibility: 'private', + usingUniformAcl: true, + }, + }, + + /** + * Optional symbolic links to create when the `storage:link` command is executed. + * The key of the object represents the link location, while the value represents the target location. + */ + links: { + [path.join(Arkstack.rootDir(), './public/storage')]: path.join(Arkstack.rootDir(), './storage/app/public'), + }, + + /** + * Optional file name generator function. + * If provided, this function will be used to generate unique file names + * for uploaded files when using the `saveFile` method of the Storage class. + * + * @param originalName The original name of the file + * @returns A unique file name + * + * fileNameGenerator: (originalName: string) => { + * const timestamp = Date.now() + * const randomString = Math.random().toString(36).substring(2, 8) + * const extension = path.extname(originalName) + * const baseName = path.basename(originalName, extension) + * + * return `${baseName}-${timestamp}-${randomString}${extension}` + * }, + */ + } +} diff --git a/templates/fastify/src/config/middleware.ts b/templates/fastify/src/config/middleware.ts new file mode 100644 index 00000000..0d667b74 --- /dev/null +++ b/templates/fastify/src/config/middleware.ts @@ -0,0 +1,20 @@ +import { formdata, requestLogger, resora } from '@arkstack/driver-fastify/middlewares' + +import { MiddlewareConfig } from '@arkstack/driver-fastify/types' +import { localeMiddleware } from '@arkstack/lang' +import { useExpressUploadContext } from '@kanun-hq/plugin-file' + +export default (): MiddlewareConfig => { + return { + global: [], + before: [ + formdata(), + resora(), + localeMiddleware() as never, + async (req) => { + useExpressUploadContext({ files: req.uploads }) + }, + ], + after: [requestLogger()], + } +} diff --git a/templates/fastify/src/config/notifications.ts b/templates/fastify/src/config/notifications.ts new file mode 100644 index 00000000..6d911500 --- /dev/null +++ b/templates/fastify/src/config/notifications.ts @@ -0,0 +1,163 @@ +import { Arkstack } from '@arkstack/contract' +import { NotificationConfig } from '@arkstack/notifications' +import { join } from 'node:path' + +export default (): NotificationConfig => { + return { + /** + * Default Notification Driver + * + * The default notifications driver that is utilized for outgoing messages. + */ + default_driver: env('NOTIFICATION_DRIVER', 'mail'), + + /** + * Notification Drivers + * + * Notifications will be sent using one of the configured drivers. + */ + drivers: { + mail: { + transport: env('MAIL_TRANSPORT', 'smtp') as 'smtp' | 'file', + from: { + name: env('MAIL_FROM_NAME', 'Arcstack'), + address: env('MAIL_FROM_ADDRESS', 'no-reply@example.com'), + }, + test_address: env('MAIL_TEST_ADDRESS'), + }, + sms: { + transport: 'africastalking', + from: env('SMS_FROM', env('AFRICASTALKING_SENDER_ID', env('TWILIO_FROM'))), + }, + db: { + table: 'user_notifications', + }, + realtime: { + /** + * pusher | firebase | firebase-database | polling | memory + */ + transport: env('REALTIME_TRANSPORT', 'pusher'), + /** + * Also push user notifications to their devices: 'firebase' or false. + */ + push: env('REALTIME_PUSH', false), + channel_prefix: env('REALTIME_CHANNEL_PREFIX', 'user.'), + event: env('REALTIME_EVENT', 'notification'), + store: env('REALTIME_STORE', false), + }, + }, + transports: { + /** + * SMTP Notifications Transport + * + * Outgoing notifications will be sent as mail using SMTP. + */ + smtp: { + host: env('MAIL_HOST', 'localhost'), + port: env('MAIL_PORT', 1025), + secure: env('MAIL_SECURE', false), + auth: { + user: env('MAIL_USERNAME', 'user@example.com'), + pass: env('MAIL_PASSWORD', 'password'), + }, + }, + + /** + * File Notifications Transport + * + * Outgoing notifications will be sent as mail but stored locally in a file + * for testing purposes. + */ + file: { + /** + * Mails Directory + * + * Outgoing mails will be intercepted and stored in this directory. + */ + directory: env('MAIL_FILE_PATH', join(Arkstack.rootDir(), './storage/framework/mails')), + }, + + /** + * Africa's Talking Notifications Transport + * + * Outgoing notifications will be sent as SMS via Africa's Talking. + */ + africastalking: { + username: env('AFRICASTALKING_USERNAME', 'sandbox'), + apiKey: env('AFRICASTALKING_API_KEY', 'sandbox'), + senderId: env('AFRICASTALKING_SENDER_ID', env('SMS_FROM', 'Arkstack')), + }, + + /** + * Twilio Notifications Transport + * + * Outgoing notifications will be sent as SMS via Twilio. + */ + twilio: { + accountSid: env('TWILIO_ACCOUNT_SID'), + authToken: env('TWILIO_AUTH_TOKEN'), + from: env('TWILIO_FROM', env('SMS_FROM')), + }, + + /** + * Pusher Realtime Transport + * + * Realtime notifications will be broadcast over Pusher Channels. + */ + pusher: { + app_id: env('PUSHER_APP_ID', ''), + key: env('PUSHER_KEY', ''), + secret: env('PUSHER_SECRET', ''), + cluster: env('PUSHER_CLUSTER', 'mt1'), + use_tls: env('PUSHER_USE_TLS', true), + }, + + /** + * Firebase Realtime Transport + * + * Realtime notifications will be broadcast over Firebase Cloud Messaging topics. + */ + firebase: { + project_id: env('FIREBASE_PROJECT_ID', ''), + client_email: env('FIREBASE_CLIENT_EMAIL', ''), + private_key: env('FIREBASE_PRIVATE_KEY', ''), + app_name: env('APP_NAME', 'Arkstack'), + admin_sdk_path: env('FIREBASE_ADMINSDK', 'firebase-adminsdk.json'), + web: { + api_key: env('FIREBASE_API_KEY', ''), + app_id: env('FIREBASE_APP_ID', ''), + messaging_sender_id: env('FIREBASE_MESSAGING_SENDER_ID', ''), + vapid_key: env('FIREBASE_VAPID_KEY', ''), + }, + }, + + /** + * Firebase Realtime Database Transport + * + * Realtime notifications will be broadcast over Realtime Database channels. + */ + 'firebase-database': { + project_id: env('FIREBASE_PROJECT_ID', ''), + client_email: env('FIREBASE_CLIENT_EMAIL', ''), + private_key: env('FIREBASE_PRIVATE_KEY', ''), + app_name: env('APP_NAME', 'Arkstack'), + admin_sdk_path: env('FIREBASE_ADMINSDK', 'firebase-adminsdk.json'), + database_url: env('FIREBASE_DATABASE_URL', ''), + web: { + api_key: env('FIREBASE_API_KEY', ''), + auth_domain: env('FIREBASE_AUTH_DOMAIN', ''), + app_id: env('FIREBASE_APP_ID', ''), + }, + }, + + /** + * Polling Realtime Transport + * + * Nothing is pushed; clients poll on this interval (ms) instead. + */ + polling: { + interval: env('REALTIME_POLL_INTERVAL', 5000), + }, + }, + } +} diff --git a/templates/fastify/src/config/plugins.ts b/templates/fastify/src/config/plugins.ts new file mode 100644 index 00000000..49da80fd --- /dev/null +++ b/templates/fastify/src/config/plugins.ts @@ -0,0 +1,20 @@ +import type { FastifyPluginEntry } from '@arkstack/driver-fastify/types' +import corsConfig from './cors' +import fastifyCors from '@fastify/cors' + +/** + * Fastify plugins registered when the app is created. + */ +export default (): FastifyPluginEntry[] => { + const cConf = corsConfig() + + return [ + [ + fastifyCors, + { + origin: cConf.allowed_origins.length > 0 ? cConf.allowed_origins : true, + credentials: true, + }, + ], + ] +} diff --git a/templates/fastify/src/config/queue.ts b/templates/fastify/src/config/queue.ts new file mode 100644 index 00000000..68cf03fe --- /dev/null +++ b/templates/fastify/src/config/queue.ts @@ -0,0 +1,43 @@ +import type { QueueConfig } from '@arkstack/queue' + +export default (): QueueConfig => { + return { + /** + * Default Queue Connection + * + * The connection used when a job is dispatched without specifying one. + * The `sync` connection runs jobs immediately (no worker required) and is + * a sensible default for local development and tests. + * + * Supported drivers: sync, database, redis. + */ + default: env('QUEUE_CONNECTION', 'sync'), + + /** + * Queue Connections + * + * Configure a connection per backend. Run a worker with `ark queue:work` + * to process jobs on the database and redis connections. + */ + connections: { + sync: { + driver: 'sync', + }, + database: { + driver: 'database', + table: env('QUEUE_TABLE', 'jobs'), + queue: env('QUEUE_NAME', 'default'), + retryAfter: env('QUEUE_RETRY_AFTER', 90), + }, + redis: { + driver: 'redis', + host: env('REDIS_HOST', '127.0.0.1'), + port: env('REDIS_PORT', 6379), + password: env('REDIS_PASSWORD'), + db: env('REDIS_QUEUE_DB', 0), + queue: env('QUEUE_NAME', 'default'), + retryAfter: env('QUEUE_RETRY_AFTER', 90), + }, + }, + } +} diff --git a/templates/fastify/src/config/resources.ts b/templates/fastify/src/config/resources.ts new file mode 100644 index 00000000..d6045fb0 --- /dev/null +++ b/templates/fastify/src/config/resources.ts @@ -0,0 +1,52 @@ +import type { ResoraConfig } from 'resora' + +/** + * Resora configuration, applied per request by the `resora()` middleware and at + * generation time by `make:resource`. + * + * The object is cast to `ResoraConfig` because it also carries arkstack + * passthrough fields (`localStubsDir` and the controller/model stub names) that + * resora accepts at runtime but doesn't surface in its narrower config type. + */ +export default (): ResoraConfig => { + return { + preferredCase: 'camel', + responseStructure: { + wrap: true, + rootKey: 'data', + }, + paginatedExtras: ['meta', 'links'], + baseUrl: env('APP_URL', 'http://localhost'), + pageName: 'page', + paginatedLinks: { + first: 'first', + last: 'last', + prev: 'prev', + next: 'next', + }, + paginatedMeta: { + to: 'to', + from: 'from', + links: 'links', + path: 'path', + total: 'total', + per_page: 'per_page', + last_page: 'last_page', + current_page: 'current_page', + }, + cursorMeta: { + previous: 'previous', + next: 'next', + }, + resourcesDir: 'src/app/http/resources', + localStubsDir: 'node_modules/@arkstack/driver-fastify/stubs', + stubs: { + resource: 'resource.stub', + collection: 'resource.collection.stub', + controller: 'controller.stub', + api: 'controller.api.stub', + model: 'controller.model.stub', + apiResource: 'controller.api.resource.stub', + }, + } as ResoraConfig +} diff --git a/templates/fastify/src/config/session.ts b/templates/fastify/src/config/session.ts new file mode 100644 index 00000000..1f5fa62d --- /dev/null +++ b/templates/fastify/src/config/session.ts @@ -0,0 +1,83 @@ +import { Arkstack } from '@arkstack/contract' +import { SessionConfig } from '@arkstack/http' +import { Str } from '@h3ravel/support' +import { join } from 'node:path' + +export default (): SessionConfig => { + return { + secret: env('SESSION_SECRET', env('APP_KEY', 'change-me')), + + /** + * Session Driver + * + * The default session driver that is utilized for incoming requests. + * + * Supported: "file", "cookie", "database", + * (WIP: "apc", "memcached", "redis", "dynamodb", "array") + */ + driver: env('SESSION_DRIVER', 'file'), + + /** + * Cookie Name + * + * The name of the session cookie that is created by Arkstack. + */ + cookie: env('SESSION_COOKIE', Str.slug(env('APP_NAME', 'arkstack'), '_') + '_session'), + + /** + * Session Time To Live + * + * The number of minutes that the session is allowed to remain idle before + * it expires. + */ + ttl: env('SESSION_LIFETIME', 60 * 60 * 24 * 7), + + /** + * HTTP Only Access + * + * Prevent JavaScript from accessing the value of the cookie making it + * only accessible through the HTTP protocol. + */ + http_only: true, + + /** + * HTTPS Only Access + * + * This option ensures that cookies will only be sent back to the server + * if the browser has a HTTPS connection if set to "true". + */ + secure: env('NODE_ENV', 'development') === 'production', + + /** + * Same-Site Cookies + * + * Determines how cookies behave when cross-site requests take + * place, and can be used to mitigate CSRF attacks. + * See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + * + * Supported: "Lax", "Strict", "None", undefined + */ + same_site: 'Lax', + + /** + * Session Cookie Path + * + * Determines the path for which the cookie will be available. + */ + path: '/', + /** + * Datbase Table + * + * For the "database" session driver, this will determine the table to + * be used to store sessions. + */ + table: env('SESSION_TABLE', 'sessions'), + + /** + * Session Directory + * + * For the "file" session driver, the session files are placed in this directory. + */ + directory: env('SESSION_FILE_PATH', join(Arkstack.rootDir(), './storage/framework/sessions')), + } +} diff --git a/templates/fastify/src/core/app.ts b/templates/fastify/src/core/app.ts new file mode 100644 index 00000000..939e4fef --- /dev/null +++ b/templates/fastify/src/core/app.ts @@ -0,0 +1,75 @@ +import { Arkstack, ArkstackRouteListOptions, ArkstackRouterContract } from '@arkstack/contract' +import { FastifyDriver, Router } from '@arkstack/driver-fastify' + +import type { FastifyInstance } from 'fastify' +import type { FastifyMiddleware } from '@arkstack/driver-fastify/types' +import path from 'path' +import plugins from '../config/plugins' + +export default class Application extends Arkstack { + private prepared = false + + /** + * Creates an instance of the Application class, initializing + * the Fastify driver and creating a Fastify instance. + * + * @param app + */ + constructor(app?: FastifyInstance) { + super() + this.driver = new FastifyDriver({ + bindRouter: async (runtime) => { + await Router.bind(runtime) + }, + plugins: plugins(), + }) + + this.app = app ?? this.driver.createApp() + + Application.app = this.app + globalThis.app = () => this.app as never + } + + /** + * Gets the ArkstackRouterContract implementation for the Fastify framework. + * + * @returns + */ + getRouter(): ArkstackRouterContract { + return { + bind: (app: FastifyInstance) => Router.bind(app), + list: (options: ArkstackRouteListOptions = {}) => Router.list(options), + } + } + + /** + * Boots the application by mounting public assets, applying middleware, + * binding the router, and starting the server. Fastify can only be set up + * once, so later calls only start the server. + * + * @param port The numeric port to run the server on + * @param defer Set to true to skip server startup + */ + public async boot(port: number, defer = false) { + if (!this.prepared) { + this.prepared = true + + // Load public assets + await this.driver.mountPublicAssets(this.app, path.join(Arkstack.rootDir(), 'public')) + + // Apply all middleware + await this.driver.applyMiddleware(this.app, config('middleware') as never) + + // Bind the router + await this.driver.bindRouter(this.app) + + // Error Handler + await this.driver.registerErrorHandler?.(this.app) + } + + // Start the server + if (defer !== true) { + await this.driver.start(this.app, port) + } + } +} diff --git a/templates/fastify/src/core/bootstrap.ts b/templates/fastify/src/core/bootstrap.ts new file mode 100644 index 00000000..602b99bf --- /dev/null +++ b/templates/fastify/src/core/bootstrap.ts @@ -0,0 +1,17 @@ +import '@arkstack/database/setup' +import '@arkstack/http/setup' +import '@arkstack/auth/setup' +import '@arkstack/queue/setup' +import '@arkstack/cache/setup' +import '@arkstack/lang/setup' + +import Application from '../core/app' +import { Validator } from 'kanun' +import { View } from '@arkstack/view' +import { fileValidatorPlugin } from '@kanun-hq/plugin-file' + +View.boot() +Validator.use(fileValidatorPlugin) + +export const app = new Application() +export const fastifyApp = app.getAppInstance() diff --git a/templates/fastify/src/database/factories/UserFactory.ts b/templates/fastify/src/database/factories/UserFactory.ts new file mode 100644 index 00000000..7076c8b8 --- /dev/null +++ b/templates/fastify/src/database/factories/UserFactory.ts @@ -0,0 +1,16 @@ +import { Hash } from '@arkstack/common' +import { ModelFactory } from '@arkstack/database' +import { User } from '@app/models/User' +import { faker } from '@faker-js/faker' + +export class UserFactory extends ModelFactory { + protected model = User + + protected definition(_sequence: number) { + return { + name: faker.person.fullName(), + email: faker.internet.email(), + password: Hash.make('password'), + } + } +} diff --git a/templates/fastify/src/database/migrations/20260428164120_create_users_table.ts b/templates/fastify/src/database/migrations/20260428164120_create_users_table.ts new file mode 100644 index 00000000..cb489074 --- /dev/null +++ b/templates/fastify/src/database/migrations/20260428164120_create_users_table.ts @@ -0,0 +1,17 @@ +import { Migration, SchemaBuilder } from "arkormx"; + +export default class CreateUsersTableMigration extends Migration { + public async up(schema: SchemaBuilder): Promise { + schema.createTable("users", (table) => { + table.id("id", "uuid").primary(); + table.string("name"); + table.string("email").unique().index(); + table.string("password"); + table.timestamps(); + }); + } + + public async down(schema: SchemaBuilder): Promise { + schema.dropTable("users"); + } +} diff --git a/templates/fastify/src/database/migrations/20260428164833_createpasswordresetstable.ts b/templates/fastify/src/database/migrations/20260428164833_createpasswordresetstable.ts new file mode 100644 index 00000000..f6dc4083 --- /dev/null +++ b/templates/fastify/src/database/migrations/20260428164833_createpasswordresetstable.ts @@ -0,0 +1,17 @@ +import { Migration, SchemaBuilder } from "arkormx"; + +export default class CreatePasswordResetsTableMigration extends Migration { + public async up(schema: SchemaBuilder): Promise { + schema.createTable("password_resets", (table) => { + table.id("id", "uuid").primary(); + table.string("email").nullable().index(); + table.string("phone").nullable().index(); + table.string("token"); + table.timestamps(); + }); + } + + public async down(schema: SchemaBuilder): Promise { + schema.dropTable("password_resets"); + } +} diff --git a/templates/fastify/src/database/migrations/20260428165239_create_person_access_tokens_table.ts b/templates/fastify/src/database/migrations/20260428165239_create_person_access_tokens_table.ts new file mode 100644 index 00000000..45e1249b --- /dev/null +++ b/templates/fastify/src/database/migrations/20260428165239_create_person_access_tokens_table.ts @@ -0,0 +1,29 @@ +import { Migration, SchemaBuilder } from "arkormx"; + +export default class CreatePersonAccessTokensTableMigration extends Migration { + public async up(schema: SchemaBuilder): Promise { + schema.createTable("personal_access_tokens", (table) => { + table.id("id", "uuid").primary(); + table + .uuid("userId") + .map("user_id") + .foreign() + .references("users", "id") + .onDelete("cascade") + .as("user") + .inverseAlias("personalAccessTokens"); + table.string("name"); + table.text("token").unique(); + table.json("abilities").nullable(); + table.json("deviceInfo").nullable().map("device_info"); + table.date("lastUsedAt").nullable().map("last_used_at"); + table.date("expiresAt").nullable().map("expires_at"); + table.timestamps(); + table.index(["userId"]); + }); + } + + public async down(schema: SchemaBuilder): Promise { + schema.dropTable("personal_access_tokens"); + } +} diff --git a/templates/fastify/src/database/migrations/20260505110000_create_user_two_factors_table.ts b/templates/fastify/src/database/migrations/20260505110000_create_user_two_factors_table.ts new file mode 100644 index 00000000..1647dda3 --- /dev/null +++ b/templates/fastify/src/database/migrations/20260505110000_create_user_two_factors_table.ts @@ -0,0 +1,33 @@ +import { Migration, SchemaBuilder } from "arkormx"; + +export default class CreateUserTwoFactorsTableMigration extends Migration { + public async up(schema: SchemaBuilder): Promise { + schema.createTable("user_two_factors", (table) => { + table.id("id", "uuid").primary(); + table + .uuid("userId") + .map("user_id") + .foreign() + .references("users", "id") + .onDelete("cascade") + .as("user") + .inverseAlias("twoFactor"); + table + .enum("method", ["authenticator", "sms"]) + .nullable() + .enumName("TwoFactorMethod"); + table.string("secretCiphertext").nullable().map("secret_ciphertext"); + table.string("smsCodeHash").nullable().map("sms_code_hash"); + table.date("smsCodeExpiresAt").nullable().map("sms_code_expires_at"); + table.string("smsCodePurpose").nullable().map("sms_code_purpose"); + table.date("enabledAt").nullable().map("enabled_at"); + table.json("recoveryCodeHashes").nullable().map("recovery_code_hashes"); + table.timestamps(); + table.index(["userId"]); + }); + } + + public async down(schema: SchemaBuilder): Promise { + schema.dropTable("user_two_factors"); + } +} diff --git a/templates/fastify/src/database/migrations/20260505111000_create_user_notifications_table.ts b/templates/fastify/src/database/migrations/20260505111000_create_user_notifications_table.ts new file mode 100644 index 00000000..8234a972 --- /dev/null +++ b/templates/fastify/src/database/migrations/20260505111000_create_user_notifications_table.ts @@ -0,0 +1,30 @@ +import { Migration, SchemaBuilder } from "arkormx"; + +export default class CreateUserNotificationsTableMigration extends Migration { + public async up(schema: SchemaBuilder): Promise { + schema.createTable("user_notifications", (table) => { + table.id("id", "uuid").primary(); + table + .uuid("userId") + .map("user_id") + .foreign() + .references("users", "id") + .onDelete("cascade") + .as("user") + .inverseAlias("notifications"); + table.string("type").nullable(); + table.string("title"); + table.text("description"); + table.string("actionText").nullable().map("action_text"); + table.string("actionLink").nullable().map("action_link"); + table.json("meta").nullable(); + table.date("readAt").nullable().map("read_at"); + table.timestamps(); + table.index(["userId"]); + }); + } + + public async down(schema: SchemaBuilder): Promise { + schema.dropTable("user_notifications"); + } +} diff --git a/templates/fastify/src/database/migrations/20260524000000_create_sessions_table.ts b/templates/fastify/src/database/migrations/20260524000000_create_sessions_table.ts new file mode 100644 index 00000000..e3f71385 --- /dev/null +++ b/templates/fastify/src/database/migrations/20260524000000_create_sessions_table.ts @@ -0,0 +1,16 @@ +import { Migration, SchemaBuilder } from "arkormx"; + +export default class CreateSessionsTableMigration extends Migration { + public async up(schema: SchemaBuilder): Promise { + schema.createTable("sessions", (table) => { + table.string("id").primary(); + table.text("payload"); + table.date("expiresAt").nullable().map("expires_at"); + table.timestamps(); + }); + } + + public async down(schema: SchemaBuilder): Promise { + schema.dropTable("sessions"); + } +} diff --git a/templates/fastify/src/database/seeders/DatabaseSeeder.ts b/templates/fastify/src/database/seeders/DatabaseSeeder.ts new file mode 100644 index 00000000..23079b78 --- /dev/null +++ b/templates/fastify/src/database/seeders/DatabaseSeeder.ts @@ -0,0 +1,8 @@ +import { Seeder } from '@arkstack/database' +import { UserSeeder } from './UserSeeder' + +export class DatabaseSeeder extends Seeder { + public async run(): Promise { + this.call([UserSeeder]) + } +} diff --git a/templates/fastify/src/database/seeders/UserSeeder.ts b/templates/fastify/src/database/seeders/UserSeeder.ts new file mode 100644 index 00000000..fcfb7800 --- /dev/null +++ b/templates/fastify/src/database/seeders/UserSeeder.ts @@ -0,0 +1,8 @@ +import { Seeder } from '@arkstack/database' +import { User } from 'src/app/models/User' + +export class UserSeeder extends Seeder { + public async run(): Promise { + User.factory(5).create() + } +} diff --git a/templates/fastify/src/lang/en/messages.ts b/templates/fastify/src/lang/en/messages.ts new file mode 100644 index 00000000..3dbd5a08 --- /dev/null +++ b/templates/fastify/src/lang/en/messages.ts @@ -0,0 +1,4 @@ +export default { + welcome: 'Welcome to :app!', + items: '{0} No items|{1} One item|[2,*] :count items', +} diff --git a/templates/fastify/src/resources/views/welcome.edge b/templates/fastify/src/resources/views/welcome.edge new file mode 100644 index 00000000..ce5b177a --- /dev/null +++ b/templates/fastify/src/resources/views/welcome.edge @@ -0,0 +1,439 @@ + + + + + + + {{ title }} + + + + + + + +
+
+
+
+ Arkstack logo +
+
+

Arkstack

+

Node.js Framework — Runtime Agnostic

+
+
+ +
+
+ {{ message }} +
+ +
+ +
+
+
+ + + +
+
Fast by default
+
Zero-bloat core. Ships only what you need.
+
+
+
+ + + + +
+
Adapters
+
Express, Fastify, Hono, Koa — swap runtimes freely.
+
+
+
+ + + + +
+
Structured
+
Modular conventions for Node.js.
+
+
+ +
+
get started
+ + // src/routes/api.ts
+ import { Router } from '@arkstack/driver-fastify'

+ Router.get('/', () => {
+   return { status: 'ok' }
+ })
+
+ + + +
+ + + + + \ No newline at end of file diff --git a/templates/fastify/src/routes/api.ts b/templates/fastify/src/routes/api.ts new file mode 100644 index 00000000..e7c871de --- /dev/null +++ b/templates/fastify/src/routes/api.ts @@ -0,0 +1,8 @@ +import { Router } from '@arkstack/driver-fastify' +import UserController from 'src/app/http/controllers/UserController' + +Router.get('/', () => { + return { status: 'OK' } +}) + +Router.apiResource('/users', UserController) diff --git a/templates/fastify/src/routes/console.ts b/templates/fastify/src/routes/console.ts new file mode 100644 index 00000000..c872552f --- /dev/null +++ b/templates/fastify/src/routes/console.ts @@ -0,0 +1,18 @@ +import { Schedule } from '@arkstack/scheduler' + +/* + * Console schedule + * + * Define your scheduled tasks here. In production, run them with a single system + * cron entry: + * + * * * * * * cd /path/to/app && npx ark schedule:run >> /dev/null 2>&1 + * + * During development, run `ark schedule:work` to evaluate tasks every minute. + */ + +// Schedule.command('cache:prune').hourly() +Schedule.call(() => console.log('tick')) + .everyFiveMinutes() + .withoutOverlapping() +// Schedule.exec('backup.sh').dailyAt('01:30').onOneServer() diff --git a/templates/fastify/src/routes/web.ts b/templates/fastify/src/routes/web.ts new file mode 100644 index 00000000..8901f5be --- /dev/null +++ b/templates/fastify/src/routes/web.ts @@ -0,0 +1,9 @@ +import { Router } from '@arkstack/driver-fastify' +import { view } from '@arkstack/view' + +Router.get('/', async () => { + return await view('welcome', { + title: 'Welcome to Arkstack', + message: 'Server running — ready for requests', + }) +}) diff --git a/templates/fastify/src/server.ts b/templates/fastify/src/server.ts new file mode 100644 index 00000000..d774edfb --- /dev/null +++ b/templates/fastify/src/server.ts @@ -0,0 +1,3 @@ +import { app } from './core/bootstrap' + +await app.startup() diff --git a/templates/fastify/storage/app/.gitignore b/templates/fastify/storage/app/.gitignore new file mode 100755 index 00000000..ac20221a --- /dev/null +++ b/templates/fastify/storage/app/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!public/ \ No newline at end of file diff --git a/templates/fastify/storage/app/public/.gitignore b/templates/fastify/storage/app/public/.gitignore new file mode 100644 index 00000000..cb43407a --- /dev/null +++ b/templates/fastify/storage/app/public/.gitignore @@ -0,0 +1,4 @@ +* +!.gitignore +!/**/*/ +!/**/*/default.* diff --git a/templates/fastify/storage/logs/.gitignore b/templates/fastify/storage/logs/.gitignore new file mode 100755 index 00000000..d6b7ef32 --- /dev/null +++ b/templates/fastify/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/templates/fastify/tests/basic.test.ts b/templates/fastify/tests/basic.test.ts new file mode 100644 index 00000000..e4003191 --- /dev/null +++ b/templates/fastify/tests/basic.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { Arkstack } from '@arkstack/contract' +import { app } from '../src/core/bootstrap' +import { existsSync } from 'node:fs' +import path from 'node:path' +import request from 'parasito' + +/** + * We use this test at the monorepo level, + * this asertion allows us to scope the test to work with the monorepo setup + * If you created your app with `create arkstack`, this can safely be removed. + */ +const rwd = path.join(process.cwd(), '/templates/fastify') +if (existsSync(rwd)) Arkstack.setRootDir(rwd) + +describe('starter template', () => { + it('runs a basic assertion', () => { + expect(1 + 1).toBe(2) + }) + + it('can test a route', async () => { + await request(app).get('/api').contains('{"status":"OK"}') + }) + + it('can test an api route', async () => { + await request(app).get('/api/users').contains('"status":"success","message":"OK"') + }) +}) diff --git a/templates/fastify/tests/decorators.test.ts b/templates/fastify/tests/decorators.test.ts new file mode 100644 index 00000000..58f566d1 --- /dev/null +++ b/templates/fastify/tests/decorators.test.ts @@ -0,0 +1,49 @@ +import 'clear-router/decorators/setup' + +import { Controller as BaseController, Request } from 'clear-router' +import Fastify, { type FastifyInstance } from 'fastify' +import { afterEach, beforeEach, describe, it } from 'vitest' + +import { Bind } from 'clear-router/decorators' +import { Container } from 'clear-router/decorators' +import { Router } from 'clear-router/fastify' +import { request } from 'parasito' + +describe('@Bind() — Controllers', () => { + let app: FastifyInstance + + const setupApp = async (): Promise => { + Router.apply(app) + await app.ready() + } + + beforeEach(() => { + Router.reset() + Container.clear() + + app = Fastify() + }) + + afterEach(async () => { + await app.close() + }) + + class UserController extends BaseController { + @Bind() + async test(req: Request) { + return req.url ?? '' + } + } + + it('can setup basic route', async () => { + Router.get('/test', ({ reply }) => reply.send('OK')) + await setupApp() + await request(app).get('/test').expect(200).expect('OK') + }) + + it('can use container injection in controllers', async () => { + Router.get('/test/:id', [UserController, 'test']) + await setupApp() + await request(app).get('/test/1541').expect(200).expect('/test/1541') + }) +}) diff --git a/templates/fastify/tsconfig.json b/templates/fastify/tsconfig.json new file mode 100644 index 00000000..5722af5c --- /dev/null +++ b/templates/fastify/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.arkstack/tsconfig.json" +} diff --git a/templates/fastify/tsdown.config.ts b/templates/fastify/tsdown.config.ts new file mode 100644 index 00000000..b1965105 --- /dev/null +++ b/templates/fastify/tsdown.config.ts @@ -0,0 +1,59 @@ +import { devServer, nodeEnv, outputDir } from '@arkstack/common' +import { readFileSync, writeFileSync } from 'node:fs' + +import { Arkstack } from '@arkstack/contract' +import { defineConfig } from 'tsdown' +import path from 'node:path' + +const env = nodeEnv() +const dist = path.relative(Arkstack.rootDir(), outputDir()) + +export default defineConfig([ + { + unbundle: true, + clean: env !== 'dev' || process.env.CLI_BUILD === 'true', + tsconfig: 'tsconfig.json', + entry: ['src/**/*.ts'], + platform: 'node', + outDir: dist, + format: 'esm', + sourcemap: env !== 'dev', + logLevel: 'silent', + copy: [{ from: 'src/resources', to: dist }], + deps: { + skipNodeModulesBundle: true, + }, + watch: env === 'dev' && process.env.CLI_BUILD !== 'true' ? ['.env', '.env.*', 'src', 'tsconfig.json'] : false, + plugins: [ + devServer({ + env: Object.assign({}, process.env, { + NODE_ENV: env, + }), + execArgv: ['-r', 'source-map-support/register'], + input: path.join(Arkstack.rootDir(), 'src/server.ts'), + }), + ], + outExtensions: () => { + return { + js: '.js', + dts: '.d.ts', + } + }, + hooks(e) { + e.hook('build:done', async (e) => { + for (let i = 0; i < e.chunks.length; i++) { + const chunk = e.chunks[i] + if (chunk && chunk.fileName.endsWith('.js')) { + let code = readFileSync(path.join(chunk.outDir, chunk.fileName), 'utf-8') + code = code.replace( + /(? + `${kw}${gap}${quote}${spec.replace(/^src\//, `${dist}/`).replace(/(?