@checkstack/backend 0.8.1 → 0.9.0

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +280 -0
  2. package/drizzle/0001_slim_mordo.sql +34 -0
  3. package/drizzle/meta/0001_snapshot.json +444 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +14 -9
  6. package/src/index.ts +460 -23
  7. package/src/plugin-deregistration.test.ts +137 -0
  8. package/src/plugin-manager/api-router.ts +35 -11
  9. package/src/plugin-manager/core-services.ts +21 -2
  10. package/src/plugin-manager/plugin-loader.ts +94 -0
  11. package/src/plugin-manager.ts +324 -105
  12. package/src/router-incremental.test.ts +49 -0
  13. package/src/schema.ts +79 -1
  14. package/src/services/compatibility-checker.test.ts +146 -0
  15. package/src/services/compatibility-checker.ts +137 -0
  16. package/src/services/dev-auth.test.ts +87 -0
  17. package/src/services/dev-auth.ts +56 -0
  18. package/src/services/plugin-artifact-store.ts +131 -0
  19. package/src/services/plugin-bundle-resolver.ts +76 -0
  20. package/src/services/plugin-event-recorder.ts +87 -0
  21. package/src/services/plugin-installers/catalog-installer.ts +33 -0
  22. package/src/services/plugin-installers/github-installer.ts +207 -0
  23. package/src/services/plugin-installers/install-from-tarball.ts +69 -0
  24. package/src/services/plugin-installers/installer-registry.ts +51 -0
  25. package/src/services/plugin-installers/npm-installer.ts +156 -0
  26. package/src/services/plugin-installers/plugin-install-error.ts +37 -0
  27. package/src/services/plugin-installers/tarball-installer.ts +80 -0
  28. package/src/services/plugin-installers/tarball-utils.test.ts +200 -0
  29. package/src/services/plugin-installers/tarball-utils.ts +172 -0
  30. package/src/services/plugin-manager-orchestrator.ts +522 -0
  31. package/src/services/plugin-manager-router.ts +219 -0
  32. package/src/services/readiness-registry.test.ts +124 -0
  33. package/src/services/readiness-registry.ts +103 -0
  34. package/src/utils/plugin-discovery.test.ts +6 -0
  35. package/src/utils/plugin-discovery.ts +6 -1
  36. package/tsconfig.json +36 -1
  37. package/src/plugin-lifecycle.test.ts +0 -276
  38. package/src/plugin-manager/plugin-admin-router.ts +0 -89
  39. package/src/services/plugin-installer.test.ts +0 -90
  40. package/src/services/plugin-installer.ts +0 -70
@@ -1,8 +1,11 @@
1
1
  import type { Hono } from "hono";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
2
4
  import { adminPool, db } from "./db";
3
5
  import { ServiceRegistry } from "./services/service-registry";
4
6
  import type { CoreCollectorRegistry } from "./services/collector-registry";
5
7
  import type { WebSocketRouteStoreImpl } from "./services/ws-route-registry";
