@valentinkolb/cloud 0.3.1 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valentinkolb/cloud",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Modular Hono+SolidJS framework for building per-app docker services behind a dynamic gateway. Powers cloud.stuve-ulm.de.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,8 +36,6 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@fontsource-variable/jetbrains-mono": "^5.2.8",
39
- "@scalar/hono-api-reference": "^0.10.9",
40
- "@scalar/openapi-to-markdown": "^0.5.6",
41
39
  "@tabler/icons-webfont": "^3.36.1",
42
40
  "@tailwindcss/typography": "^0.5.19",
43
41
  "@valentinkolb/ssr": "0.9.0",
@@ -8,6 +8,7 @@ import { createConfig as createSsrConfig } from "@valentinkolb/ssr";
8
8
  import { createSSRHandler, routes } from "@valentinkolb/ssr/hono";
9
9
  import { Hono } from "hono";
10
10
  import { serveStatic } from "hono/bun";
11
+ import { generateSpecs } from "hono-openapi";
11
12
  import type { SsrConfig } from "@valentinkolb/ssr";
12
13
  import type {
13
14
  AppMeta,
@@ -107,6 +108,23 @@ export type AppOptions<S extends AppSettingsMap = {}> = {
107
108
  * prefix-trie from these strings.
108
109
  */
109
110
  routes: readonly string[];
111
+ /**
112
+ * Gateway-relative URL at which this app's OpenAPI 3.x JSON spec is
113
+ * served, e.g. `"/api/notebooks/openapi.json"`. Opt-in: only set this
114
+ * for apps whose api router is documented with `middleware.openapi()`
115
+ * (i.e. `describeRoute()`) and worth surfacing in the api-docs aggregator.
116
+ *
117
+ * Pair this with `app.start({ openapi: <api router> })` — `defineApp`
118
+ * generates the spec from that router at boot, mounts it on the
119
+ * framework server (before the user's fetch, so it bypasses any
120
+ * auth/rate-limit middleware), and advertises the URL via the registry
121
+ * so `app-api-docs` picks it up automatically.
122
+ *
123
+ * The path must be reachable through the gateway — usually that means
124
+ * the standard form `"/api/<id>/openapi.json"` (covered by the
125
+ * `/api/<id>` entry in `routes`).
126
+ */
127
+ openapi?: string;
110
128
  /**
111
129
  * Project root used by the SSR plugin to discover island/client files.
112
130
  * Defaults to `process.cwd()`. Override only if you run the entrypoint
@@ -134,6 +152,18 @@ export type StartOptions = {
134
152
  * might register.
135
153
  */
136
154
  fetch: (req: Request) => Response | Promise<Response>;
155
+ /**
156
+ * Hono router to scan for OpenAPI route metadata. When set together with
157
+ * `defineApp({ openapi: "..." })`, the framework generates an OpenAPI
158
+ * spec from this router and mounts it at the configured URL on the
159
+ * framework server (before the user's fetch, so the spec stays public).
160
+ *
161
+ * Pass the BARE api router — the one with `describeRoute()` annotations.
162
+ * `generateSpecs` walks the route tree without executing middleware, so
163
+ * the inner auth/rate-limit `.use(...)` calls don't matter for spec
164
+ * generation; they only run if the router is hit by an actual request.
165
+ */
166
+ openapi?: Hono<any>;
137
167
  lifecycle?: AppLifecycle;
138
168
  capabilities?: AppCapabilities;
139
169
  port?: number;
@@ -258,6 +288,7 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
258
288
  nav: opts.nav,
259
289
  legalLinks: opts.legalLinks ? [...opts.legalLinks] : undefined,
260
290
  widgets: opts.widgets ? opts.widgets.map((w) => ({ ...w })) : undefined,
291
+ openapi: opts.openapi,
261
292
  };
262
293
 
263
294
  // ── 3. start() — builds and boots the Hono server ────────────────────
@@ -266,6 +297,11 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
266
297
  const baseUrl = opts.baseUrl;
267
298
  const log = logger("app");
268
299
 
300
+ // OpenAPI advertised in the registry only when there's a router to
301
+ // derive the spec from. The mount block lower down uses the same
302
+ // flag so the registry never points at a URL that 404s.
303
+ const advertiseOpenapi = !!(opts.openapi && startOpts.openapi);
304
+
269
305
  // Registry entry
270
306
  const entry: AppRegistryEntry = {
271
307
  id: meta.id,
@@ -294,6 +330,7 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
294
330
  : undefined,
295
331
  legalLinks: meta.legalLinks ? meta.legalLinks.map((l) => ({ ...l })) : undefined,
296
332
  widgets: meta.widgets ? meta.widgets.map((w) => ({ ...w })) : undefined,
333
+ openapi: advertiseOpenapi ? opts.openapi : undefined,
297
334
  };
298
335
 
299
336
  // Heartbeat
@@ -306,11 +343,13 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
306
343
  // watcher is a module-level singleton, only one runs per process.
307
344
  await ensureRuntimeWatcher();
308
345
 
309
- // Build Hono server. Framework owns three mounts (registered first so
346
+ // Build Hono server. Framework owns these mounts (registered first so
310
347
  // they take precedence over any catch-all in the user's fetch):
311
348
  // /_ssr/* island chunks (SSR adapter)
312
349
  // /public/* serveStatic + terminal 404
313
350
  // /api/_internal/search only when capabilities.search is declared
351
+ // <opts.openapi> OpenAPI JSON spec, when both opts.openapi
352
+ // and startOpts.openapi are set
314
353
  const ssrMountPath = config.basePath ? `${config.basePath}/_ssr` : "/_ssr";
315
354
 
316
355
  const server = new Hono()
@@ -336,6 +375,32 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
336
375
  });
