diff --git a/deno.json b/deno.json index a3f06bd5..4a34db67 100644 --- a/deno.json +++ b/deno.json @@ -9,6 +9,7 @@ "./packages/metrics", "./packages/policies", "./packages/ratelimiter", + "./packages/router", "./packages/translators", "./packages/uploaders" ], diff --git a/packages/router/DittoApp.test.ts b/packages/router/DittoApp.test.ts new file mode 100644 index 00000000..83da5bca --- /dev/null +++ b/packages/router/DittoApp.test.ts @@ -0,0 +1,23 @@ +import { DittoConf } from '@ditto/conf'; +import { DittoDB } from '@ditto/db'; +import { Hono } from '@hono/hono'; +import { MockRelay } from '@nostrify/nostrify/test'; + +import { DittoApp } from './DittoApp.ts'; +import { DittoRoute } from './DittoRoute.ts'; + +Deno.test('DittoApp', async () => { + await using db = DittoDB.create('memory://'); + const conf = new DittoConf(new Map()); + const relay = new MockRelay(); + + const app = new DittoApp({ conf, db, relay }); + + const hono = new Hono(); + const route = new DittoRoute(); + + app.route('/', route); + + // @ts-expect-error Passing a non-DittoRoute to route. + app.route('/', hono); +}); diff --git a/packages/router/DittoApp.ts b/packages/router/DittoApp.ts new file mode 100644 index 00000000..3309f65d --- /dev/null +++ b/packages/router/DittoApp.ts @@ -0,0 +1,21 @@ +import { Hono } from '@hono/hono'; + +import type { HonoOptions } from '@hono/hono/hono-base'; +import type { DittoEnv } from './DittoEnv.ts'; + +export class DittoApp extends Hono { + // @ts-ignore Require a DittoRoute for type safety. + declare route: (path: string, app: Hono) => Hono; + + constructor(vars: Omit, opts: HonoOptions = {}) { + super(opts); + + this.use((c, next) => { + c.set('db', vars.db); + c.set('conf', vars.conf); + c.set('relay', vars.relay); + c.set('signal', c.req.raw.signal); + return next(); + }); + } +} diff --git a/packages/router/DittoEnv.ts b/packages/router/DittoEnv.ts new file mode 100644 index 00000000..761bc3f8 --- /dev/null +++ b/packages/router/DittoEnv.ts @@ -0,0 +1,20 @@ +import type { DittoConf } from '@ditto/conf'; +import type { DittoDatabase } from '@ditto/db'; +import type { Env } from '@hono/hono'; +import type { NRelay } from '@nostrify/nostrify'; + +export interface DittoEnv extends Env { + Variables: { + /** Ditto site configuration. */ + conf: DittoConf; + /** Relay store. */ + relay: NRelay; + /** + * Database object. + * @deprecated Store data as Nostr events instead. + */ + db: DittoDatabase; + /** Abort signal for the request. */ + signal: AbortSignal; + }; +} diff --git a/packages/router/DittoMiddleware.ts b/packages/router/DittoMiddleware.ts new file mode 100644 index 00000000..1483ca90 --- /dev/null +++ b/packages/router/DittoMiddleware.ts @@ -0,0 +1,5 @@ +import type { MiddlewareHandler } from '@hono/hono'; +import type { DittoEnv } from './DittoEnv.ts'; + +// deno-lint-ignore ban-types +export type DittoMiddleware = MiddlewareHandler; diff --git a/packages/router/DittoRoute.test.ts b/packages/router/DittoRoute.test.ts new file mode 100644 index 00000000..737019c4 --- /dev/null +++ b/packages/router/DittoRoute.test.ts @@ -0,0 +1,12 @@ +import { assertEquals } from '@std/assert'; + +import { DittoRoute } from './DittoRoute.ts'; + +Deno.test('DittoRoute', async () => { + const route = new DittoRoute(); + const response = await route.request('/'); + const body = await response.json(); + + assertEquals(response.status, 500); + assertEquals(body, { error: 'Missing required variable: db' }); +}); diff --git a/packages/router/DittoRoute.ts b/packages/router/DittoRoute.ts new file mode 100644 index 00000000..369fb858 --- /dev/null +++ b/packages/router/DittoRoute.ts @@ -0,0 +1,53 @@ +import { type ErrorHandler, Hono } from '@hono/hono'; +import { HTTPException } from '@hono/hono/http-exception'; + +import type { HonoOptions } from '@hono/hono/hono-base'; +import type { DittoEnv } from './DittoEnv.ts'; + +/** + * Ditto base route class. + * Ensures that required variables are set for type safety. + */ +export class DittoRoute extends Hono { + constructor(opts: HonoOptions = {}) { + super(opts); + + this.use((c, next) => { + this.assertVars(c.var); + return next(); + }); + + this.onError(this._errorHandler); + } + + private assertVars(vars: Partial): DittoEnv['Variables'] { + if (!vars.db) this.throwMissingVar('db'); + if (!vars.conf) this.throwMissingVar('conf'); + if (!vars.relay) this.throwMissingVar('relay'); + if (!vars.signal) this.throwMissingVar('signal'); + + return { + ...vars, + db: vars.db, + conf: vars.conf, + relay: vars.relay, + signal: vars.signal, + }; + } + + private throwMissingVar(name: string): never { + throw new HTTPException(500, { message: `Missing required variable: ${name}` }); + } + + private _errorHandler: ErrorHandler = (error, c) => { + if (error instanceof HTTPException) { + if (error.res) { + return error.res; + } else { + return c.json({ error: error.message }, error.status); + } + } + + return c.json({ error: 'Something went wrong' }, 500); + }; +} diff --git a/packages/router/deno.json b/packages/router/deno.json new file mode 100644 index 00000000..8321baaf --- /dev/null +++ b/packages/router/deno.json @@ -0,0 +1,7 @@ +{ + "name": "@ditto/router", + "version": "1.1.0", + "exports": { + ".": "./mod.ts" + } +} diff --git a/packages/router/mod.ts b/packages/router/mod.ts new file mode 100644 index 00000000..8e9d1d46 --- /dev/null +++ b/packages/router/mod.ts @@ -0,0 +1,4 @@ +export { DittoApp } from './DittoApp.ts'; +export { DittoRoute } from './DittoRoute.ts'; + +export type { DittoEnv } from './DittoEnv.ts';