Skip to content

Commit e06d0fe

Browse files
authored
feat: implement plugin architecture (#34)
* feat: support plugin * feat(experimental): data caching plugin
1 parent ea83c2f commit e06d0fe

File tree

12 files changed

+194
-35
lines changed

12 files changed

+194
-35
lines changed

packages/dobs/src/_types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export type LooseObject<KnownKeys extends string, ValueType> = Partial<
44
Record<string, ValueType>;
55

66
export type Promisable<T> = T | Promise<T>;
7+
export type Maybe<T> = T | null | undefined;

packages/dobs/src/config.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createServer } from 'node:http';
22
import type { Middleware } from '@dobsjs/http';
33
import { deepmerge } from 'deepmerge-ts';
4+
import { createPluginRunner, Plugin } from './plugin';
45

56
export interface ResolvedServerConfig {
67
/** port to serve (default: 8080) */
@@ -27,6 +28,8 @@ export interface ResolvedServerConfig {
2728
*/
2829
directory: string;
2930
};
31+
32+
plugins: Plugin[];
3033
}
3134

3235
export type ServerConfig = Partial<ResolvedServerConfig>;
@@ -41,8 +44,19 @@ export const DEFAULT_CONFIG: ResolvedServerConfig = {
4144
build: {
4245
directory: 'dist',
4346
},
47+
plugins: [],
4448
};
4549

4650
export function resolveConfig(config: ServerConfig): ResolvedServerConfig {
47-
return deepmerge(DEFAULT_CONFIG, config) as ResolvedServerConfig;
51+
const runner = createPluginRunner(config?.plugins ?? []);
52+
53+
// [plugin] execute plugin.config
54+
runner.execute('config', config);
55+
56+
const resolvedConfig = deepmerge(DEFAULT_CONFIG, config) as ResolvedServerConfig;
57+
58+
// [plugin] execute plugin.resolvedConfig
59+
runner.execute('resolvedConfig', resolvedConfig);
60+
61+
return resolvedConfig;
4862
}

packages/dobs/src/defineRoutes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ import { Routes } from './types';
1010
* });
1111
* ```
1212
*/
13-
export function defineRoutes(routes: Routes, wrappers: any[] = []) {
13+
export function defineRoutes(routes: Routes, wrappers: any[] = []): Routes {
1414
return wrappers.reduce((acc, wrapper) => wrapper(acc), routes);
1515
}

packages/dobs/src/experimental/cache.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Routes, Handler } from '../types';
2-
import { mutateObjectValues } from '../shared/object';
2+
import { mutateObjectKeys, mutateObjectValues } from '../shared/object';
3+
import { Plugin } from '../plugin';
34

45
export interface CacheOptions {
56
/**
@@ -108,6 +109,7 @@ function createWrapper(
108109
*
109110
* export default defineRoutes((req, res) => {}, [useCache()])
110111
* ```
112+
*
111113
* The values included in the ID generation are as follows: handler ID (GET, ALL, etc.) and pathname.
112114
* Values not included are: query, req.body, etc.
113115
*/
@@ -128,3 +130,40 @@ export function useCache(cacheOptions?: CacheOptions) {
128130
);
129131
};
130132
}
133+
134+
// internal cache plugin
135+
// concept: $ALL => ALL (caching enabled)
136+
137+
/**
138+
* Plugin that enables data caching
139+
*/
140+
export function cachePlugin(cacheOptions?: CacheOptions): Plugin {
141+
const cache = new (cacheOptions?.customCache ?? TtlCache)(cacheOptions?.ttl);
142+
143+
return {
144+
name: 'dobs/experimental/cache-plugin',
145+
146+
generateRoute(router) {
147+
const routerType = typeof router === 'function' ? 'function' : 'object';
148+
149+
if (routerType === 'function') {
150+
return router;
151+
}
152+
153+
const wrappedRouter = mutateObjectValues<string, Handler>(
154+
router as any,
155+
(value, key) => {
156+
if (key.startsWith('$')) {
157+
return createWrapper(value, cache, key);
158+
}
159+
return value;
160+
},
161+
);
162+
163+
return mutateObjectKeys(wrappedRouter, (key) => {
164+
if (key.startsWith('$')) return key.slice(1);
165+
return key;
166+
});
167+
},
168+
};
169+
}

