@checkstack/backend 0.9.1 → 0.10.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.
- package/CHANGELOG.md +65 -0
- package/package.json +12 -12
- package/src/index.ts +7 -2
- package/src/openapi-router.ts +38 -19
- package/src/plugin-manager/api-router.ts +268 -125
- package/src/plugin-manager/plugin-loader.ts +17 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
# @checkstack/backend
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9016526: Add a `/rest/:pluginId/*` HTTP mount that serves every plugin's oRPC contract
|
|
8
|
+
through the REST/OpenAPI shape described by `/api/openapi.json`. Queries are
|
|
9
|
+
`GET` with query parameters, mutations are `POST` with the input as the raw
|
|
10
|
+
JSON body. The existing `/api/:pluginId/*` mount continues to serve oRPC's
|
|
11
|
+
native wire protocol unchanged, so existing clients are not affected.
|
|
12
|
+
|
|
13
|
+
The OpenAPI spec at `/api/openapi.json` now reflects the real mount: every
|
|
14
|
+
`paths` entry is prefixed with `/rest` instead of `/api`.
|
|
15
|
+
|
|
16
|
+
Also fixes a SPA-fallback bug: the backend's `/api-docs` route previously
|
|
17
|
+
returned 404 on production deployments because the static-file middleware
|
|
18
|
+
skipped any path starting with `/api`, capturing `/api-docs` along with real
|
|
19
|
+
API routes. The skip now requires a trailing slash (`/api/`, `/rest/`).
|
|
20
|
+
|
|
21
|
+
Required access rules are now visible in the API Docs UI. The OpenAPI spec
|
|
22
|
+
generator was reading a non-existent `accessRules` field on procedure
|
|
23
|
+
metadata; the real field is `access: AccessRule[]`. Each procedure's access
|
|
24
|
+
rules are now flattened to fully-qualified IDs (e.g. `catalog.system.read`)
|
|
25
|
+
and emitted under `x-orpc-meta.accessRules`, which the existing
|
|
26
|
+
`Required Access Rules` section in the docs UI already knew how to render.
|
|
27
|
+
|
|
28
|
+
The API Docs schema renderer now handles record types (zod `z.record`),
|
|
29
|
+
`$ref`s into `components.schemas`, `oneOf`/`anyOf`/`allOf`, nullable union
|
|
30
|
+
types (`type: ["string", "null"]`), and `format` qualifiers. Previously
|
|
31
|
+
record outputs like `{ statuses: object }` masked the actual value type;
|
|
32
|
+
they now render as `{ [key]: <ResolvedType> { ... } }` with the inner
|
|
33
|
+
schema expanded, capped at 12 levels with cycle detection.
|
|
34
|
+
|
|
35
|
+
**REST method conventions.** `proc()` now defaults to `GET` for queries and
|
|
36
|
+
`POST` for mutations on the `/rest` mount, using bracket-notation query
|
|
37
|
+
params (`?filter[status]=active&ids[0]=a`) for GET inputs. Existing
|
|
38
|
+
procedures were updated to follow REST semantics:
|
|
39
|
+
|
|
40
|
+
- `update*` mutations → `PATCH`
|
|
41
|
+
- `delete*` / `remove*` mutations → `DELETE`
|
|
42
|
+
- `getBulk*` queries and any query taking a large array input → `POST`
|
|
43
|
+
(because `@orpc/openapi@1.13.x` has no GET→POST URL-length fallback)
|
|
44
|
+
|
|
45
|
+
GET endpoints require an `object` input — bare scalars like
|
|
46
|
+
`.input(z.string())` are not valid on GET. `getSystemConfigurations` was
|
|
47
|
+
refactored from `.input(z.string())` to `.input(z.object({ systemId: ... }))`
|
|
48
|
+
to fit the GET shape; the only call-site update was the in-process router
|
|
49
|
+
unpacking `input.systemId` instead of passing `input` directly.
|
|
50
|
+
|
|
51
|
+
The API Docs UI now renders query parameters (path/query/header/cookie) in a
|
|
52
|
+
dedicated table for GET endpoints, and the fetch example shows them in the
|
|
53
|
+
URL with `<required>` / `<optional>` placeholders.
|
|
54
|
+
|
|
55
|
+
### Patch Changes
|
|
56
|
+
|
|
57
|
+
- Updated dependencies [9016526]
|
|
58
|
+
- @checkstack/common@0.10.0
|
|
59
|
+
- @checkstack/auth-common@0.7.0
|
|
60
|
+
- @checkstack/api-docs-common@0.1.13
|
|
61
|
+
- @checkstack/backend-api@0.15.2
|
|
62
|
+
- @checkstack/pluginmanager-common@0.2.2
|
|
63
|
+
- @checkstack/signal-backend@0.2.5
|
|
64
|
+
- @checkstack/signal-common@0.2.3
|
|
65
|
+
- @checkstack/cache-api@0.3.1
|
|
66
|
+
- @checkstack/queue-api@0.3.1
|
|
67
|
+
|
|
3
68
|
## 0.9.1
|
|
4
69
|
|
|
5
70
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"checkstack": {
|
|
6
6
|
"type": "backend"
|
|
@@ -14,16 +14,16 @@
|
|
|
14
14
|
"lint:code": "eslint . --max-warnings 0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/api-docs-common": "0.1.
|
|
18
|
-
"@checkstack/auth-common": "0.6.
|
|
19
|
-
"@checkstack/backend-api": "0.15.
|
|
20
|
-
"@checkstack/common": "0.
|
|
17
|
+
"@checkstack/api-docs-common": "0.1.12",
|
|
18
|
+
"@checkstack/auth-common": "0.6.6",
|
|
19
|
+
"@checkstack/backend-api": "0.15.1",
|
|
20
|
+
"@checkstack/common": "0.9.0",
|
|
21
21
|
"@checkstack/drizzle-helper": "0.0.5",
|
|
22
|
-
"@checkstack/cache-api": "0.
|
|
23
|
-
"@checkstack/queue-api": "0.
|
|
24
|
-
"@checkstack/signal-backend": "0.2.
|
|
25
|
-
"@checkstack/signal-common": "0.2.
|
|
26
|
-
"@checkstack/pluginmanager-common": "0.2.
|
|
22
|
+
"@checkstack/cache-api": "0.3.0",
|
|
23
|
+
"@checkstack/queue-api": "0.3.0",
|
|
24
|
+
"@checkstack/signal-backend": "0.2.4",
|
|
25
|
+
"@checkstack/signal-common": "0.2.2",
|
|
26
|
+
"@checkstack/pluginmanager-common": "0.2.1",
|
|
27
27
|
"@hono/zod-validator": "^0.7.6",
|
|
28
28
|
"@orpc/client": "^1.13.14",
|
|
29
29
|
"@orpc/contract": "^1.13.14",
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"@types/bun": "latest",
|
|
46
46
|
"@types/semver": "^7.5.0",
|
|
47
47
|
"@checkstack/tsconfig": "0.0.7",
|
|
48
|
-
"@checkstack/scripts": "0.3.
|
|
49
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
48
|
+
"@checkstack/scripts": "0.3.1",
|
|
49
|
+
"@checkstack/test-utils-backend": "0.1.25",
|
|
50
50
|
"drizzle-kit": "^0.31.10"
|
|
51
51
|
}
|
|
52
52
|
}
|
package/src/index.ts
CHANGED
|
@@ -335,8 +335,13 @@ if (frontendDistPath && fs.existsSync(frontendDistPath)) {
|
|
|
335
335
|
// Serve root-level static files (e.g., /favicon.svg) from the dist directory
|
|
336
336
|
// before the SPA fallback, so they don't get caught by the index.html handler
|
|
337
337
|
app.get("*", async (c, next) => {
|
|
338
|
-
// Skip API and WebSocket routes - let them pass through to actual handlers
|
|
339
|
-
|
|
338
|
+
// Skip API and WebSocket routes - let them pass through to actual handlers.
|
|
339
|
+
// The trailing slash matters: `/api-docs` is a frontend route and must hit
|
|
340
|
+
// the SPA fallback below, while `/api/...` and `/rest/...` go to backend
|
|
341
|
+
// handlers (oRPC RPC + OpenAPI REST mounts).
|
|
342
|
+
const apiPath =
|
|
343
|
+
c.req.path.startsWith("/api/") || c.req.path.startsWith("/rest/");
|
|
344
|
+
if (apiPath) {
|
|
340
345
|
return next();
|
|
341
346
|
}
|
|
342
347
|
|
package/src/openapi-router.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
|
|
10
10
|
import type { AnyContractRouter } from "@orpc/contract";
|
|
11
11
|
import type { PluginManager } from "./plugin-manager";
|
|
12
12
|
import type { AuthService } from "@checkstack/backend-api";
|
|
13
|
+
import type { AccessRule } from "@checkstack/common";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Check if a user has a specific access rule.
|
|
@@ -25,40 +26,57 @@ function hasAccess(
|
|
|
25
26
|
);
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Shape of the metadata we surface in the OpenAPI spec as `x-orpc-meta`.
|
|
31
|
+
* `accessRules` are the fully-qualified access rule IDs (e.g.
|
|
32
|
+
* `"catalog.system.read"`), flattened from each procedure's `access`
|
|
33
|
+
* AccessRule[] so doc consumers don't need to know the AccessRule shape.
|
|
34
|
+
*/
|
|
35
|
+
interface ExposedProcedureMeta {
|
|
36
|
+
userType?: string;
|
|
37
|
+
accessRules?: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
/**
|
|
29
41
|
* Extract procedure metadata from a contract using oRPC internal structure.
|
|
42
|
+
* Returns the raw `meta` block stored on the contract procedure builder.
|
|
30
43
|
*/
|
|
31
|
-
function
|
|
44
|
+
function extractRawProcedureMeta(
|
|
32
45
|
contract: unknown
|
|
33
|
-
): { userType?: string;
|
|
46
|
+
): { userType?: string; access?: AccessRule[] } | undefined {
|
|
34
47
|
const orpcData = (contract as Record<string, unknown>)?.["~orpc"] as
|
|
35
|
-
| { meta?: { userType?: string;
|
|
48
|
+
| { meta?: { userType?: string; access?: AccessRule[] } }
|
|
36
49
|
| undefined;
|
|
37
50
|
return orpcData?.meta;
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
/**
|
|
41
|
-
* Build a lookup map of operationId -> metadata from all contracts.
|
|
42
|
-
* operationId format: "pluginId.procedureName"
|
|
54
|
+
* Build a lookup map of operationId -> exposed metadata from all contracts.
|
|
55
|
+
* operationId format: "pluginId.procedureName".
|
|
56
|
+
*
|
|
57
|
+
* The procedure metadata stores `access` as AccessRule[]; we flatten it to
|
|
58
|
+
* `accessRules: string[]` of qualified IDs (`{pluginId}.{ruleId}`) so the
|
|
59
|
+
* OpenAPI consumer (API docs UI, third-party clients) gets a plain list.
|
|
43
60
|
*/
|
|
44
61
|
function buildMetadataLookup(
|
|
45
62
|
contracts: Map<string, AnyContractRouter>
|
|
46
|
-
): Map<string,
|
|
47
|
-
const lookup = new Map<
|
|
48
|
-
string,
|
|
49
|
-
{ userType?: string; accessRules?: string[] }
|
|
50
|
-
>();
|
|
63
|
+
): Map<string, ExposedProcedureMeta> {
|
|
64
|
+
const lookup = new Map<string, ExposedProcedureMeta>();
|
|
51
65
|
|
|
52
66
|
for (const [pluginId, contract] of contracts) {
|
|
53
|
-
// Contract is an object with procedure names as keys
|
|
54
67
|
for (const [procedureName, procedure] of Object.entries(
|
|
55
68
|
contract as Record<string, unknown>
|
|
56
69
|
)) {
|
|
57
|
-
const
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
const raw = extractRawProcedureMeta(procedure);
|
|
71
|
+
if (!raw) continue;
|
|
72
|
+
|
|
73
|
+
const accessRules = raw.access?.map((rule) => `${pluginId}.${rule.id}`);
|
|
74
|
+
|
|
75
|
+
const operationId = `${pluginId}.${procedureName}`;
|
|
76
|
+
lookup.set(operationId, {
|
|
77
|
+
userType: raw.userType,
|
|
78
|
+
...(accessRules && accessRules.length > 0 ? { accessRules } : {}),
|
|
79
|
+
});
|
|
62
80
|
}
|
|
63
81
|
}
|
|
64
82
|
|
|
@@ -107,13 +125,14 @@ export async function generateOpenApiSpec({
|
|
|
107
125
|
>;
|
|
108
126
|
};
|
|
109
127
|
|
|
110
|
-
// Post-process: Add x-orpc-meta to each operation and prefix paths with /
|
|
128
|
+
// Post-process: Add x-orpc-meta to each operation and prefix paths with /rest.
|
|
129
|
+
// The REST handler is mounted at /rest/:pluginId/* (see api-router.ts);
|
|
130
|
+
// /api/:pluginId/* serves oRPC's native wire protocol, not REST.
|
|
111
131
|
if (spec.paths) {
|
|
112
132
|
const prefixedPaths: typeof spec.paths = {};
|
|
113
133
|
|
|
114
134
|
for (const [path, methods] of Object.entries(spec.paths)) {
|
|
115
|
-
|
|
116
|
-
const prefixedPath = `/api${path.startsWith("/") ? path : `/${path}`}`;
|
|
135
|
+
const prefixedPath = `/rest${path.startsWith("/") ? path : `/${path}`}`;
|
|
117
136
|
prefixedPaths[prefixedPath] = methods;
|
|
118
137
|
|
|
119
138
|
// Add metadata to each operation
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Hono, Context } from "hono";
|
|
2
2
|
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
3
3
|
import { RPCHandler } from "@orpc/server/fetch";
|
|
4
|
+
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
|
4
5
|
import {
|
|
5
6
|
coreServices,
|
|
6
7
|
AuthService,
|
|
@@ -23,9 +24,182 @@ import type { PluginMetadata } from "@checkstack/common";
|
|
|
23
24
|
import { rootLogger } from "../logger";
|
|
24
25
|
import { extractErrorMessage } from "@checkstack/common";
|
|
25
26
|
|
|
27
|
+
interface RouteHandlerDeps {
|
|
28
|
+
registry: ServiceRegistry;
|
|
29
|
+
pluginRpcRouters: Map<string, unknown>;
|
|
30
|
+
pluginMetadataRegistry: Map<string, PluginMetadata>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ResolvedRequestContext {
|
|
34
|
+
context: RpcContext;
|
|
35
|
+
logger: Logger;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type ContextResult =
|
|
39
|
+
| { ok: true; resolved: ResolvedRequestContext }
|
|
40
|
+
| { ok: false; response: Response };
|
|
41
|
+
|
|
42
|
+
function createServiceGetter(registry: ServiceRegistry) {
|
|
43
|
+
return async function getService<T>(ref: {
|
|
44
|
+
id: string;
|
|
45
|
+
T: T;
|
|
46
|
+
}): Promise<T | undefined> {
|
|
47
|
+
try {
|
|
48
|
+
return await registry.get(ref, { pluginId: "core" });
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve auth + core services and build the per-request RpcContext.
|
|
57
|
+
* Shared between the oRPC `/api/*` handler and the REST `/rest/*` handler.
|
|
58
|
+
*/
|
|
59
|
+
async function resolveRequestContext({
|
|
60
|
+
c,
|
|
61
|
+
pluginId,
|
|
62
|
+
deps,
|
|
63
|
+
}: {
|
|
64
|
+
c: Context;
|
|
65
|
+
pluginId: string;
|
|
66
|
+
deps: RouteHandlerDeps;
|
|
67
|
+
}): Promise<ContextResult> {
|
|
68
|
+
const getService = createServiceGetter(deps.registry);
|
|
69
|
+
const pathname = new URL(c.req.raw.url).pathname;
|
|
70
|
+
|
|
71
|
+
const logger = await getService(coreServices.logger);
|
|
72
|
+
const auth = await getService(coreServices.auth);
|
|
73
|
+
const db = await getService(coreServices.database);
|
|
74
|
+
const fetch = await getService(coreServices.fetch);
|
|
75
|
+
const healthCheckRegistry = await getService(
|
|
76
|
+
coreServices.healthCheckRegistry,
|
|
77
|
+
);
|
|
78
|
+
const collectorRegistry = await getService(coreServices.collectorRegistry);
|
|
79
|
+
const queuePluginRegistry = await getService(
|
|
80
|
+
coreServices.queuePluginRegistry,
|
|
81
|
+
);
|
|
82
|
+
const queueManager = await getService(coreServices.queueManager);
|
|
83
|
+
const cachePluginRegistry = await getService(
|
|
84
|
+
coreServices.cachePluginRegistry,
|
|
85
|
+
);
|
|
86
|
+
const cacheManager = await getService(coreServices.cacheManager);
|
|
87
|
+
const eventBus = await getService(coreServices.eventBus);
|
|
88
|
+
|
|
89
|
+
if (
|
|
90
|
+
!auth ||
|
|
91
|
+
!logger ||
|
|
92
|
+
!db ||
|
|
93
|
+
!fetch ||
|
|
94
|
+
!healthCheckRegistry ||
|
|
95
|
+
!collectorRegistry ||
|
|
96
|
+
!queuePluginRegistry ||
|
|
97
|
+
!queueManager ||
|
|
98
|
+
!cachePluginRegistry ||
|
|
99
|
+
!cacheManager ||
|
|
100
|
+
!eventBus
|
|
101
|
+
) {
|
|
102
|
+
const missing = [
|
|
103
|
+
!auth && "auth",
|
|
104
|
+
!logger && "logger",
|
|
105
|
+
!db && "db",
|
|
106
|
+
!fetch && "fetch",
|
|
107
|
+
!healthCheckRegistry && "healthCheckRegistry",
|
|
108
|
+
!collectorRegistry && "collectorRegistry",
|
|
109
|
+
!queuePluginRegistry && "queuePluginRegistry",
|
|
110
|
+
!queueManager && "queueManager",
|
|
111
|
+
!cachePluginRegistry && "cachePluginRegistry",
|
|
112
|
+
!cacheManager && "cacheManager",
|
|
113
|
+
!eventBus && "eventBus",
|
|
114
|
+
]
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.join(", ");
|
|
117
|
+
(logger ?? rootLogger).error(
|
|
118
|
+
`${pathname}: core services not initialized — missing: ${missing}`,
|
|
119
|
+
);
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
response: c.json({ error: "Core services not initialized" }, 500),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const user = await (auth as AuthService).authenticate(c.req.raw);
|
|
127
|
+
|
|
128
|
+
const emitHook: EmitHookFn = async <T>(hook: Hook<T>, payload: T) => {
|
|
129
|
+
await (eventBus as EventBus).emit(hook, payload);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const pluginMetadata: PluginMetadata | undefined =
|
|
133
|
+
deps.pluginMetadataRegistry.get(pluginId);
|
|
134
|
+
|
|
135
|
+
if (!pluginMetadata) {
|
|
136
|
+
(logger as Logger).error(
|
|
137
|
+
`${pathname}: no plugin metadata registered for pluginId='${pluginId}'. ` +
|
|
138
|
+
`Regular plugins populate this during register(); core routers must call ` +
|
|
139
|
+
`pluginManager.registerCorePluginMetadata().`,
|
|
140
|
+
);
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
response: c.json({ error: "Plugin metadata not found in registry" }, 500),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const context: RpcContext = {
|
|
148
|
+
pluginMetadata,
|
|
149
|
+
auth: auth as AuthService,
|
|
150
|
+
logger: logger as Logger,
|
|
151
|
+
db: db as SafeDatabase<Record<string, unknown>>,
|
|
152
|
+
fetch: fetch as Fetch,
|
|
153
|
+
healthCheckRegistry: healthCheckRegistry as HealthCheckRegistry,
|
|
154
|
+
collectorRegistry: collectorRegistry as CollectorRegistry,
|
|
155
|
+
queuePluginRegistry: queuePluginRegistry as QueuePluginRegistry,
|
|
156
|
+
queueManager: queueManager as QueueManager,
|
|
157
|
+
cachePluginRegistry: cachePluginRegistry as CachePluginRegistry,
|
|
158
|
+
cacheManager: cacheManager as CacheManager,
|
|
159
|
+
user,
|
|
160
|
+
emitHook,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return { ok: true, resolved: { context, logger: logger as Logger } };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildRpcRouter(
|
|
167
|
+
pluginRpcRouters: Map<string, unknown>,
|
|
168
|
+
): Record<string, unknown> {
|
|
169
|
+
const rootRpcRouter: Record<string, unknown> = {};
|
|
170
|
+
for (const [pluginId, router] of pluginRpcRouters.entries()) {
|
|
171
|
+
rootRpcRouter[pluginId] = router;
|
|
172
|
+
}
|
|
173
|
+
return rootRpcRouter;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function logHandlerError({
|
|
177
|
+
error,
|
|
178
|
+
pathname,
|
|
179
|
+
logger,
|
|
180
|
+
protocolLabel,
|
|
181
|
+
}: {
|
|
182
|
+
error: unknown;
|
|
183
|
+
pathname: string;
|
|
184
|
+
logger: Logger | undefined;
|
|
185
|
+
protocolLabel: string;
|
|
186
|
+
}) {
|
|
187
|
+
const target = (logger ?? rootLogger) as Logger;
|
|
188
|
+
target.error(
|
|
189
|
+
`${protocolLabel} ${pathname} failed: ${extractErrorMessage(error)}`,
|
|
190
|
+
);
|
|
191
|
+
const stack =
|
|
192
|
+
error !== null && typeof error === "object" && "stack" in error
|
|
193
|
+
? (error as { stack: string }).stack
|
|
194
|
+
: undefined;
|
|
195
|
+
if (stack) {
|
|
196
|
+
target.error(`Stack trace: ${stack}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
26
200
|
/**
|
|
27
201
|
* Creates the API route handler for Hono.
|
|
28
|
-
*
|
|
202
|
+
* Serves oRPC's native RPC wire protocol at /api/:pluginId/*.
|
|
29
203
|
*/
|
|
30
204
|
export function createApiRouteHandler({
|
|
31
205
|
registry,
|
|
@@ -38,54 +212,36 @@ export function createApiRouteHandler({
|
|
|
38
212
|
pluginHttpHandlers: Map<string, (req: Request) => Promise<Response>>;
|
|
39
213
|
pluginMetadataRegistry: Map<string, PluginMetadata>;
|
|
40
214
|
}) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
try {
|
|
47
|
-
return await registry.get(ref, { pluginId: "core" });
|
|
48
|
-
} catch {
|
|
49
|
-
return undefined;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
215
|
+
const deps: RouteHandlerDeps = {
|
|
216
|
+
registry,
|
|
217
|
+
pluginRpcRouters,
|
|
218
|
+
pluginMetadataRegistry,
|
|
219
|
+
};
|
|
52
220
|
|
|
53
221
|
return async function handleApiRequest(c: Context) {
|
|
54
|
-
// Extract pluginId from Hono path parameter (/api/:pluginId/*)
|
|
55
222
|
const pluginId = c.req.param("pluginId") || "";
|
|
56
223
|
const pathname = new URL(c.req.raw.url).pathname;
|
|
57
224
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
for (const [pluginId, router] of pluginRpcRouters.entries()) {
|
|
62
|
-
rootRpcRouter[pluginId] = router;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Resolve logger first for use in interceptor
|
|
66
|
-
const logger = await getService(coreServices.logger);
|
|
225
|
+
const result = await resolveRequestContext({ c, pluginId, deps });
|
|
226
|
+
if (!result.ok) return result.response;
|
|
227
|
+
const { context, logger } = result.resolved;
|
|
67
228
|
|
|
68
229
|
const rpcHandler = new RPCHandler(
|
|
69
|
-
|
|
230
|
+
buildRpcRouter(pluginRpcRouters) as ConstructorParameters<
|
|
231
|
+
typeof RPCHandler
|
|
232
|
+
>[0],
|
|
70
233
|
{
|
|
71
234
|
interceptors: [
|
|
72
235
|
async ({ next, ...rest }) => {
|
|
73
236
|
try {
|
|
74
237
|
return await next(rest);
|
|
75
238
|
} catch (error) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
typeof error === "object" &&
|
|
83
|
-
"stack" in error
|
|
84
|
-
? (error as { stack: string }).stack
|
|
85
|
-
: undefined;
|
|
86
|
-
if (stack) {
|
|
87
|
-
target.error(`Stack trace: ${stack}`);
|
|
88
|
-
}
|
|
239
|
+
logHandlerError({
|
|
240
|
+
error,
|
|
241
|
+
pathname,
|
|
242
|
+
logger,
|
|
243
|
+
protocolLabel: "RPC",
|
|
244
|
+
});
|
|
89
245
|
throw error;
|
|
90
246
|
}
|
|
91
247
|
},
|
|
@@ -93,93 +249,6 @@ export function createApiRouteHandler({
|
|
|
93
249
|
},
|
|
94
250
|
);
|
|
95
251
|
|
|
96
|
-
// Resolve core services for RPC context
|
|
97
|
-
const auth = await getService(coreServices.auth);
|
|
98
|
-
const db = await getService(coreServices.database);
|
|
99
|
-
const fetch = await getService(coreServices.fetch);
|
|
100
|
-
const healthCheckRegistry = await getService(
|
|
101
|
-
coreServices.healthCheckRegistry,
|
|
102
|
-
);
|
|
103
|
-
const collectorRegistry = await getService(coreServices.collectorRegistry);
|
|
104
|
-
const queuePluginRegistry = await getService(
|
|
105
|
-
coreServices.queuePluginRegistry,
|
|
106
|
-
);
|
|
107
|
-
const queueManager = await getService(coreServices.queueManager);
|
|
108
|
-
const cachePluginRegistry = await getService(
|
|
109
|
-
coreServices.cachePluginRegistry,
|
|
110
|
-
);
|
|
111
|
-
const cacheManager = await getService(coreServices.cacheManager);
|
|
112
|
-
const eventBus = await getService(coreServices.eventBus);
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
!auth ||
|
|
116
|
-
!logger ||
|
|
117
|
-
!db ||
|
|
118
|
-
!fetch ||
|
|
119
|
-
!healthCheckRegistry ||
|
|
120
|
-
!collectorRegistry ||
|
|
121
|
-
!queuePluginRegistry ||
|
|
122
|
-
!queueManager ||
|
|
123
|
-
!cachePluginRegistry ||
|
|
124
|
-
!cacheManager ||
|
|
125
|
-
!eventBus
|
|
126
|
-
) {
|
|
127
|
-
const missing = [
|
|
128
|
-
!auth && "auth",
|
|
129
|
-
!logger && "logger",
|
|
130
|
-
!db && "db",
|
|
131
|
-
!fetch && "fetch",
|
|
132
|
-
!healthCheckRegistry && "healthCheckRegistry",
|
|
133
|
-
!collectorRegistry && "collectorRegistry",
|
|
134
|
-
!queuePluginRegistry && "queuePluginRegistry",
|
|
135
|
-
!queueManager && "queueManager",
|
|
136
|
-
!cachePluginRegistry && "cachePluginRegistry",
|
|
137
|
-
!cacheManager && "cacheManager",
|
|
138
|
-
!eventBus && "eventBus",
|
|
139
|
-
].filter(Boolean).join(", ");
|
|
140
|
-
(logger ?? rootLogger).error(
|
|
141
|
-
`${pathname}: core services not initialized — missing: ${missing}`,
|
|
142
|
-
);
|
|
143
|
-
return c.json({ error: "Core services not initialized" }, 500);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const user = await (auth as AuthService).authenticate(c.req.raw);
|
|
147
|
-
|
|
148
|
-
// Create emitHook function using eventBus
|
|
149
|
-
const emitHook: EmitHookFn = async <T>(hook: Hook<T>, payload: T) => {
|
|
150
|
-
await (eventBus as EventBus).emit(hook, payload);
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
// Lookup plugin metadata from registry
|
|
154
|
-
const pluginMetadata: PluginMetadata | undefined =
|
|
155
|
-
pluginMetadataRegistry.get(pluginId);
|
|
156
|
-
|
|
157
|
-
if (!pluginMetadata) {
|
|
158
|
-
(logger as Logger).error(
|
|
159
|
-
`${pathname}: no plugin metadata registered for pluginId='${pluginId}'. ` +
|
|
160
|
-
`Regular plugins populate this during register(); core routers must call ` +
|
|
161
|
-
`pluginManager.registerCorePluginMetadata().`,
|
|
162
|
-
);
|
|
163
|
-
return c.json({ error: "Plugin metadata not found in registry" }, 500);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const context: RpcContext = {
|
|
167
|
-
pluginMetadata,
|
|
168
|
-
auth: auth as AuthService,
|
|
169
|
-
logger: logger as Logger,
|
|
170
|
-
db: db as SafeDatabase<Record<string, unknown>>,
|
|
171
|
-
fetch: fetch as Fetch,
|
|
172
|
-
healthCheckRegistry: healthCheckRegistry as HealthCheckRegistry,
|
|
173
|
-
collectorRegistry: collectorRegistry as CollectorRegistry,
|
|
174
|
-
queuePluginRegistry: queuePluginRegistry as QueuePluginRegistry,
|
|
175
|
-
queueManager: queueManager as QueueManager,
|
|
176
|
-
cachePluginRegistry: cachePluginRegistry as CachePluginRegistry,
|
|
177
|
-
cacheManager: cacheManager as CacheManager,
|
|
178
|
-
user,
|
|
179
|
-
emitHook,
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
// 1. Try oRPC first
|
|
183
252
|
const { matched, response } = await rpcHandler.handle(c.req.raw, {
|
|
184
253
|
prefix: "/api",
|
|
185
254
|
context,
|
|
@@ -191,8 +260,7 @@ export function createApiRouteHandler({
|
|
|
191
260
|
|
|
192
261
|
logger.debug(`RPC mismatch for: ${c.req.method} ${pathname}`);
|
|
193
262
|
|
|
194
|
-
//
|
|
195
|
-
// Sort by path length (descending) to ensure more specific paths are tried first
|
|
263
|
+
// Fall through to native plugin HTTP handlers (sorted longest-prefix first)
|
|
196
264
|
const sortedHandlers = [...pluginHttpHandlers.entries()].toSorted(
|
|
197
265
|
function (a, b) {
|
|
198
266
|
return b[0].length - a[0].length;
|
|
@@ -209,6 +277,71 @@ export function createApiRouteHandler({
|
|
|
209
277
|
};
|
|
210
278
|
}
|
|
211
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Creates the REST route handler for Hono.
|
|
282
|
+
* Serves the REST/OpenAPI shape of the same oRPC contract at /rest/:pluginId/*.
|
|
283
|
+
* Standard JSON bodies / query params work here — matches /api/openapi.json.
|
|
284
|
+
*/
|
|
285
|
+
export function createRestRouteHandler({
|
|
286
|
+
registry,
|
|
287
|
+
pluginRpcRouters,
|
|
288
|
+
pluginMetadataRegistry,
|
|
289
|
+
}: {
|
|
290
|
+
registry: ServiceRegistry;
|
|
291
|
+
pluginRpcRouters: Map<string, unknown>;
|
|
292
|
+
pluginMetadataRegistry: Map<string, PluginMetadata>;
|
|
293
|
+
}) {
|
|
294
|
+
const deps: RouteHandlerDeps = {
|
|
295
|
+
registry,
|
|
296
|
+
pluginRpcRouters,
|
|
297
|
+
pluginMetadataRegistry,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
return async function handleRestRequest(c: Context) {
|
|
301
|
+
const pluginId = c.req.param("pluginId") || "";
|
|
302
|
+
const pathname = new URL(c.req.raw.url).pathname;
|
|
303
|
+
|
|
304
|
+
const result = await resolveRequestContext({ c, pluginId, deps });
|
|
305
|
+
if (!result.ok) return result.response;
|
|
306
|
+
const { context, logger } = result.resolved;
|
|
307
|
+
|
|
308
|
+
const restHandler = new OpenAPIHandler(
|
|
309
|
+
buildRpcRouter(pluginRpcRouters) as ConstructorParameters<
|
|
310
|
+
typeof OpenAPIHandler
|
|
311
|
+
>[0],
|
|
312
|
+
{
|
|
313
|
+
interceptors: [
|
|
314
|
+
async ({ next, ...rest }) => {
|
|
315
|
+
try {
|
|
316
|
+
return await next(rest);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
logHandlerError({
|
|
319
|
+
error,
|
|
320
|
+
pathname,
|
|
321
|
+
logger,
|
|
322
|
+
protocolLabel: "REST",
|
|
323
|
+
});
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
},
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const { matched, response } = await restHandler.handle(c.req.raw, {
|
|
332
|
+
prefix: "/rest",
|
|
333
|
+
context,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
if (matched) {
|
|
337
|
+
return c.newResponse(response.body, response);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
logger.debug(`REST mismatch for: ${c.req.method} ${pathname}`);
|
|
341
|
+
return c.json({ error: "Not Found" }, 404);
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
212
345
|
/**
|
|
213
346
|
* Registers the /api/:pluginId/* route with Hono.
|
|
214
347
|
*/
|
|
@@ -218,3 +351,13 @@ export function registerApiRoute(
|
|
|
218
351
|
) {
|
|
219
352
|
rootRouter.all("/api/:pluginId/*", handler);
|
|
220
353
|
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Registers the /rest/:pluginId/* route with Hono.
|
|
357
|
+
*/
|
|
358
|
+
export function registerRestRoute(
|
|
359
|
+
rootRouter: Hono,
|
|
360
|
+
handler: ReturnType<typeof createRestRouteHandler>,
|
|
361
|
+
) {
|
|
362
|
+
rootRouter.all("/rest/:pluginId/*", handler);
|
|
363
|
+
}
|
|
@@ -29,7 +29,12 @@ import {
|
|
|
29
29
|
} from "../utils/plugin-discovery";
|
|
30
30
|
import type { InitCallback, PendingInit } from "./types";
|
|
31
31
|
import { sortPlugins } from "./dependency-sorter";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
createApiRouteHandler,
|
|
34
|
+
createRestRouteHandler,
|
|
35
|
+
registerApiRoute,
|
|
36
|
+
registerRestRoute,
|
|
37
|
+
} from "./api-router";
|
|
33
38
|
import type { ExtensionPointManager } from "./extension-points";
|
|
34
39
|
import { Router } from "@orpc/server";
|
|
35
40
|
import { AnyContractRouter } from "@orpc/contract";
|
|
@@ -302,7 +307,10 @@ export async function loadPlugins({
|
|
|
302
307
|
const sortedIds = sortPlugins({ pendingInits, providedBy, logger });
|
|
303
308
|
rootLogger.debug(`✅ Initialization Order: ${sortedIds.join(" -> ")}`);
|
|
304
309
|
|
|
305
|
-
// Register /api/*
|
|
310
|
+
// Register /api/* (oRPC wire format) and /rest/* (REST/OpenAPI shape) routes
|
|
311
|
+
// BEFORE plugin initialization. Both routes share the same plugin RPC routers
|
|
312
|
+
// and per-request context; they differ only in how the handler decodes the
|
|
313
|
+
// request and matches it to a procedure.
|
|
306
314
|
const apiHandler = createApiRouteHandler({
|
|
307
315
|
registry: deps.registry,
|
|
308
316
|
pluginRpcRouters: deps.pluginRpcRouters,
|
|
@@ -311,6 +319,13 @@ export async function loadPlugins({
|
|
|
311
319
|
});
|
|
312
320
|
registerApiRoute(rootRouter, apiHandler);
|
|
313
321
|
|
|
322
|
+
const restHandler = createRestRouteHandler({
|
|
323
|
+
registry: deps.registry,
|
|
324
|
+
pluginRpcRouters: deps.pluginRpcRouters,
|
|
325
|
+
pluginMetadataRegistry: deps.pluginMetadataRegistry,
|
|
326
|
+
});
|
|
327
|
+
registerRestRoute(rootRouter, restHandler);
|
|
328
|
+
|
|
314
329
|
// Routes are now registered on the root router. Signal readiness so the
|
|
315
330
|
// server can stop blocking incoming requests in `waitForInit()`. We open
|
|
316
331
|
// the gate here (BEFORE Phase 2 / Phase 3) so that:
|