@spfn/core 0.1.0-alpha.84 → 0.1.0-alpha.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,57 @@ import { serve } from '@hono/node-server';
6
6
  * CORS configuration options - inferred from hono/cors
7
7
  */
8
8
  type CorsConfig = Parameters<typeof cors>[0];
9
+ /**
10
+ * SPFN Plugin Interface
11
+ *
12
+ * Allows packages to automatically hook into server lifecycle
13
+ * Plugins are auto-discovered from @spfn/* packages in node_modules
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * // packages/auth/src/plugin.ts
18
+ * export const spfnPlugin: ServerPlugin = {
19
+ * name: '@spfn/auth',
20
+ * afterInfrastructure: async () => {
21
+ * await initializeAuth();
22
+ * },
23
+ * beforeRoutes: async (app) => {
24
+ * app.route('/_auth', authRoutes);
25
+ * }
26
+ * };
27
+ * ```
28
+ */
29
+ interface ServerPlugin {
30
+ /**
31
+ * Plugin name (should match package name)
32
+ */
33
+ name: string;
34
+ /**
35
+ * Hook: Run after infrastructure (DB/Redis) initialization
36
+ * Use for: migrations, seeding, RBAC setup
37
+ */
38
+ afterInfrastructure?: () => Promise<void>;
39
+ /**
40
+ * Hook: Run before routes are loaded
41
+ * Use for: mounting plugin routes, adding middleware
42
+ */
43
+ beforeRoutes?: (app: Hono) => Promise<void>;
44
+ /**
45
+ * Hook: Run after all routes are loaded
46
+ * Use for: final setup, fallback handlers
47
+ */
48
+ afterRoutes?: (app: Hono) => Promise<void>;
49
+ /**
50
+ * Hook: Run after server starts successfully
51
+ * Use for: notifications, health checks
52
+ */
53
+ afterStart?: (instance: ServerInstance) => Promise<void>;
54
+ /**
55
+ * Hook: Run before graceful shutdown
56
+ * Use for: cleanup plugin resources
57
+ */
58
+ beforeShutdown?: () => Promise<void>;
59
+ }
9
60
  /**
10
61
  * Server Configuration Options
11
62
  *
@@ -405,7 +456,7 @@ declare module 'hono' {
405
456
  * 2. server.config.ts -> Partial customization
406
457
  * 3. app.ts -> Full control (no auto config)
407
458
  */
408
- declare function createServer(config?: ServerConfig): Promise<Hono>;
459
+ declare function createServer(config?: ServerConfig, plugins?: ServerPlugin[]): Promise<Hono>;
409
460
 
410
461
  /**
411
462
  * Start SPFN Server
@@ -424,4 +475,4 @@ declare function createServer(config?: ServerConfig): Promise<Hono>;
424
475
  */
425
476
  declare function startServer(config?: ServerConfig): Promise<ServerInstance>;
426
477
 
427
- export { type AppFactory, type ServerConfig, type ServerInstance, createServer, startServer };
478
+ export { type AppFactory, type ServerConfig, type ServerInstance, type ServerPlugin, createServer, startServer };
@@ -104,7 +104,18 @@ function formatConsole(metadata, colorize = true) {
104
104
  }