packages/dobs/src/plugin.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { BuildOptions } from 'rolldown';
2+
3+
import type { ResolvedServerConfig, ServerConfig } from './config';
4+
import type { Promisable, Maybe } from './_types';
5+
import type { Routes } from './types';
6+
7+
export interface Plugin {
8+
name: string;
9+
10+
/** modify server config */
11+
config?(config: ServerConfig): Maybe<ServerConfig>;
12+
13+
/** access to resolved server config */
14+
resolvedConfig?(config: ResolvedServerConfig): Maybe<ResolvedServerConfig>;
15+
16+
/**
17+
* resolve rolldown build options
18+
* https://rolldown.rs/options/input
19+
*/
20+
resolveBuildOptions?(buildOptions: BuildOptions): Maybe<BuildOptions>;
21+
22+
generateRoute?(route: Routes): Promisable<Maybe<Routes>>;
23+
}
24+
25+
type FunctionKeys<T> = {
26+
[K in keyof T]: T[K] extends (...args: any[]) => any ? K : never;
27+
}[keyof T];
28+
29+
export function createPluginRunner(plugins: Plugin[]) {
30+
return {
31+
plugins,
32+
33+
async execute<K extends FunctionKeys<Plugin>>(
34+
key: K,
35+
...args: Parameters<NonNullable<Plugin[K]>>
36+
): Promise<ReturnType<NonNullable<Plugin[K]>>> {
37+
let result: any = args[0];
38+
39+
for (const plugin of plugins) {
40+
const fn = plugin[key];
41+
if (typeof fn !== 'function') continue;
42+
43+
try {
44+
const returned = await (fn as any).apply(plugin, [result]);
45+
if (returned !== undefined && returned !== null) {
46+
result = returned;
47+
}
48+
} catch (e) {
49+
throw new Error(
50+
`[${plugin.name}] error has occurred during executing ${plugin.name}.${key} - ${e.message}`,
51+
);
52+
}
53+
}
54+
55+
return result;
56+
},
57+
};
58+
}

packages/dobs/src/server/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Server } from 'node:http';
22
import httpServer, { Middleware } from '@dobsjs/http';
33
import { resolveConfig, ServerConfig } from '~/dobs/config';
44
import { createRouterMiddleware } from './router';
5+
import { createPluginRunner } from '../plugin';
56

