@valentinkolb/cloud 0.5.1 → 0.5.2

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.5.1",
3
+ "version": "0.5.2",
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
@@ -21,11 +21,13 @@
21
21
  * it ships a `scripts/build-extras.ts` that this script runs at the end.
22
22
  */
23
23
  import { existsSync } from "node:fs";
24
- import { cp, mkdir, readdir, rm } from "node:fs/promises";
25
- import { dirname, resolve } from "node:path";
24
+ import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
25
+ import { dirname, extname, resolve } from "node:path";
26
26
  import { fileURLToPath } from "node:url";
27
+ import { brotliCompress, constants as zlibConstants, gzip } from "node:zlib";
27
28
  import tailwind from "bun-plugin-tailwind";
28
29
  import { Glob, CryptoHasher } from "bun";
30
+ import { promisify } from "node:util";
29
31
 
30
32
  const appId = process.env.APP_ID;
31
33
  if (!appId) throw new Error("APP_ID env var required");
@@ -47,6 +49,8 @@ if (!existsSync(appDir)) throw new Error(`Unknown app dir: ${appDir} (set APP_DI
47
49
 
48
50
  const dist = resolve(root, "dist");
49
51
  const distPublic = resolve(dist, "public");
52
+ const compressBrotli = promisify(brotliCompress);
53
+ const compressGzip = promisify(gzip);
50
54
 
51
55
  await rm(dist, { recursive: true, force: true });
52
56
  await mkdir(distPublic, { recursive: true });
@@ -59,6 +63,29 @@ const islandId = (file: string): string => {
59
63
  return new CryptoHasher("md5").update(rel).digest("hex").slice(0, 12);
60
64
  };
61
65
 
66
+ const compressibleExtensions = new Set([".css", ".html", ".js", ".json", ".map", ".svg", ".txt", ".xml"]);
67
+
68
+ async function precompressDistAssets(dir: string): Promise<void> {
69
+ if (!existsSync(dir)) return;
70
+
71
+ for await (const file of new Glob("**/*").scan({ cwd: dir, absolute: true, onlyFiles: true })) {
72
+ if (file.endsWith(".br") || file.endsWith(".gz")) continue;
73
+ if (!compressibleExtensions.has(extname(file))) continue;
74
+
75
+ const source = await readFile(file);
76
+ const [br, gz] = await Promise.all([
77
+ compressBrotli(source, {
78
+ params: {
79
+ [zlibConstants.BROTLI_PARAM_QUALITY]: zlibConstants.BROTLI_MAX_QUALITY,
80
+ },
81
+ }),
82
+ compressGzip(source, { level: zlibConstants.Z_BEST_COMPRESSION }),
83
+ ]);
84
+
85
+ await Promise.all([writeFile(`${file}.br`, br), writeFile(`${file}.gz`, gz)]);
86
+ }
87
+ }
88
+
62
89
  // Register the app's SSR plugin (Solid JSX transform + island bundler).
63
90
  // In the monorepo this resolves via `packages/<id>/src/config`; in standalone
64
91
  // it resolves via the appDir path (because the script's relative imports
@@ -133,4 +160,7 @@ if (existsSync(extras)) {
133
160
  await import(extras);
134
161
  }
135
162
 
163
+ await precompressDistAssets(resolve(dist, "public"));
164
+ await precompressDistAssets(resolve(dist, "_ssr"));
165
+
136
166
  console.log(`Built ${appId} → ${dist}`);
@@ -9,7 +9,6 @@ import type { SsrConfig } from "@valentinkolb/ssr";
9
9
  import { createConfig as createSsrConfig } from "@valentinkolb/ssr";
10
10
  import { createSSRHandler, routes } from "@valentinkolb/ssr/hono";
11
11
  import { Hono } from "hono";
12
- import { serveStatic } from "hono/bun";
13
12
  import { generateSpecs } from "hono-openapi";
14
13
  import type { AppCapabilities, AppLifecycle, AppMeta, AppSearchContext, CloudContext } from "../contracts/app";
15
14
  import type { AppRegistryEntry } from "../contracts/registry";
@@ -23,6 +22,7 @@ import { createSettingsAPI, type SettingsAPI } from "../services/settings/api";
23
22
  import { registerSettings, type SettingDef } from "../services/settings/defaults";
24
23
  import { createHeartbeat } from "./heartbeat";
25
24
  import { ensureRuntimeWatcher, getCurrentRuntime, stopRuntimeWatcher } from "./runtime-watcher";
25
+ import { servePublicAsset } from "./static-assets";
26
26
 
27
27
  /** Cache-busting version stamp — changes on every server start / rebuild. */
28
28
  const v = Date.now();
@@ -252,6 +252,9 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
252
252
  <meta name="mobile-web-app-capable" content="yes">
253
253
  <link rel="icon" href="/branding/favicon">
254
254
  <style data-cloud-css-layers>@layer theme, base, components, utilities;</style>
255
+ <link rel="preload" href="/public/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
256
+ <link rel="stylesheet" href="/public/fonts.css?v=${v}">
257
+ <link rel="stylesheet" href="/public/tabler-icons.css?v=${v}">
255
258
  <link rel="stylesheet" href="/public/${opts.id}/app.css?v=${v}">
256
259
  <link rel="stylesheet" href="/public/global.css?v=${v}">
257
260
  <script>${themeBootstrapScript}</script>
@@ -349,19 +352,7 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
349
352
 
350
353
  const server = new Hono()
351
354
  .route(ssrMountPath, routes(config))
352
- .use(
353
- "/public/*",
354
- serveStatic({
355
- root: "./",
356
- onFound: (_path, c) => {
357
- c.header("Cache-Control", isDevelopment ? "no-store" : "public, max-age=31536000, immutable");
358
- },
359
- }),
360
- )
361
- // serveStatic calls next() on miss — terminate /public/* here so a
362
- // missing asset is a clean 404 instead of falling through to the app
363
- // fetch (which might render an HTML page for the missing path).
364
- .all("/public/*", (c) => c.notFound());
355
+ .all("/public/*", servePublicAsset(isDevelopment));
365
356
 
366
357
  if (startOpts.capabilities?.search) {
367
358
  const searchRun = startOpts.capabilities.search.run;
@@ -0,0 +1,86 @@
1
+ import { resolve, sep } from "node:path";
2
+ import type { Context } from "hono";
3
+
4
+ const publicRoot = resolve(process.cwd(), "public");
5
+
6
+ function decodePathname(pathname: string): string | null {
7
+ try {
8
+ return decodeURIComponent(pathname);
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ function resolvePublicAsset(pathname: string): string | null {
15
+ const decoded = decodePathname(pathname);
16
+ if (!decoded?.startsWith("/public/")) return null;
17
+
18
+ const path = resolve(process.cwd(), decoded.slice(1));
19
+ if (path !== publicRoot && !path.startsWith(`${publicRoot}${sep}`)) return null;
20
+ return path;
21
+ }
22
+
23
+ function acceptsEncoding(header: string | null, encoding: "br" | "gzip"): boolean {
24
+ if (!header) return false;
25
+
26
+ return header.split(",").some((part) => {
27
+ const [name, ...params] = part.trim().split(";");
28
+ if (name?.trim().toLowerCase() !== encoding) return false;
29
+ return !params.some((param) => {
30
+ const [key, value] = param.trim().split("=");
31
+ return key?.toLowerCase() === "q" && Number(value) === 0;
32
+ });
33
+ });
34
+ }
35
+
36
+ async function encodedFile(path: string, encoding: "br" | "gzip"): Promise<Bun.BunFile | null> {
37
+ const suffix = encoding === "br" ? ".br" : ".gz";
38
+ const file = Bun.file(`${path}${suffix}`);
39
+ return (await file.exists()) ? file : null;
40
+ }
41
+
42
+ export function servePublicAsset(isDevelopment: boolean) {
43
+ return async (c: Context) => {
44
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") {
45
+ c.header("Allow", "GET, HEAD");
46
+ return c.text("Method Not Allowed", 405);
47
+ }
48
+
49
+ const path = resolvePublicAsset(new URL(c.req.url).pathname);
50
+ if (!path) return c.notFound();
51
+
52
+ const sourceFile = Bun.file(path);
53
+ if (!(await sourceFile.exists())) return c.notFound();
54
+
55
+ const acceptEncoding = c.req.header("Accept-Encoding") ?? null;
56
+ let selected = sourceFile;
57
+ let selectedEncoding: "br" | "gzip" | null = null;
58
+
59
+ if (acceptsEncoding(acceptEncoding, "br")) {
60
+ const br = await encodedFile(path, "br");
61
+ if (br) {
62
+ selected = br;
63
+ selectedEncoding = "br";
64
+ }
65
+ }
66
+
67
+ if (!selectedEncoding && acceptsEncoding(acceptEncoding, "gzip")) {
68
+ const gz = await encodedFile(path, "gzip");
69
+ if (gz) {
70
+ selected = gz;
71
+ selectedEncoding = "gzip";
72
+ }
73
+ }
74
+
75
+ const headers = new Headers({
76
+ "Cache-Control": isDevelopment ? "no-store" : "public, max-age=31536000, immutable",
77
+ "Content-Length": String(selected.size),
78
+ "Content-Type": sourceFile.type || "application/octet-stream",
79
+ Vary: "Accept-Encoding",
80
+ });
81
+
82
+ if (selectedEncoding) headers.set("Content-Encoding", selectedEncoding);
83
+
84
+ return new Response(c.req.method === "HEAD" ? null : selected, { headers });
85
+ };
86
+ }
@@ -9,25 +9,12 @@
9
9
  @custom-variant dark (&:where(.dark, .dark *));
10
10
  @custom-variant light (html:not(.dark) &);
11
11
 
12
- @import "@tabler/icons-webfont/dist/tabler-icons.css";
13
-
14
- /* IBM Plex family Sans for body, Mono for code, Condensed for badges/tags.
15
- Per-weight imports keep the network payload predictable; we deliberately
16
- skip the lighter weights (100–300) and italic-condensed since the design
17
- system never uses them. */
18
- @import "@fontsource/ibm-plex-sans/400.css";
19
- @import "@fontsource/ibm-plex-sans/400-italic.css";
20
- @import "@fontsource/ibm-plex-sans/500.css";
21
- @import "@fontsource/ibm-plex-sans/600.css";
22
- @import "@fontsource/ibm-plex-sans/700.css";
23
- @import "@fontsource/ibm-plex-mono/400.css";
24
- @import "@fontsource/ibm-plex-mono/400-italic.css";
25
- @import "@fontsource/ibm-plex-mono/500.css";
26
- @import "@fontsource/ibm-plex-mono/600.css";
27
- @import "@fontsource/ibm-plex-sans-condensed/400.css";
28
- @import "@fontsource/ibm-plex-sans-condensed/500.css";
29
- @import "@fontsource/ibm-plex-sans-condensed/600.css";
30
- @import "@fontsource/ibm-plex-sans-condensed/700.css";
12
+ /* IBM Plex font-face rules are emitted as `/public/fonts.css` during the core
13
+ build. Keeping them out of global.css avoids Bun inlining the font binaries
14
+ as base64 into the render-blocking stylesheet. */
15
+ /* Tabler icon font rules are emitted as `/public/tabler-icons.css` during the
16
+ core build and preloaded by the shared HTML template to reduce icon flicker
17
+ without changing icon layout semantics. */
31
18
 
32
19
  @import "./tokens.css";
33
20
  @import "./utilities-buttons.css";
@@ -174,7 +161,7 @@
174
161
  beat stdlib's one-class embedded <style> the same way the dark-mode
175
162
  overrides above do. font-family is otherwise unset by stdlib, so the
176
163
  chart would inherit the app sans — we pin the numeric labels to the
177
- platform mono stack (IBM Plex Mono via @fontsource). */
164
+ platform mono stack (IBM Plex Mono via `/public/fonts.css`). */
178
165
  .stdlib-chart .stdlib-chart-tick-label,
179
166
  .stdlib-chart .stdlib-chart-bar-value,
180
167
  .stdlib-chart .stdlib-chart-axis-label {
@@ -146,8 +146,8 @@
146
146
  4. `text-rendering: geometricPrecision` — disables kerning so
147
147
  even non-monospace fallback fonts don't shift glyph X
148
148
  positions across layers. */
149
- /* IBM Plex Mono is shipped via @fontsource (regular + italic + 500
150
- + 600 weights — see global.css). Pinning the font here means the
149
+ /* IBM Plex Mono is shipped via `/public/fonts.css` (regular + italic +
150
+ 500 + 600 weights). Pinning the font here means the
151
151
  editor looks identical across OSes and we control the italic
152
152
  variant precisely. The bold/heading "weight" is faked via
153
153
  text-shadow further down so we never actually request a weight