diff --git a/packages/dobs/src/_types.ts b/packages/dobs/src/_types.ts index f44a88f..5d9cf3d 100644 --- a/packages/dobs/src/_types.ts +++ b/packages/dobs/src/_types.ts @@ -4,3 +4,4 @@ export type LooseObject = Partial< Record; export type Promisable = T | Promise; +export type Maybe = T | null | undefined; diff --git a/packages/dobs/src/config.ts b/packages/dobs/src/config.ts index 78278a4..f68f12f 100644 --- a/packages/dobs/src/config.ts +++ b/packages/dobs/src/config.ts @@ -1,6 +1,7 @@ import { createServer } from 'node:http'; import type { Middleware } from '@dobsjs/http'; import { deepmerge } from 'deepmerge-ts'; +import { createPluginRunner, Plugin } from './plugin'; export interface ResolvedServerConfig { /** port to serve (default: 8080) */ @@ -27,6 +28,8 @@ export interface ResolvedServerConfig { */ directory: string; }; + + plugins: Plugin[]; } export type ServerConfig = Partial; @@ -41,8 +44,19 @@ export const DEFAULT_CONFIG: ResolvedServerConfig = { build: { directory: 'dist', }, + plugins: [], }; export function resolveConfig(config: ServerConfig): ResolvedServerConfig { - return deepmerge(DEFAULT_CONFIG, config) as ResolvedServerConfig; + const runner = createPluginRunner(config?.plugins ?? []); + + // [plugin] execute plugin.config + runner.execute('config', config); + + const resolvedConfig = deepmerge(DEFAULT_CONFIG, config) as ResolvedServerConfig; + + // [plugin] execute plugin.resolvedConfig + runner.execute('resolvedConfig', resolvedConfig); + + return resolvedConfig; } diff --git a/packages/dobs/src/defineRoutes.ts b/packages/dobs/src/defineRoutes.ts index c67ce3b..d93166a 100644 --- a/packages/dobs/src/defineRoutes.ts +++ b/packages/dobs/src/defineRoutes.ts @@ -10,6 +10,6 @@ import { Routes } from './types'; * }); * ``` */ -export function defineRoutes(routes: Routes, wrappers: any[] = []) { +export function defineRoutes(routes: Routes, wrappers: any[] = []): Routes { return wrappers.reduce((acc, wrapper) => wrapper(acc), routes); } diff --git a/packages/dobs/src/experimental/cache.ts b/packages/dobs/src/experimental/cache.ts index 6ef4eb8..3c9c752 100644 --- a/packages/dobs/src/experimental/cache.ts +++ b/packages/dobs/src/experimental/cache.ts @@ -1,5 +1,6 @@ import { Routes, Handler } from '../types'; -import { mutateObjectValues } from '../shared/object'; +import { mutateObjectKeys, mutateObjectValues } from '../shared/object'; +import { Plugin } from '../plugin'; export interface CacheOptions { /** @@ -108,6 +109,7 @@ function createWrapper( * * export default defineRoutes((req, res) => {}, [useCache()]) * ``` + * * The values included in the ID generation are as follows: handler ID (GET, ALL, etc.) and pathname. * Values not included are: query, req.body, etc. */ @@ -128,3 +130,40 @@ export function useCache(cacheOptions?: CacheOptions) { ); }; } + +// internal cache plugin +// concept: $ALL => ALL (caching enabled) + +/** + * Plugin that enables data caching + */ +export function cachePlugin(cacheOptions?: CacheOptions): Plugin { + const cache = new (cacheOptions?.customCache ?? TtlCache)(cacheOptions?.ttl); + + return { + name: 'dobs/experimental/cache-plugin', + + generateRoute(router) { + const routerType = typeof router === 'function' ? 'function' : 'object'; + + if (routerType === 'function') { + return router; + } + + const wrappedRouter = mutateObjectValues( + router as any, + (value, key) => { + if (key.startsWith('$')) { + return createWrapper(value, cache, key); + } + return value; + }, + ); + + return mutateObjectKeys(wrappedRouter, (key) => { + if (key.startsWith('$')) return key.slice(1); + return key; + }); + }, + }; +} diff --git a/packages/dobs/src/plugin.ts b/packages/dobs/src/plugin.ts new file mode 100644 index 0000000..9235514 --- /dev/null +++ b/packages/dobs/src/plugin.ts @@ -0,0 +1,58 @@ +import type { BuildOptions } from 'rolldown'; + +import type { ResolvedServerConfig, ServerConfig } from './config'; +import type { Promisable, Maybe } from './_types'; +import type { Routes } from './types'; + +export interface Plugin { + name: string; + + /** modify server config */ + config?(config: ServerConfig): Maybe; + + /** access to resolved server config */ + resolvedConfig?(config: ResolvedServerConfig): Maybe; + + /** + * resolve rolldown build options + * https://rolldown.rs/options/input + */ + resolveBuildOptions?(buildOptions: BuildOptions): Maybe; + + generateRoute?(route: Routes): Promisable>; +} + +type FunctionKeys = { + [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never; +}[keyof T]; + +export function createPluginRunner(plugins: Plugin[]) { + return { + plugins, + + async execute>( + key: K, + ...args: Parameters> + ): Promise>> { + let result: any = args[0]; + + for (const plugin of plugins) { + const fn = plugin[key]; + if (typeof fn !== 'function') continue; + + try { + const returned = await (fn as any).apply(plugin, [result]); + if (returned !== undefined && returned !== null) { + result = returned; + } + } catch (e) { + throw new Error( + `[${plugin.name}] error has occurred during executing ${plugin.name}.${key} - ${e.message}`, + ); + } + } + + return result; + }, + }; +} diff --git a/packages/dobs/src/server/index.ts b/packages/dobs/src/server/index.ts index e138fcc..a09ac6e 100644 --- a/packages/dobs/src/server/index.ts +++ b/packages/dobs/src/server/index.ts @@ -2,6 +2,7 @@ import { Server } from 'node:http'; import httpServer, { Middleware } from '@dobsjs/http'; import { resolveConfig, ServerConfig } from '~/dobs/config'; import { createRouterMiddleware } from './router'; +import { createPluginRunner } from '../plugin'; type CreateServerReturn = T['mode'] extends 'middleware' ? Middleware[] @@ -10,6 +11,12 @@ type CreateServerReturn = T['mode'] extends 'middleware' export async function createDobsServer( config?: T, ): Promise> { + const plugins = config?.plugins || []; + const runner = createPluginRunner(plugins); + + // [plugin] execute plugin.config + await runner.execute('config', config); + const resolvedConfig = resolveConfig(config); const server = httpServer(resolvedConfig.createServer); diff --git a/packages/dobs/src/server/router.ts b/packages/dobs/src/server/router.ts index 657bf16..b9166b2 100644 --- a/packages/dobs/src/server/router.ts +++ b/packages/dobs/src/server/router.ts @@ -14,6 +14,7 @@ import { lowercaseKeyObject } from '~/dobs/shared/object'; import { dynamicImport } from './load'; import nodeExternal from './plugins/external'; import { mkdirSync, writeFileSync } from 'node:fs'; +import { createPluginRunner } from '../plugin'; type HandlerType = ((req: AppRequest, res: AppResponse) => any) | Record; @@ -90,6 +91,7 @@ export function createInternalRouter( cachedModule: Map, preloadedModules?: Map, ) { + const pluginRunner = createPluginRunner(config.plugins); const routesDirectory = join(config.cwd, 'app'); const matchRoute = (url: string) => routes.find((route) => route.regex.test(url)); @@ -113,7 +115,7 @@ export function createInternalRouter( try { const method = (req.method || '').toLowerCase(); - const handlers: PageType = pageModule; + const handlers: PageType = await pluginRunner.execute('generateRoute', pageModule); const execute = async (handler: HandlerType) => { if (typeof handler !== 'function') return res.send(handler); @@ -140,25 +142,35 @@ export function createInternalRouter( export async function createRouterMiddleware( config: ResolvedServerConfig, ): Promise { + const pluginRunner = createPluginRunner(config.plugins); + const routesDirectory = join(config.cwd, 'app'); const tempDirectory = join(config.cwd, config.temp, 'routes'); const tempDirectoryPackageJSON = join(config.cwd, config.temp, 'package.json'); + const cachedModule = new Map(); - const buildOption: () => BuildOptions = () => ({ - input: routes.map((route) => join(routesDirectory, route.relativePath)), - output: { - format: 'cjs', - sourcemap: true, - esModule: true, - dir: tempDirectory, - }, - resolve: { - conditionNames: ['require', 'node', 'default'], - }, - write: false, - // exclude /node_modules/ - plugins: [nodeExternal()], - }); + const buildOption: () => BuildOptions = () => { + const bo: BuildOptions = { + input: routes.map((route) => join(routesDirectory, route.relativePath)), + output: { + format: 'cjs', + sourcemap: true, + esModule: true, + dir: tempDirectory, + }, + resolve: { + conditionNames: ['require', 'node', 'default'], + }, + write: false, + // exclude /node_modules/ + plugins: [nodeExternal()], + }; + + // [plugin] execute plugin.resolveBuildOptions + pluginRunner.execute('resolveBuildOptions', bo); + + return bo; + }; let routes = createRoutes(config); // build initially (prod/dev) diff --git a/packages/dobs/src/shared/object.test.ts b/packages/dobs/src/shared/object.test.ts index 3aefe03..fe26976 100644 --- a/packages/dobs/src/shared/object.test.ts +++ b/packages/dobs/src/shared/object.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { lowercaseKeyObject, mutateObjectValues } from './object'; +import { lowercaseKeyObject, mutateObjectKeys, mutateObjectValues } from './object'; describe('lowercaseKeyObject', () => { it('should lowercase keys of object', () => { @@ -15,3 +15,12 @@ describe('mutateObjectValues', () => { }); }); }); + +describe('mutateObjectKeys', () => { + it('should mutate keys of object', () => { + expect(mutateObjectKeys({ a: 5, b: 10 }, (k) => k + '0')).toStrictEqual({ + a0: 5, + b0: 10, + }); + }); +}); diff --git a/packages/dobs/src/shared/object.ts b/packages/dobs/src/shared/object.ts index dcba781..512c10b 100644 --- a/packages/dobs/src/shared/object.ts +++ b/packages/dobs/src/shared/object.ts @@ -17,3 +17,24 @@ export function mutateObjectValues( } return result; } + +export function mutateObjectKeys>( + obj: T, + fn: (key: string) => string, +): any { + if (typeof obj !== 'object' || obj === null) return obj; + + if (Array.isArray(obj)) { + return obj.map((item) => mutateObjectKeys(item, fn)); + } + + const result: Record = {}; + + for (const [key, value] of Object.entries(obj)) { + const newKey = fn(key); + result[newKey] = + typeof value === 'object' && value !== null ? mutateObjectKeys(value, fn) : value; + } + + return result; +} diff --git a/packages/dobs/src/types.ts b/packages/dobs/src/types.ts index 7ad83ce..64979a9 100644 --- a/packages/dobs/src/types.ts +++ b/packages/dobs/src/types.ts @@ -8,11 +8,10 @@ import type { LooseObject, Promisable } from './_types'; export type Handler = (req: AppRequest, res: AppResponse) => Promisable; +export type RouterKey = 'ALL' | 'GET' | 'POST' | 'DELETE' | 'UPDATE'; + export type Routes = - | LooseObject< - 'ALL' | 'GET' | 'POST' | 'DELETE' | 'UPDATE', - Handler | Record - > + | LooseObject> | Handler; export function defineConfig(userConfig: Omit) { diff --git a/playgrounds/default/app/index.ts b/playgrounds/default/app/index.ts index 85a8531..069365a 100644 --- a/playgrounds/default/app/index.ts +++ b/playgrounds/default/app/index.ts @@ -1,14 +1,10 @@ import { defineRoutes } from 'dobs'; -import { useCache } from 'dobs/experimental'; -export default defineRoutes( - { - GET(req, res) { - setTimeout(() => { - res.send('Dynamic Handler~!'); - }, 1000); - }, - POST: { message: 'This is static data' }, +export default defineRoutes({ + $GET(req, res) { + setTimeout(() => { + res.send('Dynamic Handler~!'); + }, 1000); }, - [useCache({ ttl: 1000 })], -); + POST: { message: 'This is static data' }, +}); diff --git a/playgrounds/default/dobs.config.ts b/playgrounds/default/dobs.config.ts index 826e196..85f855d 100644 --- a/playgrounds/default/dobs.config.ts +++ b/playgrounds/default/dobs.config.ts @@ -1,3 +1,6 @@ +import { cachePlugin } from 'dobs/experimental'; + export default { port: 3000, + plugins: [cachePlugin({ ttl: 1000 })], } satisfies import('dobs').ServerConfig;