@valentinkolb/cloud 0.2.0 → 0.3.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/README.md CHANGED
@@ -68,10 +68,10 @@ A standard app declares four prefixes (`/api/<id>`, `/app/<id>`, `/admin/<id>`,
68
68
 
69
69
  ## Documentation
70
70
 
71
- Full walkthroughs, the per-app anatomy, deployment templates, and a reference app live in the repo:
71
+ Full walkthroughs, the per-app anatomy, deployment templates, and a reference app:
72
72
 
73
- - **[APPS.md](https://github.com/ValentinKolb/cloud/blob/main/APPS.md)** — end-to-end app authoring guide
74
- - **[github.com/ValentinKolb/cloud](https://github.com/ValentinKolb/cloud)** — monorepo, docker images, prod compose template
73
+ - **[github.com/ValentinKolb/cloud-template](https://github.com/ValentinKolb/cloud-template)** — starter repo with a working reference app + the complete app-authoring guide
74
+ - **[github.com/ValentinKolb/cloud](https://github.com/ValentinKolb/cloud)** — the platform monorepo (gateway, core, all platform apps)
75
75
 
76
76
  ## License
77
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valentinkolb/cloud",
3
- "version": "0.2.0",
3
+ "version": "0.3.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": {
@@ -39,9 +39,11 @@
39
39
  "@scalar/hono-api-reference": "^0.10.9",
40
40
  "@scalar/openapi-to-markdown": "^0.5.6",
41
41
  "@tabler/icons-webfont": "^3.36.1",
42
+ "@tailwindcss/typography": "^0.5.19",
42
43
  "@valentinkolb/ssr": "0.9.0",
43
44
  "@valentinkolb/stdlib": "0.3.0",
44
45
  "@valentinkolb/sync": "^5.0.0",
46
+ "bun-plugin-tailwind": "^0.1.2",
45
47
  "hono": "^4.11.1",
46
48
  "hono-openapi": "^1.1.2",
47
49
  "katex": "^0.16.28",
@@ -51,19 +53,17 @@
51
53
  "nodemailer": "^7.0.12",
52
54
  "sanitize-html": "^2.17.0",
53
55
  "solid-js": "^1.9.10",
56
+ "tailwindcss": "^4.1.18",
54
57
  "zod": "^4.3.4"
55
58
  },
56
59
  "devDependencies": {
57
60
  "@babel/core": "^7.28.5",
58
61
  "@babel/preset-typescript": "^7.28.5",
59
- "@tailwindcss/typography": "^0.5.19",
60
62
  "@types/bun": "1.3.9",
61
63
  "@types/mustache": "^4.2.6",
62
64
  "@types/nodemailer": "^7.0.5",
63
65
  "@types/sanitize-html": "^2.16.0",
64
66
  "babel-preset-solid": "^1.9.10",
65
- "bun-plugin-tailwind": "^0.1.2",
66
- "tailwindcss": "^4.1.18",
67
67
  "typescript": "^5.9.3"
68
68
  }
69
69
  }
@@ -20,14 +20,12 @@ import type { AppRegistryEntry } from "../contracts/registry";
20
20
  import type { Role } from "../contracts/shared";
21
21
  import type { AppSettingsMap, KindToType } from "../contracts/settings-types";
22
22
  import { createSettingsAPI, type SettingsAPI } from "../services/settings/api";
23
- import { loadSnapshot } from "../services/settings/snapshot";
24
23
  import { registerSettings, type SettingDef } from "../services/settings/defaults";
25
24
  import { auth } from "../server/middleware/auth";
26
25
  import { logger } from "../services/logging";
27
26
  import { get, set, loadCache as loadSettingsCache } from "../services/settings";
28
- import { appRegistry, listApps } from "./registry";
29
27
  import { createHeartbeat } from "./heartbeat";
30
- import { buildRuntimeFromRegistry } from "./runtime-context";
28
+ import { ensureRuntimeWatcher, getCurrentRuntime, stopRuntimeWatcher } from "./runtime-watcher";
31
29
 
32
30
  /** Cache-busting version stamp — changes on every server start / rebuild. */
33
31
  const v = Date.now();
@@ -119,16 +117,23 @@ export type AppOptions<S extends AppSettingsMap = {}> = {
119
117
 
120
118
  export type StartOptions = {
121
119
  /**
122
- * Single Hono instance mounted at `/` of the app's container. The app owns
123
- * its full URL space routes are written with their absolute paths
124
- * (`/api/<id>`, `/app/<id>`, `/admin/<id>`, …), matching what the app
125
- * declared in `defineApp({ routes: [...] })`.
120
+ * Web-standard fetch handler. Mounted at `/` of the app's container.
121
+ * Typically you pass a Hono instance's `.fetch`:
126
122
  *
127
- * Framework-owned mounts (`/_ssr/*`, `/public/*`, `/api/_internal/search`)
128
- * register before this router so they take precedence — apps can ignore
129
- * those paths.
123
+ * const router = new Hono<AuthContext>()
124
+ * .use("*", middleware.runtime())
125
+ * .use("*", middleware.settings())
126
+ * .route("/api/<id>", apiRoutes)
127
+ * .route("/app/<id>", pageRoutes);
128
+ *
129
+ * app.start({ fetch: router.fetch });
130
+ *
131
+ * The framework owns `/_ssr/*`, `/public/*`, and `/api/_internal/search`
132
+ * (the last only when `capabilities.search` is set) and registers them
133
+ * before this fetch — they take precedence over any catch-all the app
134
+ * might register.
130
135
  */
131
- router: Hono<any>;
136
+ fetch: (req: Request) => Response | Promise<Response>;
132
137
  lifecycle?: AppLifecycle;
133
138
  capabilities?: AppCapabilities;
134
139
  port?: number;
@@ -296,47 +301,19 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
296
301
  await heartbeat.start();
297
302
  log.info(`Registered "${meta.id}"`, { baseUrl });
298
303
 
299
- // Runtime context (navigation, app discovery)
300
- const ac = new AbortController();
301
- let currentRuntime = buildRuntimeFromRegistry(await listApps());
304
+ // Runtime context start the registry watcher so middleware.runtime() and
305
+ // the lifecycle context below see populated cluster state. Idempotent: the
306
+ // watcher is a module-level singleton, only one runs per process.
307
+ await ensureRuntimeWatcher();
302
308
 
303
- const refreshRuntime = async () => {
304
- currentRuntime = buildRuntimeFromRegistry(await listApps());
305
- };
306
-
307
- (async () => {
308
- try {
309
- const snap = await appRegistry.snapshot({ prefix: "apps/" });
310
- for await (const ev of appRegistry.reader({ prefix: "apps/", after: snap.cursor }).stream({ signal: ac.signal })) {
311
- if (ev.type === "overflow") {
312
- await refreshRuntime();
313
- continue;
314
- }
315
- await refreshRuntime();
316
- }
317
- } catch (err) {
318
- if (err instanceof Error && err.name === "AbortError") return;
319
- log.error("Registry watcher failed", { error: err instanceof Error ? err.message : String(err) });
320
- }
321
- })();
322
-
323
- // Build Hono server
309
+ // Build Hono server. Framework owns three mounts (registered first so
310
+ // they take precedence over any catch-all in the user's fetch):
311
+ // /_ssr/* island chunks (SSR adapter)
312
+ // /public/* serveStatic + terminal 404
313
+ // /api/_internal/search only when capabilities.search is declared
324
314
  const ssrMountPath = config.basePath ? `${config.basePath}/_ssr` : "/_ssr";
325
315
 
326
- // Per-request settings snapshot: skip static-asset paths so each /public,
327
- // /branding, /favicon, /_ssr request isn't paying the snapshot cost.
328
- const SNAPSHOT_SKIP_PREFIXES = ["/public/", "/_ssr/", "/branding/", "/favicon"];
329
-
330
316
  const server = new Hono()
331
- .use("*", async (c, next) => {
332
- (c as any).set("runtime", currentRuntime);
333
- const path = c.req.path;
334
- const skip = SNAPSHOT_SKIP_PREFIXES.some((p) => path.startsWith(p));
335
- if (!skip) {
336
- (c as any).set("settings", await loadSnapshot());
337
- }
338
- await next();
339
- })
340
317
  .route(ssrMountPath, routes(config))
341
318
  .use("/public/*", serveStatic({
342
319
  root: "./",
@@ -346,11 +323,9 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
346
323
  }))
347
324
  // serveStatic calls next() on miss — terminate /public/* here so a
348
325
  // missing asset is a clean 404 instead of falling through to the app
349
- // router (which might render an HTML page for the missing path).
326
+ // fetch (which might render an HTML page for the missing path).
350
327
  .all("/public/*", (c) => c.notFound());
351
328
 
352
- // Framework-internal endpoints register BEFORE the app router so they
353
- // take precedence over any catch-all the app might mount.
354
329
  if (startOpts.capabilities?.search) {
355
330
  const searchRun = startOpts.capabilities.search.run;
356
331
  server.post("/api/_internal/search", auth.requireRole("authenticated"), async (c) => {
@@ -361,13 +336,16 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
361
336
  });
362
337
  }
363
338
 
364
- server.route("/", startOpts.router);
339
+ // User's fetch handles everything else. The framework doesn't inject any
340
+ // context vars here — the user's router is expected to register the
341
+ // middlewares it needs (middleware.runtime, middleware.settings, …).
342
+ server.all("*", (c) => Promise.resolve(startOpts.fetch(c.req.raw)));
365
343
 
366
344
  // Lifecycle
367
345
  const cloudCtx: CloudContext = {
368
346
  logger,
369
347
  settings: { get, set },
370
- runtime: currentRuntime,
348
+ runtime: getCurrentRuntime(),
371
349
  };
372
350
 
373
351
  if (!startOpts.skipSetup && startOpts.lifecycle?.setup) {
@@ -389,7 +367,7 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
389
367
  stopping = true;
390
368
  log.info(`Stopping: ${meta.id}`);
391
369
  try { if (startOpts.lifecycle?.stop) await startOpts.lifecycle.stop(cloudCtx); } catch {}
392
- ac.abort();
370
+ stopRuntimeWatcher();
393
371
  await heartbeat.stop();
394
372
  };
395
373
 
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Module-level singleton for the live cluster registry snapshot.
3
+ *
4
+ * `defineApp().start()` and the `middleware.runtime()` factory both call
5
+ * `ensureRuntimeWatcher()` — the first call subscribes to the Redis
6
+ * registry and starts refreshing on every event; subsequent calls are
7
+ * no-ops. Reads happen via `getCurrentRuntime()`.
8
+ *
9
+ * One process = one app = one watcher; lives until `stopRuntimeWatcher()`
10
+ * (called from defineApp's shutdown handler) or process exit.
11
+ */
12
+ import { logger } from "../services/logging";
13
+ import type { CloudRuntime } from "../contracts/app";
14
+ import { appRegistry, listApps } from "./registry";
15
+ import { buildRuntimeFromRegistry } from "./runtime-context";
16
+
17
+ const log = logger("runtime-watcher");
18
+
19
+ let current: CloudRuntime | undefined;
20
+ let started = false;
21
+ let abort: AbortController | undefined;
22
+
23
+ const refresh = async () => {
24
+ current = buildRuntimeFromRegistry(await listApps());
25
+ };
26
+
27
+ export const ensureRuntimeWatcher = async (): Promise<void> => {
28
+ if (started) return;
29
+ started = true;
30
+ await refresh();
31
+ abort = new AbortController();
32
+ void (async () => {
33
+ try {
34
+ const snap = await appRegistry.snapshot({ prefix: "apps/" });
35
+ for await (const _ev of appRegistry
36
+ .reader({ prefix: "apps/", after: snap.cursor })
37
+ .stream({ signal: abort.signal })) {
38
+ await refresh();
39
+ }
40
+ } catch (err) {
41
+ if (err instanceof Error && err.name === "AbortError") return;
42
+ log.error("Registry watcher failed", { error: err instanceof Error ? err.message : String(err) });
43
+ }
44
+ })();
45
+ };
46
+
47
+ export const stopRuntimeWatcher = (): void => {
48
+ abort?.abort();
49
+ abort = undefined;
50
+ started = false;
51
+ current = undefined;
52
+ };
53
+
54
+ export const getCurrentRuntime = (): CloudRuntime => {
55
+ if (!current) {
56
+ throw new Error(
57
+ "Runtime not initialized — register middleware.runtime() in your router or call ensureRuntimeWatcher() during setup",
58
+ );
59
+ }
60
+ return current;
61
+ };
@@ -1,47 +1,45 @@
1
- import { auth } from "./auth";
2
- import { imageResponse, jsonResponse, openApiMeta, requiresAdmin, requiresAuth, requiresIpa, requiresIpaUser, requiresUser } from "./openapi";
1
+ /**
2
+ * The `middleware` namespace bundles the request-lifecycle primitives
3
+ * that every cloud app composes into its own router. Apps register
4
+ * what they need; the framework no longer injects anything implicitly.
5
+ *
6
+ * import { middleware, auth } from "@valentinkolb/cloud/server"
7
+ *
8
+ * const router = new Hono<AuthContext>()
9
+ * .use("*", middleware.logger())
10
+ * .use("*", middleware.runtime()) // for Layout / Sidebar / dashboard / search
11
+ * .use("*", middleware.settings()) // for c.get("settings")
12
+ * .use("*", middleware.ratelimit())
13
+ * .use(auth.requireRole("user"))
14
+ * .post(
15
+ * "/",
16
+ * middleware.validator("json", Schema),
17
+ * middleware.openapi({ tags: ["foo"], summary: "Create" }),
18
+ * handler,
19
+ * )
20
+ *
21
+ * `auth` lives separately because it has its own surface
22
+ * (requireRole, redirectToLogin, session.*) and is conceptually
23
+ * orthogonal to the request lifecycle.
24
+ */
25
+ import { describeRoute } from "hono-openapi";
3
26
  import { rateLimit } from "./rate-limit";
4
27
  import { requestLogger } from "./request-logger";
5
- import { validator, v } from "./validator";
28
+ import { runtime } from "./runtime";
29
+ import { settings } from "./settings";
30
+ import { validator } from "./validator";
6
31
 
7
32
  export const middleware = {
8
- get auth() {
9
- return auth;
10
- },
11
- get jsonResponse() {
12
- return jsonResponse;
13
- },
14
- get imageResponse() {
15
- return imageResponse;
16
- },
17
- get openApiMeta() {
18
- return openApiMeta;
19
- },
20
- get requiresAuth() {
21
- return requiresAuth;
22
- },
23
- get requiresAdmin() {
24
- return requiresAdmin;
25
- },
26
- get requiresIpa() {
27
- return requiresIpa;
28
- },
29
- get requiresIpaUser() {
30
- return requiresIpaUser;
31
- },
32
- get requiresUser() {
33
- return requiresUser;
34
- },
35
- get rateLimit() {
36
- return rateLimit;
37
- },
38
- get requestLogger() {
39
- return requestLogger;
40
- },
41
- get validator() {
42
- return validator;
43
- },
44
- get v() {
45
- return v;
46
- },
33
+ /** Live cluster registry on `c.get("runtime")`. Required for Layout/Sidebar. */
34
+ runtime,
35
+ /** Frozen per-request settings snapshot on `c.get("settings")`. */
36
+ settings,
37
+ /** HTTP request logger (logs 5xx as error, 429 as warn, 401/403 as info). */
38
+ logger: () => requestLogger,
39
+ /** Sliding-window rate limiter, keyed by user id (auto fallback to IP). */
40
+ ratelimit: rateLimit,
41
+ /** Zod input validator. `c.req.valid(target)` is fully typed afterward. */
42
+ validator,
43
+ /** OpenAPI route metadata — re-export of hono-openapi's `describeRoute`. */
44
+ openapi: describeRoute,
47
45
  } as const;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Per-request middleware that exposes the live cluster registry on
3
+ * `c.get("runtime")`. The first call to this middleware kicks off the
4
+ * Redis registry watcher (idempotent); subsequent requests read from
5
+ * the in-memory snapshot.
6
+ *
7
+ * Required by anything that renders the framework's `<Layout>`,
8
+ * `<Sidebar>`, dashboard widget aggregator, or `Cmd+K` global search.
9
+ */
10
+ import { createMiddleware } from "hono/factory";
11
+ import { ensureRuntimeWatcher, getCurrentRuntime } from "../../_internal/runtime-watcher";
12
+
13
+ export const runtime = () =>
14
+ createMiddleware(async (c, next) => {
15
+ await ensureRuntimeWatcher();
16
+ (c as unknown as { set: (k: string, v: unknown) => void }).set("runtime", getCurrentRuntime());
17
+ await next();
18
+ });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Per-request middleware that exposes a frozen settings snapshot on
3
+ * `c.get("settings")`. Backed by Redis cache-aside (5-minute TTL),
4
+ * so the per-request cost is a single Redis read at most.
5
+ *
6
+ * Required by anything that reads typed settings inside an HTTP
7
+ * handler via `c.get("settings").<key>`.
8
+ *
9
+ * `skipPrefixes` defaults to `["/public/", "/_ssr/", "/branding/", "/favicon"]`
10
+ * — those paths never read settings, so skipping the snapshot load
11
+ * keeps static-asset requests free. Apps that mount settings only on
12
+ * /api or /app can avoid the option entirely by scoping the
13
+ * `.use()` path instead.
14
+ */
15
+ import { createMiddleware } from "hono/factory";
16
+ import { loadSnapshot } from "../../services/settings/snapshot";
17
+
18
+ const DEFAULT_SKIP = ["/public/", "/_ssr/", "/branding/", "/favicon"] as const;
19
+
20
+ export const settings = (opts?: { skipPrefixes?: readonly string[] }) => {
21
+ const skip = opts?.skipPrefixes ?? DEFAULT_SKIP;
22
+ return createMiddleware(async (c, next) => {
23
+ const path = c.req.path;
24
+ if (!skip.some((p) => path.startsWith(p))) {
25
+ (c as unknown as { set: (k: string, v: unknown) => void }).set("settings", await loadSnapshot());
26
+ }
27
+ await next();
28
+ });
29
+ };