@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
|
|
71
|
+
Full walkthroughs, the per-app anatomy, deployment templates, and a reference app:
|
|
72
72
|
|
|
73
|
-
- **[
|
|
74
|
-
- **[github.com/ValentinKolb/cloud](https://github.com/ValentinKolb/cloud)** — monorepo,
|
|
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.
|
|
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 {
|
|
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
|
-
*
|
|
123
|
-
*
|
|
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
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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
|
-
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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 {
|
|
28
|
+
import { runtime } from "./runtime";
|
|
29
|
+
import { settings } from "./settings";
|
|
30
|
+
import { validator } from "./validator";
|
|
6
31
|
|
|
7
32
|
export const middleware = {
|
|
8
|
-
get
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
};
|