@valentinkolb/cloud 0.1.2 → 0.2.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.1.2",
3
+ "version": "0.2.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": {
package/scripts/build.ts CHANGED
@@ -1,13 +1,21 @@
1
1
  /**
2
- * Production build for a single app.
2
+ * Production build for a single cloud app.
3
3
  *
4
- * APP_ID=<id> bun run packages/cloud/scripts/build.ts
4
+ * Two consumer shapes:
5
5
  *
6
- * Output goes to /<workspace-root>/dist:
6
+ * Monorepo (this repo):
7
+ * APP_ID=<id> bun run packages/cloud/scripts/build.ts
8
+ * # appDir defaults to packages/<APP_ID>, run from workspace root.
9
+ *
10
+ * Standalone (npm consumer, see cloud-template):
11
+ * APP_ID=<id> APP_DIR=src bun run node_modules/@valentinkolb/cloud/scripts/build.ts
12
+ * # appDir = APP_DIR (resolved against cwd).
13
+ *
14
+ * Output goes to <cwd>/dist:
7
15
  * server.js bundled Bun entry
8
16
  * _ssr/<island>.js hydration bundles (auto-emitted by the SSR plugin)
9
17
  * public/<id>/app.css Tailwind, if the app has src/styles/app.css
10
- * public/<id>/... anything from packages/<id>/public/
18
+ * public/<id>/... anything from <appDir>/public/
11
19
  *
12
20
  * If the app needs additional artefacts (core's global.css, logo, katex),
13
21
  * it ships a `scripts/build-extras.ts` that this script runs at the end.
@@ -19,21 +27,23 @@ import { fileURLToPath } from "node:url";
19
27
  import tailwind from "bun-plugin-tailwind";
20
28
  import { Glob, CryptoHasher } from "bun";
21
29
 
22
- // Mirrors @valentinkolb/ssr's island-id (md5 of POSIX path relative to the
23
- // SSR plugin's rootDir, truncated to 12 chars). define-app sets rootDir to
24
- // the `packages` directory.
25
- const ssrRootDir = "packages";
26
- const islandId = (file: string): string => {
27
- const rel = file.slice(root.length + 1 + ssrRootDir.length + 1).replace(/\\/g, "/");
28
- return new CryptoHasher("md5").update(rel).digest("hex").slice(0, 12);
29
- };
30
-
31
30
  const appId = process.env.APP_ID;
32
31
  if (!appId) throw new Error("APP_ID env var required");
33
32
 
34
- const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
35
- const appDir = resolve(root, "packages", appId);
36
- if (!existsSync(appDir)) throw new Error(`Unknown app: ${appId} (no packages/${appId})`);
33
+ // `root` = wherever the user is building from. SSR plugin's rootDir is
34
+ // process.cwd() (set in defineApp), so we use the same here for hash parity.
35
+ const root = process.cwd();
36
+
37
+ // Framework dir — works whether this script is in packages/cloud/scripts/
38
+ // (monorepo) or node_modules/@valentinkolb/cloud/scripts/ (npm install).
39
+ const frameworkDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
40
+
41
+ // App dir — APP_DIR override for standalone consumers, defaults to monorepo
42
+ // convention. Resolved against cwd if relative.
43
+ const appDir = process.env.APP_DIR
44
+ ? resolve(root, process.env.APP_DIR)
45
+ : resolve(root, "packages", appId);
46
+ if (!existsSync(appDir)) throw new Error(`Unknown app dir: ${appDir} (set APP_DIR or check APP_ID)`);
37
47
 
38
48
  const dist = resolve(root, "dist");
39
49
  const distPublic = resolve(dist, "public");
@@ -41,8 +51,20 @@ const distPublic = resolve(dist, "public");
41
51
  await rm(dist, { recursive: true, force: true });
42
52
  await mkdir(distPublic, { recursive: true });
43
53
 
54
+ // Mirrors @valentinkolb/ssr's island-id (md5 of POSIX path relative to the
55
+ // SSR plugin's rootDir, truncated to 12 chars). Both this script and the
56
+ // plugin use process.cwd() as the rootDir, so hashes match.
57
+ const islandId = (file: string): string => {
58
+ const rel = file.slice(root.length + 1).replace(/\\/g, "/");
59
+ return new CryptoHasher("md5").update(rel).digest("hex").slice(0, 12);
60
+ };
61
+
44
62
  // Register the app's SSR plugin (Solid JSX transform + island bundler).
45
- const { plugin } = await import(`../../${appId}/src/config`);
63
+ // In the monorepo this resolves via `packages/<id>/src/config`; in standalone
64
+ // it resolves via the appDir path (because the script's relative imports
65
+ // only work for monorepo, we use absolute file:// for standalone).
66
+ const configPath = resolve(appDir, "src/config");
67
+ const { plugin } = await import(configPath);
46
68
 
47
69
  // 1. Server entry — also emits dist/_ssr/<island>.js via the SSR plugin.
48
70
  const server = await Bun.build({
@@ -58,13 +80,14 @@ if (!server.success) {
58
80
  throw new Error("Server bundle failed");
59
81
  }
60
82
 
61
- // 1b. The SSR plugin scans the workspace root and emits one chunk per
62
- // island/client file across every package. Drop the ones from other apps
63
- // so this image only carries its own + the framework's. Chunk files
64
- // (chunk-<hash>.js) are shared splits and always kept.
83
+ // 1b. The SSR plugin scans the project root and emits one chunk per island
84
+ // across every reachable package (including any island-shaped files in
85
+ // other workspace packages or in node_modules). Keep only this app's own
86
+ // islands plus the framework's; drop the rest. chunk-* files are shared
87
+ // splits and always kept.
65
88
  const ssrDir = resolve(dist, "_ssr");
66
89
  if (existsSync(ssrDir)) {
67
- const allowedDirs = [resolve(root, "packages/cloud"), appDir];
90
+ const allowedDirs = [frameworkDir, appDir];
68
91
  const allowedIds = new Set<string>();
69
92
  for (const dir of allowedDirs) {
70
93
  for await (const file of new Glob("**/*.{island,client}.tsx").scan({ cwd: dir, absolute: true })) {
@@ -9,8 +9,6 @@ import { createSSRHandler, routes } from "@valentinkolb/ssr/hono";
9
9
  import { Hono } from "hono";
10
10
  import { serveStatic } from "hono/bun";
11
11
  import type { SsrConfig } from "@valentinkolb/ssr";
12
- import { resolve, dirname } from "node:path";
13
- import { fileURLToPath } from "node:url";
14
12
  import type {
15
13
  AppMeta,
16
14
  AppLifecycle,
@@ -111,13 +109,26 @@ export type AppOptions<S extends AppSettingsMap = {}> = {
111
109
  * prefix-trie from these strings.
112
110
  */
113
111
  routes: readonly string[];
112
+ /**
113
+ * Project root used by the SSR plugin to discover island/client files.
114
+ * Defaults to `process.cwd()`. Override only if you run the entrypoint
115
+ * from a directory other than the project root.
116
+ */
117
+ appRoot?: string;
114
118
  };
115
119
 
116
120
  export type StartOptions = {
117
- routes: {
118
- api?: Hono<any>;
119
- pages?: Hono<any>;
120
- };
121
+ /**
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: [...] })`.
126
+ *
127
+ * Framework-owned mounts (`/_ssr/*`, `/public/*`, `/api/_internal/search`)
128
+ * register before this router so they take precedence — apps can ignore
129
+ * those paths.
130
+ */
131
+ router: Hono<any>;
121
132
  lifecycle?: AppLifecycle;
122
133
  capabilities?: AppCapabilities;
123
134
  port?: number;
@@ -193,7 +204,7 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
193
204
  const { config, plugin, html } = createSsrConfig<PageOptions>({
194
205
  dev: process.env.NODE_ENV !== "production",
195
206
  verbose: true,
196
- rootDir: resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."),
207
+ rootDir: opts.appRoot ?? process.cwd(),
197
208
  basePath: opts.basePath,
198
209
  template: ({ body, scripts, title, description, theme }) => {
199
210
  const themeFixed = theme !== undefined;
@@ -332,13 +343,14 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
332
343
  onFound: (_path, c) => {
333
344
  c.header("Cache-Control", "public, max-age=31536000, immutable");
334
345
  },
335
- }));
346
+ }))
347
+ // serveStatic calls next() on miss — terminate /public/* here so a
348
+ // 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).
350
+ .all("/public/*", (c) => c.notFound());
336
351
 
337
- // Mount app routes
338
- if (startOpts.routes.api) server.route("/api", startOpts.routes.api);
339
- if (startOpts.routes.pages) server.route("/", startOpts.routes.pages);
340
-
341
- // Internal search endpoint
352
+ // Framework-internal endpoints register BEFORE the app router so they
353
+ // take precedence over any catch-all the app might mount.
342
354
  if (startOpts.capabilities?.search) {
343
355
  const searchRun = startOpts.capabilities.search.run;
344
356
  server.post("/api/_internal/search", auth.requireRole("authenticated"), async (c) => {
@@ -349,6 +361,8 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
349
361
  });
350
362
  }
351
363
 
364
+ server.route("/", startOpts.router);
365
+
352
366
  // Lifecycle
353
367
  const cloudCtx: CloudContext = {
354
368
  logger,