337
376
  }
338
377
 
378
+ // OpenAPI spec mount. Registered on the framework server (before the
379
+ // user-fetch catch-all below) so it bypasses any auth / rate-limit
380
+ // middleware on the api router — the spec must stay reachable without
381
+ // a session for `app-api-docs` to render it.
382
+ //
383
+ // The `servers` override is load-bearing: hono-openapi walks the
384
+ // BARE api router and emits paths relative to its own root (e.g.
385
+ // `/{id}`, `/{id}/notes`), without the `/api/<id>` prefix it ends
386
+ // up under in the user's outer router. We derive that prefix from
387
+ // `opts.openapi` (everything before the trailing `/openapi.json`)
388
+ // so combined Scalar URLs resolve to the real public paths.
389
+ if (advertiseOpenapi) {
390
+ const apiPrefix = opts.openapi!.replace(/\/openapi\.json$/, "") || "/";
391
+ const spec = await generateSpecs(startOpts.openapi!, {
392
+ documentation: {
393
+ info: {
394
+ title: meta.name,
395
+ version: "0.0.1",
396
+ description: meta.description,
397
+ },
398
+ servers: [{ url: apiPrefix }],
399
+ },
400
+ });
401
+ server.get(opts.openapi!, (c) => c.json(spec));
402
+ }
403
+
339
404
  // User's fetch handles everything else. The framework doesn't inject any
340
405
  // context vars here — the user's router is expected to register the
341
406
  // middlewares it needs (middleware.runtime, middleware.settings, …).
@@ -33,6 +33,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[]): CloudRunt
33
33
  searchHelp: e.search?.help,
34
34
  searchTagHelp: e.search?.tagHelp,
35
35
  legalLinks: e.legalLinks ? e.legalLinks.map((l) => ({ ...l })) : undefined,
36
+ openapi: e.openapi,
36
37
  }),
37
38
  ),
38
39
  });
package/src/api/index.ts CHANGED
@@ -8,13 +8,13 @@
8
8
  * Apps that need a typed client to these routes import from
9
9
  * `@valentinkolb/cloud/clients/core`. The client and the routes share their
10
10
  * type via `CoreApiType` below.
11
+ *
12
+ * The OpenAPI spec for these routes is generated by `defineApp` (driven by
13
+ * core's `openapi: "/api/openapi.json"` opt-in) — this file no longer mounts
14
+ * any docs UI; the api-docs aggregator at `/app/api-docs` is the only consumer.
11
15
  */
12
16
  import { Hono } from "hono";
13
- import { Scalar } from "@scalar/hono-api-reference";
14
- import { generateSpecs } from "hono-openapi";
15
17
  import { prettyJSON } from "hono/pretty-json";
16
- import { createMarkdownFromOpenApi } from "@scalar/openapi-to-markdown";
17
- import { openApiMeta } from "../server";
18
18
  import authRoutes from "./auth";
19
19
  import meRoutes from "./me";
20
20
  import adminLifecycleRoutes from "./admin-lifecycle";
@@ -41,26 +41,11 @@ const buildCoreApi = () => {
41
41
  export type CoreApiType = ReturnType<typeof buildCoreApi>;
42
42
 
43
43
  /**
44
- * Build the core router and accompanying OpenAPI assets. The core-app calls
45
- * this and mounts the returned router under `/api`.
44
+ * Build the core router. The core-app calls this and mounts the returned
45
+ * router under `/api`.
46
46
  */
47
- export const createCoreApiRouter = async () => {
47
+ export const createCoreApiRouter = () => {
48
48
  const api = buildCoreApi();
49
-
50
- const spec = await generateSpecs(api, openApiMeta);
51
- const llmsTxt = await createMarkdownFromOpenApi(JSON.stringify(spec));
52
-
53
- api.get("/openapi.json", (c) => c.json(spec));
54
- api.get(
55
- "/docs",
56
- Scalar({
57
- theme: "saturn",
58
- url: "/api/openapi.json",
59
- hideClientButton: true,
60
- }),
61
- );
62
-
63
49
  api.all("/*", (c) => c.json({ message: "API route not found" }, 404));
64
-
65
- return { api, llmsTxt };
50
+ return { api };
66
51
  };
@@ -43,6 +43,8 @@ export type AppMeta = {
43
43
  * silently skip rendering for the current user.
44
44
  */
45
45
  widgets?: WidgetEndpoint[];
46
+ /** Gateway-relative URL where this app's OpenAPI JSON is served, or undefined. */
47
+ openapi?: string;
46
48
  };
47
49
 
48
50
  export type WidgetEndpoint = {
@@ -47,4 +47,6 @@ export type AppRegistryEntry = {
47
47
  search?: AppRegistrySearch;
48
48
  legalLinks?: AppRegistryLegalLink[];
49
49
  widgets?: AppRegistryWidget[];
50
+ /** Gateway-relative URL where this app serves its OpenAPI JSON spec. */
51
+ openapi?: string;
50
52
  };