@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
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
|
-
.
|
|
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
|
+
}
|
package/src/styles/global.css
CHANGED
|
@@ -9,25 +9,12 @@
|
|
|
9
9
|
@custom-variant dark (&:where(.dark, .dark *));
|
|
10
10
|
@custom-variant light (html:not(.dark) &);
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
|
150
|
-
+ 600 weights
|
|
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
|