8
+ import type { CoreReadinessRegistry } from "./services/readiness-registry";
6
9
  import {
7
10
  BackendPlugin,
8
11
  ServiceRef,
@@ -13,12 +16,14 @@ import {
13
16
  } from "@checkstack/backend-api";
14
17
  import type { AnyContractRouter } from "@orpc/contract";
15
18
  import type { AccessRule, PluginMetadata } from "@checkstack/common";
19
+ import { extractErrorMessage } from "@checkstack/common";
16
20
 
17
21
  // Extracted modules
18
22
  import { registerCoreServices } from "./plugin-manager/core-services";
19
23
  import { createExtensionPointManager } from "./plugin-manager/extension-points";
20
24
  import { loadPlugins as loadPluginsImpl } from "./plugin-manager/plugin-loader";
21
25
  import { rootLogger } from "./logger";
26
+ import type { PluginEventRecorder } from "./services/plugin-event-recorder";
22
27
 
23
28
  export interface DeregisterOptions {
24
29
  deleteSchema: boolean;
@@ -54,7 +59,29 @@ export class PluginManager {
54
59
  // Global WebSocket route store for server-level routing
55
60
  private wsStore: WebSocketRouteStoreImpl;
56
61
 
62
+ // Global readiness registry — plugins contribute probes, /ready aggregates them
63
+ private readinessRegistry: CoreReadinessRegistry;
64
+
65
+ // Audit/error event recorder — wired post-construction in index.ts.
66
+ private eventRecorder: PluginEventRecorder | undefined;
67
+
68
+ // Filesystem location plugins are installed into at runtime (set in
69
+ // index.ts). The runtime install pipeline reads from here on bootstrap
70
+ // and writes to here when handling broadcast install hooks.
71
+ private runtimeDir: string | undefined;
72
+
73
+ // Resolves once `/api/:pluginId/*` is registered on the root router and
74
+ // Phase 2 (per-plugin init) is starting. The HTTP server awaits this
75
+ // promise to know when it is safe to stop gating incoming requests.
76
+ // Held as a deferred so the listener (server) can be wired up before
77
+ // loadPlugins() runs.
78
+ private resolveRoutesReady!: () => void;
79
+ readonly routesReadyPromise: Promise<void>;
80
+
57
81
  constructor() {
82
+ this.routesReadyPromise = new Promise<void>((resolve) => {
83
+ this.resolveRoutesReady = resolve;
84
+ });
58
85
  const registries = registerCoreServices({
59
86
  registry: this.registry,
60
87
  adminPool,
@@ -64,6 +91,69 @@ export class PluginManager {
64
91
  });
65
92
  this.collectorRegistry = registries.collectorRegistry;
66
93
  this.wsStore = registries.wsStore;
94
+ this.readinessRegistry = registries.readinessRegistry;
95
+ }
96
+
97
+ /**
98
+ * Register access rules owned by a core router (not a regular plugin).
99
+ * Used by `pluginmanager` and other built-in admin endpoints whose access
100
+ * rules need to be visible to the autoAuthMiddleware.
101
+ */
102
+ registerCoreAccessRules(
103
+ pluginId: string,
104
+ accessRules: AccessRule[],
105
+ ): void {
106
+ const prefixed = accessRules.map((rule) => ({
107
+ ...rule,
108
+ pluginId,
109
+ id: `${pluginId}.${rule.id}`,
110
+ }));
111
+ this.registeredAccessRules.push(...prefixed);
112
+ }
113
+
114
+ /**
115
+ * Register plugin metadata owned by a core router. The /api/:pluginId/*
116
+ * dispatcher in api-router.ts looks up `pluginMetadataRegistry` to build
117
+ * the RpcContext and 500s with "Plugin metadata not found in registry"
118
+ * when the lookup misses. Regular plugins populate the registry as part
119
+ * of their register() lifecycle; core routers (which never go through
120
+ * that lifecycle) need to call this method to register theirs.
121
+ */
122
+ registerCorePluginMetadata(metadata: PluginMetadata): void {
123
+ this.pluginMetadataRegistry.set(metadata.pluginId, metadata);
124
+ }
125
+
126
+ /**
127
+ * Expose the underlying ServiceRegistry to internal core code. Plugins
128
+ * should NEVER touch this directly — they go through the registered
129
+ * service refs.
130
+ */
131
+ getRegistry(): ServiceRegistry {
132
+ return this.registry;
133
+ }
134
+
135
+ setEventRecorder(recorder: PluginEventRecorder): void {
136
+ this.eventRecorder = recorder;
137
+ }
138
+
139
+ getEventRecorder(): PluginEventRecorder | undefined {
140
+ return this.eventRecorder;
141
+ }
142
+
143
+ setRuntimeDir(dir: string): void {
144
+ this.runtimeDir = dir;
145
+ }
146
+
147
+ getRuntimeDir(): string | undefined {
148
+ return this.runtimeDir;
149
+ }
150
+
151
+ /**
152
+ * Get the global readiness registry so the server-level /ready endpoint
153
+ * can aggregate plugin-contributed probes.
154
+ */
155
+ getReadinessRegistry(): CoreReadinessRegistry {
156
+ return this.readinessRegistry;
67
157
  }
68
158
 
69
159
  /**
@@ -124,22 +214,27 @@ export class PluginManager {
124
214
  pluginMetadataRegistry: this.pluginMetadataRegistry,
125
215
  cleanupHandlers: this.cleanupHandlers,
126
216
  pluginContractRegistry: this.pluginContractRegistry,
217
+ onApiRouteRegistered: () => this.resolveRoutesReady(),
127
218
  },
128
219
  });
220
+ // Defensive: if loadPlugins returned without ever calling the callback
221
+ // (e.g. zero plugins discovered and no api route registered), unblock
222
+ // the server gate anyway — by this point Hono is fully configured.
223
+ this.resolveRoutesReady();
129
224
  }
130
225
 
131
226
  /**
132
- * Deregister a plugin at runtime.
133
- * Only works for plugins with isUninstallable: true.
227
+ * In-process teardown of a plugin. Runs on EVERY instance that receives
228
+ * the deregistration broadcast (the originator AND all replicas).
229
+ *
230
+ * Strictly memory-only: clears registries, runs cleanup handlers, removes
231
+ * collectors and access rules. **Does NOT touch shared persistent state**
232
+ * (Postgres schemas, plugin_configs, plugin_artifacts, plugins rows) —
233
+ * destructive cleanup is the originator's job, see `deletePluginData`.
134
234
  */
135
- async deregisterPlugin(
136
- pluginId: string,
137
- options: DeregisterOptions
138
- ): Promise<void> {
139
- rootLogger.info(`🔄 Deregistering plugin: ${pluginId}...`);
235
+ async deregisterPluginInProcess(pluginId: string): Promise<void> {
236
+ rootLogger.info(`🔄 Deregistering plugin in-process: ${pluginId}...`);
140
237
 
141
- // 1. Emit pluginDeregistering hook locally (instance-local, not distributed)
142
- // This lets other plugins on THIS instance cleanup dependencies
143
238
  const eventBus = await this.registry.get(coreServices.eventBus, {
144
239
  pluginId: "core",
145
240
  });
@@ -148,7 +243,6 @@ export class PluginManager {
148
243
  reason: "uninstall" as const,
149
244
  });
150
245
 
151
- // 2. Run cleanup handlers (LIFO order)
152
246
  const handlers = this.cleanupHandlers.get(pluginId) || [];
153
247
  for (const handler of handlers.toReversed()) {
154
248
  try {
@@ -159,7 +253,6 @@ export class PluginManager {
159
253
  }
160
254
  this.cleanupHandlers.delete(pluginId);
161
255
 
162
- // 3. Unsubscribe from all EventBus hooks
163
256
  const subscriptions = this.hookSubscriptions.get(pluginId) || [];
164
257
  for (const unsubscribe of subscriptions) {
165
258
  try {
@@ -170,48 +263,94 @@ export class PluginManager {
170
263
  }
171
264
  this.hookSubscriptions.delete(pluginId);
172
265
 
173
- // 4. Remove from router maps and contract registry
174
266
  this.pluginRpcRouters.delete(pluginId);
175
267
  this.pluginHttpHandlers.delete(pluginId);
176
268
  this.pluginContractRegistry.delete(pluginId);
177
- rootLogger.debug(` -> Removed routers and contracts for ${pluginId}`);
269
+ this.pluginMetadataRegistry.delete(pluginId);
178
270
 
179
- // 4b. Cleanup collectors
180
- // - Remove collectors owned by this plugin
181
271
  this.collectorRegistry.unregisterByOwner(pluginId);
182
- // - Remove collectors that have no loaded strategy plugins
183
- // (exclude the current plugin being deregistered)
184
272
  const loadedPluginIds = new Set(
185
- [...this.pluginMetadataRegistry.keys()].filter((id) => id !== pluginId)
273
+ [...this.pluginMetadataRegistry.keys()].filter((id) => id !== pluginId),
186
274
  );
187
275
  this.collectorRegistry.unregisterByMissingStrategies(loadedPluginIds);
188
276
 
189
- // 5. Remove access rules from registry
190
- const beforeCount = this.registeredAccessRules.length;
191
277
  this.registeredAccessRules = this.registeredAccessRules.filter(
192
- (p) => p.pluginId !== pluginId
278
+ (p) => p.pluginId !== pluginId,
193
279
  );
194
- rootLogger.debug(
195
- ` -> Removed ${
196
- beforeCount - this.registeredAccessRules.length
197
- } access rules`
280
+
281
+ await eventBus.emit(coreHooks.pluginDeregistered, { pluginId });
282
+ rootLogger.info(`✅ Plugin deregistered in-process: ${pluginId}`);
283
+ }
284
+
285
+ /**
286
+ * Originator-only destructive cleanup.
287
+ *
288
+ * Runs AFTER `deregisterPluginInProcess` has been broadcast and acked
289
+ * across all instances. Drops the plugin's Postgres schema, deletes its
290
+ * plugin_configs rows, deletes the artifact, deletes the `plugins` row.
291
+ *
292
+ * Multiple instances must NOT call this concurrently — coordination via
293
+ * the originator's request handler is the contract.
294
+ */
295
+ async deletePluginData({
296
+ pluginIds,
297
+ bundleId,
298
+ deleteSchema,
299
+ deleteConfigs,
300
+ }: {
301
+ pluginIds: string[];
302
+ bundleId?: string | null;
303
+ deleteSchema: boolean;
304
+ deleteConfigs: boolean;
305
+ }): Promise<void> {
306
+ rootLogger.info(
307
+ `🗑 Originator: cleaning up data for ${pluginIds.join(", ")}...`,
198
308
  );
199
309
 
200
- // 6. Drop schema if requested
201
- if (options.deleteSchema) {
202
- try {
203
- const schemaName = `plugin_${pluginId}`;
204
- await db.execute(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
205
- rootLogger.info(` -> Dropped schema: ${schemaName}`);
206
- } catch (error) {
207
- rootLogger.error(`Failed to drop schema for ${pluginId}:`, error);
310
+ const { plugins, pluginConfigs } = await import("./schema");
311
+ const { inArray, eq, and } = await import("drizzle-orm");
312
+
313
+ if (deleteSchema) {
314
+ for (const pluginId of pluginIds) {
315
+ try {
316
+ const schemaName = `plugin_${pluginId}`;
317
+ await db.execute(
318
+ `DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`,
319
+ );
320
+ rootLogger.info(` -> Dropped schema: ${schemaName}`);
321
+ } catch (error) {
322
+ rootLogger.error(`Failed to drop schema for ${pluginId}:`, error);
323
+ }
208
324
  }
209
325
  }
210
326
 
211
- // 7. Emit pluginDeregistered hook (for access rule cleanup in auth-backend)
212
- await eventBus.emit(coreHooks.pluginDeregistered, { pluginId });
327
+ if (deleteConfigs) {
328
+ await db
329
+ .delete(pluginConfigs)
330
+ .where(inArray(pluginConfigs.pluginId, pluginIds));
331
+ rootLogger.debug(` -> Deleted plugin_configs rows`);
332
+ }
213
333
 
214
- rootLogger.info(`✅ Plugin deregistered: ${pluginId}`);
334
+ const artifactStore = await this.registry.get(
335
+ coreServices.pluginArtifactStore,
336
+ { pluginId: "core" },
337
+ );
338
+ if (bundleId) {
339
+ await artifactStore.deleteByBundle({ bundleId });
340
+ } else {
341
+ for (const pluginName of pluginIds) {
342
+ await artifactStore.delete({ pluginName });
343
+ }
344
+ }
345
+ rootLogger.debug(` -> Deleted plugin_artifacts rows`);
346
+
347
+ await (bundleId ? db.delete(plugins).where(eq(plugins.bundleId, bundleId)) : db.delete(plugins).where(
348
+ and(
349
+ inArray(plugins.name, pluginIds),
350
+ eq(plugins.isUninstallable, true),
351
+ ),
352
+ ));
353
+ rootLogger.info(`✅ Originator: data cleanup complete`);
215
354
  }
216
355
 
217
356
  /**
@@ -224,49 +363,39 @@ export class PluginManager {
224
363
  }
225
364
 
226
365
  /**
227
- * Request deregistration of a plugin across all instances.
228
- * This emits a broadcast hook that all instances (including this one) will receive.
229
- * Each instance then performs local cleanup via deregisterPlugin().
366
+ * Originator-side: broadcast in-process deregistration to all instances.
367
+ * Carries plugin ids only no destructive flags. Destructive ops happen
368
+ * locally on the originator after the broadcast settles
369
+ * (see `deletePluginData`).
230
370
  */
231
- async requestDeregistration(
232
- pluginId: string,
233
- options: DeregisterOptions
234
- ): Promise<void> {
235
- rootLogger.info(`📢 Broadcasting deregistration request for: ${pluginId}`);
236
-
237
- // Emit broadcast hook - all instances receive and perform local cleanup
371
+ async broadcastDeregistration(pluginIds: string[]): Promise<void> {
372
+ rootLogger.info(`📢 Broadcasting deregistration: ${pluginIds.join(", ")}`);
238
373
  const eventBus = await this.registry.get(coreServices.eventBus, {
239
374
  pluginId: "core",
240
375
  });
241
- await eventBus.emit(coreHooks.pluginDeregistrationRequested, {
242
- pluginId,
243
- deleteSchema: options.deleteSchema,
244
- });
245
-
246
- rootLogger.info(`✅ Deregistration request broadcast for: ${pluginId}`);
376
+ for (const pluginId of pluginIds) {
377
+ await eventBus.emit(coreHooks.pluginDeregistrationRequested, {
378
+ pluginId,
379
+ deleteSchema: false, // unused by listeners now — destructive ops are originator-only
380
+ });
381
+ }
247
382
  }
248
383
 
249
384
  /**
250
- * Request installation/loading of a plugin across all instances.
251
- * This emits a broadcast hook that all instances (including this one) will receive.
252
- * Each instance then loads the plugin into memory.
385
+ * Originator-side: broadcast in-process installation to all instances.
386
+ * Each receiver pulls the artifact from `plugin_artifacts` and loads.
253
387
  */
254
- async requestInstallation(
255
- pluginId: string,
256
- pluginPath: string
257
- ): Promise<void> {
258
- rootLogger.info(`📢 Broadcasting installation request for: ${pluginId}`);
259
-
260
- // Emit broadcast hook - all instances receive and load the plugin
388
+ async broadcastInstallation(pluginIds: string[]): Promise<void> {
389
+ rootLogger.info(`📢 Broadcasting installation: ${pluginIds.join(", ")}`);
261
390
  const eventBus = await this.registry.get(coreServices.eventBus, {
262
391
  pluginId: "core",
263
392
  });
264
- await eventBus.emit(coreHooks.pluginInstallationRequested, {
265
- pluginId,
266
- pluginPath,
267
- });
268
-
269
- rootLogger.info(`✅ Installation request broadcast for: ${pluginId}`);
393
+ for (const pluginId of pluginIds) {
394
+ await eventBus.emit(coreHooks.pluginInstallationRequested, {
395
+ pluginId,
396
+ pluginPath: "", // ignored — receivers resolve via plugin_artifacts
397
+ });
398
+ }
270
399
  }
271
400
 
272
401
  /**
@@ -278,26 +407,61 @@ export class PluginManager {
278
407
  pluginId: "core",
279
408
  });
280
409
 
281
- // Listen for deregistration broadcasts (from any instance)
410
+ // Listen for deregistration broadcasts (from any instance) — every
411
+ // instance does in-process teardown only. Originator separately runs
412
+ // `deletePluginData` after the broadcast settles.
282
413
  await eventBus.subscribe(
283
414
  "core",
284
415
  coreHooks.pluginDeregistrationRequested,
285
- async ({ pluginId, deleteSchema }) => {
416
+ async ({ pluginId }) => {
286
417
  rootLogger.info(`📥 Received deregistration request for: ${pluginId}`);
287
- await this.deregisterPlugin(pluginId, { deleteSchema });
288
- }
418
+ try {
419
+ await this.deregisterPluginInProcess(pluginId);
420
+ await this.eventRecorder?.record({
421
+ pluginName: pluginId,
422
+ action: "uninstall",
423
+ phase: "in-process-unload",
424
+ status: "succeeded",
425
+ });
426
+ } catch (error) {
427
+ await this.eventRecorder?.record({
428
+ pluginName: pluginId,
429
+ action: "uninstall",
430
+ phase: "in-process-unload",
431
+ status: "failed",
432
+ error: extractErrorMessage(error),
433
+ });
434
+ throw error;
435
+ }
436
+ },
289
437
  );
290
438
 
291
- // Listen for installation broadcasts (from any instance)
439
+ // Listen for installation broadcasts (from any instance) — every
440
+ // instance hydrates the artifact (if not already on disk) then loads.
292
441
  await eventBus.subscribe(
293
442
  "core",
294
443
  coreHooks.pluginInstallationRequested,
295
- async ({ pluginId, pluginPath }) => {
296
- rootLogger.info(
297
- `📥 Received installation request for: ${pluginId} at ${pluginPath}`
298
- );
299
- await this.loadSinglePlugin(pluginId, pluginPath);
300
- }
444
+ async ({ pluginId }) => {
445
+ rootLogger.info(`📥 Received installation request for: ${pluginId}`);
446
+ try {
447
+ await this.hydrateAndLoadPlugin(pluginId);
448
+ await this.eventRecorder?.record({
449
+ pluginName: pluginId,
450
+ action: "install",
451
+ phase: "in-process-load",
452
+ status: "succeeded",
453
+ });
454
+ } catch (error) {
455
+ await this.eventRecorder?.record({
456
+ pluginName: pluginId,
457
+ action: "install",
458
+ phase: "in-process-load",
459
+ status: "failed",
460
+ error: extractErrorMessage(error),
461
+ });
462
+ throw error;
463
+ }
464
+ },
301
465
  );
302
466
 
303
467
  rootLogger.debug("🔗 Lifecycle listeners registered");
@@ -316,51 +480,106 @@ export class PluginManager {
316
480
  }
317
481
 
318
482
  /**
319
- * Load a single plugin at runtime (for dynamic installation).
320
- * If the plugin isn't available locally, it will be installed via npm first.
321
- * This imports the plugin module, registers it, and initializes it.
483
+ * Resolve a plugin's `plugins` row, fetch its artifact from the artifact
484
+ * store (or original `PluginSource` as a fallback), install it into the
485
+ * runtime dir if not present, and run `loadSinglePlugin`. Used by both
486
+ * the installation broadcast handler AND the fresh-instance bootstrap
487
+ * step in `loadPlugins`.
488
+ */
489
+ async hydrateAndLoadPlugin(pluginId: string): Promise<void> {
490
+ const { plugins: pluginsTable } = await import("./schema");
491
+ const { eq } = await import("drizzle-orm");
492
+
493
+ const rows = await db
494
+ .select()
495
+ .from(pluginsTable)
496
+ .where(eq(pluginsTable.name, pluginId))
497
+ .limit(1);
498
+
499
+ if (rows.length === 0) {
500
+ throw new Error(`Plugin '${pluginId}' has no row in 'plugins' table`);
501
+ }
502
+ const row = rows[0];
503
+
504
+ // Fast path: monorepo-local plugin, just load by package name / path.
505
+ if (!row.isUninstallable) {
506
+ await this.loadSinglePlugin(pluginId, row.path);
507
+ return;
508
+ }
509
+
510
+ if (!this.runtimeDir) {
511
+ throw new Error(
512
+ `Runtime plugin dir not configured — call setRuntimeDir before hydrating runtime plugins.`,
513
+ );
514
+ }
515
+
516
+ const pkgDir = path.join(this.runtimeDir, "node_modules", pluginId);
517
+ if (!fs.existsSync(path.join(pkgDir, "package.json"))) {
518
+ // Module not installed yet — pull from artifact store and install.
519
+ const artifactStore = await this.registry.get(
520
+ coreServices.pluginArtifactStore,
521
+ { pluginId: "core" },
522
+ );
523
+ const artifact = await artifactStore.fetch({
524
+ pluginName: pluginId,
525
+ version: row.version,
526
+ });
527
+ if (!artifact) {
528
+ throw new Error(
529
+ `No tarball found in plugin_artifacts for ${pluginId}@${row.version}. ` +
530
+ `The plugin row exists but its artifact is missing — re-install from the original source.`,
531
+ );
532
+ }
533
+ const allowInstallScripts =
534
+ (row.metadata as { checkstack?: { allowInstallScripts?: boolean } })
535
+ ?.checkstack?.allowInstallScripts === true;
536
+
537
+ const installerRegistry = await this.registry.get(
538
+ coreServices.pluginInstallerRegistry,
539
+ { pluginId: "core" },
540
+ );
541
+ // Any installer's installFromArtifact does the same thing (delegates
542
+ // to the shared install-from-tarball helper) — pick npm.
543
+ await installerRegistry
544
+ .forSource("npm")
545
+ .installFromArtifact({
546
+ tarball: artifact.tarball,
547
+ pluginName: pluginId,
548
+ allowInstallScripts,
549
+ });
550
+ }
551
+
552
+ await this.loadSinglePlugin(pluginId, pkgDir);
553
+ }
554
+
555
+ /**
556
+ * Register + initialize an installed plugin module.
322
557
  *
323
- * @param pluginId - The plugin ID (package name)
324
- * @param pluginPath - The expected path (may not exist yet on this instance)
558
+ * Pre-condition: the package must already be importable (either monorepo
559
+ * source via the workspace, or installed under `runtime_plugins/node_modules`
560
+ * by `hydrateAndLoadPlugin`).
325
561
  */
326
562
  async loadSinglePlugin(pluginId: string, pluginPath: string): Promise<void> {
327
563
  rootLogger.info(`🔌 Loading plugin at runtime: ${pluginId}`);
328
564
 
329
- // Emit instance-local installing hook
330
565
  const eventBus = await this.registry.get(coreServices.eventBus, {
331
566
  pluginId: "core",
332
567
  });
333
568
  await eventBus.emitLocal(coreHooks.pluginInstalling, { pluginId });
334
569
 
335
570
  try {
336
- // 1. Try to import the plugin - if it fails, install it first
337
571
  let pluginModule;
338
572
 
339
573
  try {
340
- // Try importing by package name first
341
574
  pluginModule = await import(pluginId);
342
575
  } catch {
343
576
  try {
344
- // Try importing by path
345
577
  pluginModule = await import(pluginPath);
346
- } catch {
347
- // Plugin not available locally - need to install it
348
- rootLogger.info(
349
- ` -> Plugin ${pluginId} not found locally, installing via npm...`
350
- );
351
-
352
- const installer = await this.registry.get(
353
- coreServices.pluginInstaller,
354
- { pluginId: "core" }
578
+ } catch (error) {
579
+ throw new Error(
580
+ `Plugin ${pluginId} module not available locally — call hydrateAndLoadPlugin instead, or check that the plugin is correctly installed.`,
581
+ { cause: error },
355
582
  );
356
- const result = await installer.install(pluginId);
357
-
358
- // Now try importing again
359
- try {
360
- pluginModule = await import(result.name);
361
- } catch {
362
- pluginModule = await import(result.path);
363
- }
364
583
  }
365
584
  }
366
585
 
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { Hono } from "hono";
3
+ import { TrieRouter } from "hono/router/trie-router";
4
+
5
+ /**
6
+ * Regression test for the "Hono router was already initialized" bug.
7
+ *
8
+ * Hono's default SmartRouter freezes its matcher on first request: any later
9
+ * `app.get/all/...` throws "Can not add a route since the matcher is already
10
+ * built". Plugins register routes during init() (and at runtime via
11
+ * loadSinglePlugin), so we use TrieRouter — which is incremental. If anyone
12
+ * ever swaps the router back to default, this test fails fast.
13
+ *
14
+ * See core/backend/src/index.ts where TrieRouter is wired up.
15
+ */
16
+ describe("Hono router (TrieRouter) supports incremental route registration", () => {
17
+ it("accepts routes added after the first request", async () => {
18
+ const app = new Hono({ router: new TrieRouter() });
19
+ app.get("/early", (c) => c.text("early"));
20
+
21
+ // Trigger matcher build (this is what freezes SmartRouter).
22
+ const r1 = await app.fetch(new Request("http://x/early"));
23
+ expect(await r1.text()).toBe("early");
24
+
25
+ // Add a route AFTER the matcher is "built". On SmartRouter this throws.
26
+ app.get("/late", (c) => c.text("late"));
27
+
28
+ const r2 = await app.fetch(new Request("http://x/late"));
29
+ expect(r2.status).toBe(200);
30
+ expect(await r2.text()).toBe("late");
31
+ });
32
+
33
+ it("accepts a parameterized route added after a request", async () => {
34
+ const app = new Hono({ router: new TrieRouter() });
35
+ app.get("/seed", (c) => c.text("seed"));
36
+ await app.fetch(new Request("http://x/seed"));
37
+
38
+ // This is the actual production scenario: /api/:pluginId/* is registered
39
+ // inside loadPlugins() during init, well after the first request may have
40
+ // already been handled.
41
+ app.all("/api/:pluginId/*", (c) =>
42
+ c.json({ pluginId: c.req.param("pluginId") }),
43
+ );
44
+
45
+ const r = await app.fetch(new Request("http://x/api/healthcheck/foo"));
46
+ expect(r.status).toBe(200);
47
+ expect(await r.json()).toEqual({ pluginId: "healthcheck" });
48
+ });
49
+ });