67
type CreateServerReturn<T extends ServerConfig> = T['mode'] extends 'middleware'
78
? Middleware[]
@@ -10,6 +11,12 @@ type CreateServerReturn<T extends ServerConfig> = T['mode'] extends 'middleware'
1011
export async function createDobsServer<T extends ServerConfig>(
1112
config?: T,
1213
): Promise<CreateServerReturn<T>> {
14+
const plugins = config?.plugins || [];
15+
const runner = createPluginRunner(plugins);
16+
17+
// [plugin] execute plugin.config
18+
await runner.execute('config', config);
19+
1320
const resolvedConfig = resolveConfig(config);
1421
const server = httpServer(resolvedConfig.createServer);
1522

packages/dobs/src/server/router.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { lowercaseKeyObject } from '~/dobs/shared/object';
1414
import { dynamicImport } from './load';
1515
import nodeExternal from './plugins/external';
1616
import { mkdirSync, writeFileSync } from 'node:fs';
17+
import { createPluginRunner } from '../plugin';
1718

1819
type HandlerType = ((req: AppRequest, res: AppResponse) => any) | Record<string, any>;
1920

@@ -90,6 +91,7 @@ export function createInternalRouter(
9091
cachedModule: Map<string, any>,
9192
preloadedModules?: Map<string, any>,
9293
) {
94+
const pluginRunner = createPluginRunner(config.plugins);
9395
const routesDirectory = join(config.cwd, 'app');
9496

9597
const matchRoute = (url: string) => routes.find((route) => route.regex.test(url));
@@ -113,7 +115,7 @@ export function createInternalRouter(
113115

114116
try {
115117
const method = (req.method || '').toLowerCase();
116-
const handlers: PageType = pageModule;
118+
const handlers: PageType = await pluginRunner.execute('generateRoute', pageModule);
117119

118120
const execute = async (handler: HandlerType) => {
119121
if (typeof handler !== 'function') return res.send(handler);
@@ -140,25 +142,35 @@ export function createInternalRouter(
140142
export async function createRouterMiddleware(
141143
config: ResolvedServerConfig,
142144
): Promise<Middleware> {
145+
const pluginRunner = createPluginRunner(config.plugins);
146+
143147
const routesDirectory = join(config.cwd, 'app');
144148
const tempDirectory = join(config.cwd, config.temp, 'routes');
145149
const tempDirectoryPackageJSON = join(config.cwd, config.temp, 'package.json');
150+
146151
const cachedModule = new Map<string, any>();
147-
const buildOption: () => BuildOptions = () => ({
148-
input: routes.map((route) => join(routesDirectory, route.relativePath)),
149-
output: {
150-
format: 'cjs',
151-
sourcemap: true,
152-
esModule: true,
153-
dir: tempDirectory,
154-
},
155-
resolve: {
156-
conditionNames: ['require', 'node', 'default'],
157-
},
158-
write: false,
159-
// exclude /node_modules/
160-
plugins: [nodeExternal()],
161-
});
152+
const buildOption: () => BuildOptions = () => {
153+
const bo: BuildOptions = {
154+
input: routes.map((route) => join(routesDirectory, route.relativePath)),
155+
output: {
156+
format: 'cjs',
157+
sourcemap: true,
158+
esModule: true,
159+
dir: tempDirectory,
160+
},
161+
resolve: {
162+
conditionNames: ['require', 'node', 'default'],
163+
},
164+
write: false,
165+
// exclude /node_modules/
166+
plugins: [nodeExternal()],
167+
};
168+
169+
// [plugin] execute plugin.resolveBuildOptions
170+
pluginRunner.execute('resolveBuildOptions', bo);
171+
172+
return bo;
173+
};
162174
let routes = createRoutes(config);
163175

164176
// build initially (prod/dev)

packages/dobs/src/shared/object.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { lowercaseKeyObject, mutateObjectValues } from './object';
2+
import { lowercaseKeyObject, mutateObjectKeys, mutateObjectValues } from './object';
33

44
describe('lowercaseKeyObject', () => {
55
it('should lowercase keys of object', () => {
@@ -15,3 +15,12 @@ describe('mutateObjectValues', () => {
1515
});
1616
});
1717
});
18+
19+
describe('mutateObjectKeys', () => {
20+
it('should mutate keys of object', () => {
21+
expect(mutateObjectKeys({ a: 5, b: 10 }, (k) => k + '0')).toStrictEqual({
22+
a0: 5,
23+
b0: 10,
24+
});
25+
});
26+
});

packages/dobs/src/shared/object.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,24 @@ export function mutateObjectValues<Key = string, Value = any>(
1717
}
1818
return result;
1919
}
20+
21+
export function mutateObjectKeys<T extends Record<string, any>>(
22+
obj: T,
23+
fn: (key: string) => string,
24+
): any {
25+
if (typeof obj !== 'object' || obj === null) return obj;
26+
27+
if (Array.isArray(obj)) {
28+
return obj.map((item) => mutateObjectKeys(item, fn));
29+
}
30+
31+
const result: Record<string, any> = {};
32+
33+
for (const [key, value] of Object.entries(obj)) {
34+
const newKey = fn(key);
35+
result[newKey] =
36+
typeof value === 'object' && value !== null ? mutateObjectKeys(value, fn) : value;
37+
}
38+
39+
return result;
40+
}

packages/dobs/src/types.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,10 @@ import type { LooseObject, Promisable } from './_types';
88

99
export type Handler = (req: AppRequest, res: AppResponse) => Promisable<any>;
1010

11+
export type RouterKey = 'ALL' | 'GET' | 'POST' | 'DELETE' | 'UPDATE';
12+
1113
export type Routes =
12-
| LooseObject<
13-
'ALL' | 'GET' | 'POST' | 'DELETE' | 'UPDATE',
14-
Handler | Record<string, any>
15-
>
14+
| LooseObject<RouterKey | `$${RouterKey}`, Handler | Record<string, any>>
1615
| Handler;
1716

1817
export function defineConfig(userConfig: Omit<ServerConfig, 'mode'>) {

0 commit comments

Comments
 (0)