Skip to content

Commit

Permalink
feat: basic plugin manager
Browse files Browse the repository at this point in the history
  • Loading branch information
sorrycc committed Nov 13, 2024
1 parent 7bca6c7 commit 9fcac63
Show file tree
Hide file tree
Showing 9 changed files with 285 additions and 27 deletions.
7 changes: 7 additions & 0 deletions src/build.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { BuildParams } from '@umijs/mako';
import chokidar from 'chokidar';
import path from 'pathe';
import { PluginHookType } from './plugin/plugin_manager';
import { sync } from './sync/sync';
import type { Context } from './types';

Expand Down Expand Up @@ -59,6 +60,12 @@ export async function build({
root: cwd,
watch: !!watch,
});
await context.pluginManager.apply({
hook: 'buildEnd',
args: [],
type: PluginHookType.Parallel,
pluginContext: context.pluginContext,
});
}

function resolveLib(lib: string) {
Expand Down
37 changes: 25 additions & 12 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,37 @@
import assert from 'assert';
import path from 'pathe';
import yargsParser from 'yargs-parser';
import { loadConfig } from './config.js';
import { FRAMEWORK_NAME, MIN_NODE_VERSION } from './constants.js';
import {
checkVersion,
setNoDeprecation,
setNodeTitle,
} from './fishkit/node.js';
import type { Context } from './types/index.js';
import { loadConfig } from './config';
import { FRAMEWORK_NAME, MIN_NODE_VERSION } from './constants';
import { checkVersion, setNoDeprecation, setNodeTitle } from './fishkit/node';
import { PluginManager } from './plugin/plugin_manager';
import { type Context, Mode } from './types';

async function buildContext(cwd: string): Promise<Context> {
const argv = yargsParser(process.argv.slice(2));
const cmd = argv._[0];
const isDev = cmd === 'development';
const command = argv._[0];
const isDev = command === 'dev';
const config = await loadConfig({ cwd });
const pluginManager = new PluginManager(config.plugins || []);
const pluginContext = {
command: command as string | undefined,
config,
cwd,
// TODO: diff config and userConfig
userConfig: config,
// TODO: debug
// TODO: error
// TODO: info
// TODO: warn
// TODO: watcher
};
return {
argv,
config: await loadConfig({ cwd }),
config,
pluginManager,
pluginContext,
cwd,
mode: isDev ? 'development' : 'production',
mode: isDev ? Mode.Development : Mode.Production,
paths: {
tmpPath: path.join(cwd, `.${FRAMEWORK_NAME}`),
},
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
} from 'c12';
import { z } from 'zod';
import { CONFIG_FILE } from './constants';
import { PluginSchema } from './plugin/types';

const ConfigSchema = z.object({
externals: z.record(z.string()).optional(),
Expand All @@ -28,6 +29,7 @@ const ConfigSchema = z.object({
plugins: z.array(z.any()).optional(),
})
.optional(),
plugins: z.array(PluginSchema).optional(),
router: z
.object({
defaultPreload: z.enum(['intent', 'render', 'viewport']).optional(),
Expand Down
122 changes: 122 additions & 0 deletions src/plugin/plugin_manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { expect, test } from 'vitest';
import { PluginHookType, PluginManager } from './plugin_manager';
import type { Plugin } from './types';

test('PluginManager should execute plugins in correct order (pre -> normal -> post)', async () => {
const order: string[] = [];
const plugins: Plugin[] = [
{ enforce: 'post', buildStart: () => order.push('post') },
{ enforce: 'pre', buildStart: () => order.push('pre') },
{ buildStart: () => order.push('normal') },
];

const manager = new PluginManager(plugins);
await manager.apply({
hook: 'buildStart',
args: [],
type: PluginHookType.Series,
pluginContext: {},
});

expect(order).toEqual(['pre', 'normal', 'post']);
});

test('First hook type should return first non-null result', async () => {
const plugins: Plugin[] = [
{ buildStart: () => null },
{ buildStart: () => 'second' },
{ buildStart: () => 'third' },
];

const manager = new PluginManager(plugins);
const result = await manager.apply({
hook: 'buildStart',
args: [],
type: PluginHookType.First,
pluginContext: {},
});

expect(result).toBe('second');
});

test('Series hook type should pass result through plugins', async () => {
const plugins: (Plugin & { transform: (n: number) => number })[] = [
{ transform: (n: number) => n + 1 },
{ transform: (n: number) => n * 2 },
{ transform: (n: number) => n - 3 },
];

const manager = new PluginManager(plugins);
const result = await manager.apply({
hook: 'transform',
args: [],
memo: 5,
type: PluginHookType.Series,
pluginContext: {},
});

// 5 -> 6 -> 12 -> 9
expect(result).toBe(9);
});

test('Parallel hook type should execute plugins concurrently', async () => {
const delays = [30, 10, 20];
const order: number[] = [];

const plugins: (Plugin & { test: () => Promise<string> })[] = delays.map(
(delay, index) => ({
test: async () => {
await new Promise((resolve) => setTimeout(resolve, delay));
order.push(index);
return `result${index}`;
},
}),
);

const manager = new PluginManager(plugins);
const results = await manager.apply({
hook: 'test',
args: [],
type: PluginHookType.Parallel,
pluginContext: {},
});

// Results should be in original plugin order
expect(results).toEqual(['result0', 'result1', 'result2']);
// Execution order should be based on delays (10ms, 20ms, 30ms)
expect(order).toEqual([1, 2, 0]);
});

test('Plugin context should be correctly passed to hooks', async () => {
const context = { value: 42 };
const plugin: Plugin & { test: () => number } = {
test() {
expect(this).toBe(context);
// @ts-expect-error
return this.value;
},
};

const manager = new PluginManager([plugin]);
const result = await manager.apply({
hook: 'test',
args: [],
type: PluginHookType.First,
pluginContext: context,
});

expect(result).toBe(42);
});

test('Invalid hook type should throw error', async () => {
const manager = new PluginManager([]);

await expect(
manager.apply({
hook: 'test',
args: [],
type: 'invalid' as any,
pluginContext: {},
}),
).rejects.toThrow('Invalid hook type: invalid');
});
68 changes: 68 additions & 0 deletions src/plugin/plugin_manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { Plugin } from './types';

export enum PluginHookType {
First = 'first',
Series = 'series',
Parallel = 'parallel',
}

export class PluginManager<T extends Plugin> {
#plugins: T[] = [];
constructor(rawPlugins: T[]) {
this.#plugins = [
...rawPlugins.filter((p) => p.enforce === 'pre'),
...rawPlugins.filter((p) => !p.enforce),
...rawPlugins.filter((p) => p.enforce === 'post'),
];
}

async apply({
hook,
args,
memo,
type = PluginHookType.Series,
pluginContext,
}: {
hook: keyof T;
args: any[];
memo?: any;
type: PluginHookType;
pluginContext: any;
}) {
const plugins = this.#plugins.filter((p) => !!p[hook]);
if (type === PluginHookType.First) {
for (const plugin of plugins) {
const hookFn = plugin[hook] as Function;
if (typeof hookFn === 'function') {
const result = await hookFn.apply(pluginContext, args);
if (result != null) {
return result;
}
}
}
return null;
} else if (type === PluginHookType.Parallel) {
const results = await Promise.all(
plugins.map((p) => {
const hookFn = p[hook] as Function;
if (typeof hookFn === 'function') {
return hookFn.apply(pluginContext, args);
}
return null;
}),
);
return results.filter((r) => r != null);
} else if (type === PluginHookType.Series) {
let result = memo;
for (const plugin of plugins) {
const hookFn = plugin[hook] as Function;
if (typeof hookFn === 'function') {
result = await hookFn.apply(pluginContext, [result, ...args]);
}
}
return result;
} else {
throw new Error(`Invalid hook type: ${type}`);
}
}
}
27 changes: 27 additions & 0 deletions src/plugin/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { z } from 'zod';

export const PluginSchema = z.object({
enforce: z.enum(['pre', 'post']).optional(),
name: z.string().optional(),
buildStart: z
.function(
z.tuple([z.object({ command: z.string() })]),
z.union([z.any(), z.promise(z.any())]),
)
.optional(),
buildEnd: z
.function(
z.tuple([z.object({ command: z.string(), err: z.any().optional() })]),
z.union([z.any(), z.promise(z.any())]),
)
.optional(),
config: z
.function(
// TODO: fix z.any()
z.tuple([z.any(), z.object({ command: z.string() })]),
z.union([z.any(), z.promise(z.any()), z.null()]),
)
.optional(),
});

export type Plugin = z.infer<typeof PluginSchema>;
5 changes: 3 additions & 2 deletions src/sync/write_tailwindcss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type ChildProcess, spawn } from 'child_process';
import fs from 'fs-extra';
import module from 'module';
import path from 'pathe';
import { Mode } from '../types';
import type { SyncOptions } from './sync';

interface Paths {
Expand Down Expand Up @@ -41,10 +42,10 @@ function getTailwindBinPath(cwd: string): string {
async function generateFile(opts: {
binPath: string;
paths: Paths;
mode: 'development' | 'production';
mode: Mode;
}): Promise<void> {
const { binPath, paths, mode } = opts;
const isProduction = mode === 'production';
const isProduction = mode === Mode.Production;

return new Promise((resolve, reject) => {
tailwindProcess = spawn(
Expand Down
13 changes: 0 additions & 13 deletions src/types/index.d.ts

This file was deleted.

31 changes: 31 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type yargsParser from 'yargs-parser';
import type { Config } from '../config';
import type { PluginManager } from '../plugin/plugin_manager';
import type { Plugin } from '../plugin/types';

interface ContextPaths {
tmpPath: string;
}

// TODO: fix any
interface PluginContext {
command: string | undefined;
config: Config;
cwd: string;
userConfig: Config;
}

export enum Mode {
Development = 'development',
Production = 'production',
}

export interface Context {
argv: yargsParser.Arguments;
config: Config;
pluginManager: PluginManager<Plugin>;
pluginContext: PluginContext;
cwd: string;
mode: Mode;
paths: ContextPaths;
}

0 comments on commit 9fcac63

Please sign in to comment.