105
105
  if (metadata.context && Object.keys(metadata.context).length > 0) {
106
106
  Object.entries(metadata.context).forEach(([key, value]) => {
107
- const valueStr = typeof value === "string" ? value : String(value);
107
+ let valueStr;
108
+ if (typeof value === "string") {
109
+ valueStr = value;
110
+ } else if (typeof value === "object" && value !== null) {
111
+ try {
112
+ valueStr = JSON.stringify(value);
113
+ } catch (error) {
114
+ valueStr = "[circular]";
115
+ }
116
+ } else {
117
+ valueStr = String(value);
118
+ }
108
119
  if (colorize) {
109
120
  parts.push(`${COLORS.dim}[${key}=${valueStr}]${COLORS.reset}`);
110
121
  } else {
@@ -2851,6 +2862,7 @@ async function loadRoutes(app, options) {
2851
2862
 
2852
2863
  // src/route/bind.ts
2853
2864
  init_errors();
2865
+ init_logger2();
2854
2866
 
2855
2867
  // src/middleware/error-handler.ts
2856
2868
  init_logger2();
@@ -3111,29 +3123,133 @@ function buildStartupConfig(config, timeouts) {
3111
3123
  };
3112
3124
  }
3113
3125
 
3126
+ // src/server/plugin-discovery.ts
3127
+ init_logger2();
3128
+ var pluginLogger = logger.child("plugin");
3129
+ async function discoverPlugins(cwd = process.cwd()) {
3130
+ const plugins = [];
3131
+ const nodeModulesPath = join(cwd, "node_modules");
3132
+ try {
3133
+ const projectPkgPath = join(cwd, "package.json");
3134
+ if (!existsSync(projectPkgPath)) {
3135
+ pluginLogger.debug("No package.json found, skipping plugin discovery");
3136
+ return plugins;
3137
+ }
3138
+ const projectPkg = JSON.parse(readFileSync(projectPkgPath, "utf-8"));
3139
+ const dependencies = {
3140
+ ...projectPkg.dependencies,
3141
+ ...projectPkg.devDependencies
3142
+ };
3143
+ for (const [packageName] of Object.entries(dependencies)) {
3144
+ if (!packageName.startsWith("@spfn/")) {
3145
+ continue;
3146
+ }
3147
+ try {
3148
+ const plugin = await loadPluginFromPackage(packageName, nodeModulesPath);
3149
+ if (plugin) {
3150
+ plugins.push(plugin);
3151
+ pluginLogger.info("Plugin discovered", {
3152
+ name: plugin.name,
3153
+ hooks: getPluginHookNames(plugin)
3154
+ });
3155
+ }
3156
+ } catch (error) {
3157
+ pluginLogger.debug("Failed to load plugin", {
3158
+ package: packageName,
3159
+ error: error instanceof Error ? error.message : "Unknown error"
3160
+ });
3161
+ }
3162
+ }
3163
+ } catch (error) {
3164
+ pluginLogger.warn("Plugin discovery failed", {
3165
+ error: error instanceof Error ? error.message : "Unknown error"
3166
+ });
3167
+ }
3168
+ return plugins;
3169
+ }
3170
+ async function loadPluginFromPackage(packageName, nodeModulesPath) {
3171
+ const pkgPath = join(nodeModulesPath, ...packageName.split("/"), "package.json");
3172
+ if (!existsSync(pkgPath)) {
3173
+ return null;
3174
+ }
3175
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
3176
+ const packageDir = dirname(pkgPath);
3177
+ const mainEntry = pkg.main || "dist/index.js";
3178
+ const mainPath = join(packageDir, mainEntry);
3179
+ if (!existsSync(mainPath)) {
3180
+ return null;
3181
+ }
3182
+ try {
3183
+ const module = await import(mainPath);
3184
+ if (module.spfnPlugin && isValidPlugin(module.spfnPlugin)) {
3185
+ return module.spfnPlugin;
3186
+ }
3187
+ return null;
3188
+ } catch (error) {
3189
+ return null;
3190
+ }
3191
+ }
3192
+ function isValidPlugin(plugin) {
3193
+ return plugin && typeof plugin === "object" && typeof plugin.name === "string" && (typeof plugin.afterInfrastructure === "function" || typeof plugin.beforeRoutes === "function" || typeof plugin.afterRoutes === "function" || typeof plugin.afterStart === "function" || typeof plugin.beforeShutdown === "function");
3194
+ }
3195
+ function getPluginHookNames(plugin) {
3196
+ const hooks = [];
3197
+ if (plugin.afterInfrastructure) hooks.push("afterInfrastructure");
3198
+ if (plugin.beforeRoutes) hooks.push("beforeRoutes");
3199
+ if (plugin.afterRoutes) hooks.push("afterRoutes");
3200
+ if (plugin.afterStart) hooks.push("afterStart");
3201
+ if (plugin.beforeShutdown) hooks.push("beforeShutdown");
3202
+ return hooks;
3203
+ }
3204
+ async function executePluginHooks(plugins, hookName, ...args) {
3205
+ for (const plugin of plugins) {
3206
+ const hook = plugin[hookName];
3207
+ if (typeof hook === "function") {
3208
+ try {
3209
+ pluginLogger.debug("Executing plugin hook", {
3210
+ plugin: plugin.name,
3211
+ hook: hookName
3212
+ });
3213
+ await hook(...args);
3214
+ } catch (error) {
3215
+ pluginLogger.error("Plugin hook failed", {
3216
+ plugin: plugin.name,
3217
+ hook: hookName,
3218
+ error: error instanceof Error ? error.message : "Unknown error"
3219
+ });
3220
+ throw new Error(
3221
+ `Plugin ${plugin.name} failed in ${hookName} hook: ${error instanceof Error ? error.message : "Unknown error"}`
3222
+ );
3223
+ }
3224
+ }
3225
+ }
3226
+ }
3227
+
3114
3228
  // src/server/create-server.ts
3115
3229
  var serverLogger = logger.child("server");
3116
- async function createServer(config) {
3230
+ async function createServer(config, plugins = []) {
3117
3231
  const cwd = process.cwd();
3118
3232
  const appPath = join(cwd, "src", "server", "app.ts");
3119
3233
  const appJsPath = join(cwd, "src", "server", "app.js");
3120
3234
  if (existsSync(appPath) || existsSync(appJsPath)) {
3121
- return await loadCustomApp(appPath, appJsPath, config);
3235
+ return await loadCustomApp(appPath, appJsPath, config, plugins);
3122
3236
  }
3123
- return await createAutoConfiguredApp(config);
3237
+ return await createAutoConfiguredApp(config, plugins);
3124
3238
  }
3125
- async function loadCustomApp(appPath, appJsPath, config) {
3239
+ async function loadCustomApp(appPath, appJsPath, config, plugins = []) {
3126
3240
  const appModule = await (existsSync(appPath) ? import(appPath) : import(appJsPath));
3127
3241
  const appFactory = appModule.default;
3128
3242
  if (!appFactory) {
3129
3243
  throw new Error("app.ts must export a default function that returns a Hono app");
3130
3244
  }
3131
3245
  const app = await appFactory();
3246
+ await executePluginHooks(plugins, "beforeRoutes", app);
3132
3247
  const debug = config?.debug ?? process.env.NODE_ENV === "development";
3133
3248
  await loadRoutes(app, { routesDir: config?.routesPath, debug });
3249
+ await executePluginHooks(plugins, "afterRoutes", app);
3134
3250
  return app;
3135
3251
  }
3136
- async function createAutoConfiguredApp(config) {
3252
+ async function createAutoConfiguredApp(config, plugins = []) {
3137
3253
  const app = new Hono();
3138
3254
  const middlewareConfig = config?.middleware ?? {};
3139
3255
  const enableLogger = middlewareConfig.logger !== false;
@@ -3149,8 +3265,10 @@ async function createAutoConfiguredApp(config) {
3149
3265
  config?.use?.forEach((mw) => app.use("*", mw));
3150
3266
  registerHealthCheckEndpoint(app, config);
3151
3267
  await executeBeforeRoutesHook(app, config);
3268
+ await executePluginHooks(plugins, "beforeRoutes", app);
3152
3269
  await loadAppRoutes(app, config);
3153
3270
  await executeAfterRoutesHook(app, config);
3271
+ await executePluginHooks(plugins, "afterRoutes", app);
3154
3272
  if (enableErrorHandler) {
3155
3273
  app.onError(ErrorHandler());
3156
3274
  }
@@ -3295,9 +3413,17 @@ async function startServer(config) {
3295
3413
  if (debug) {
3296
3414
  logMiddlewareOrder(finalConfig);
3297
3415
  }
3416
+ serverLogger2.debug("Discovering plugins...");
3417
+ const plugins = await discoverPlugins();
3418
+ if (plugins.length > 0) {
3419
+ serverLogger2.info("Plugins discovered", {
3420
+ count: plugins.length,
3421
+ plugins: plugins.map((p) => p.name)
3422
+ });
3423
+ }
3298
3424
  try {
3299
- await initializeInfrastructure(finalConfig);
3300
- const app = await createServer(finalConfig);
3425
+ await initializeInfrastructure(finalConfig, plugins);
3426
+ const app = await createServer(finalConfig, plugins);
3301
3427
  const server = startHttpServer(app, host, port);
3302
3428
  const timeouts = getTimeoutConfig(finalConfig.timeout);
3303
3429
  applyServerTimeouts(server, timeouts);
@@ -3308,7 +3434,7 @@ async function startServer(config) {
3308
3434
  port
3309
3435
  });
3310
3436
  logServerStarted(debug, host, port, finalConfig, timeouts);
3311
- const shutdownServer = createShutdownHandler(server, finalConfig);
3437
+ const shutdownServer = createShutdownHandler(server, finalConfig, plugins);
3312
3438
  const shutdown = createGracefulShutdown(shutdownServer, finalConfig);
3313
3439
  registerShutdownHandlers(shutdown);
3314
3440
  const serverInstance = {
@@ -3328,6 +3454,7 @@ async function startServer(config) {
3328
3454
  serverLogger2.error("afterStart hook failed", error);
3329
3455
  }
3330
3456
  }
3457
+ await executePluginHooks(plugins, "afterStart", serverInstance);
3331
3458
  return serverInstance;
3332
3459
  } catch (error) {
3333
3460
  const err = error;
@@ -3369,7 +3496,7 @@ function logMiddlewareOrder(config) {
3369
3496
  order: middlewareOrder
3370
3497
  });
3371
3498
  }
3372
- async function initializeInfrastructure(config) {
3499
+ async function initializeInfrastructure(config, plugins) {
3373
3500
  if (config.lifecycle?.beforeInfrastructure) {
3374
3501
  serverLogger2.debug("Executing beforeInfrastructure hook...");
3375
3502
  try {
@@ -3402,6 +3529,7 @@ async function initializeInfrastructure(config) {
3402
3529
  throw new Error("Server initialization failed in afterInfrastructure hook");
3403
3530
  }
3404
3531
  }
3532
+ await executePluginHooks(plugins, "afterInfrastructure");
3405
3533
  }
3406
3534
  function startHttpServer(app, host, port) {
3407
3535
  serverLogger2.debug(`Starting server on ${host}:${port}...`);
@@ -3428,7 +3556,7 @@ function logServerStarted(debug, host, port, config, timeouts) {
3428
3556
  config: startupConfig
3429
3557
  });
3430
3558
  }
3431
- function createShutdownHandler(server, config) {
3559
+ function createShutdownHandler(server, config, plugins) {
3432
3560
  return async () => {
3433
3561
  serverLogger2.debug("Closing HTTP server...");
3434
3562
  await new Promise((resolve) => {
@@ -3445,6 +3573,11 @@ function createShutdownHandler(server, config) {
3445
3573
  serverLogger2.error("beforeShutdown hook failed", error);
3446
3574
  }
3447
3575
  }
3576
+ try {
3577
+ await executePluginHooks(plugins, "beforeShutdown");
3578
+ } catch (error) {
3579
+ serverLogger2.error("Plugin beforeShutdown hooks failed", error);
3580
+ }
3448
3581
  const shouldCloseDatabase = config.infrastructure?.database !== false;
3449
3582
  const shouldCloseRedis = config.infrastructure?.redis !== false;
3450
3583
  if (shouldCloseDatabase) {
@@ -3502,14 +3635,16 @@ function registerShutdownHandlers(shutdown) {
3502
3635
  } else {
3503
3636
  serverLogger2.error("Uncaught exception", error);
3504
3637
  }
3505
- shutdown("UNCAUGHT_EXCEPTION");
3638
+ serverLogger2.info("Exiting immediately for clean restart");
3639
+ process.exit(1);
3506
3640
  });
3507
3641
  process.on("unhandledRejection", (reason, promise) => {
3508
3642
  serverLogger2.error("Unhandled promise rejection", {
3509
3643
  reason,
3510
3644
  promise
3511
3645
  });
3512
- shutdown("UNHANDLED_REJECTION");
3646
+ serverLogger2.info("Exiting immediately for clean restart");
3647
+ process.exit(1);
3513
3648
  });
3514
3649
  }
3515
3650
  async function cleanupOnFailure(config) {