Compare commits

...

8 Commits

Author SHA1 Message Date
MangoPig
99538e30c8 Feat: Web loader 2026-06-15 06:59:57 +01:00
MangoPig
90de5ca868 Merge branch 'Features/Frontend/Local-Prod-Proxy' 2026-06-15 05:09:39 +01:00
MangoPig
354dbc849b Feat: Local prod proxy 2026-06-15 05:09:13 +01:00
MangoPig
ddd25b6eb3 Merge branch 'Features/Frontend/Allowed-Hosts' 2026-06-14 15:23:31 +01:00
MangoPig
cc6243d630 Merge branch 'Features/Frontend/Frontend-Hardening' 2026-06-14 15:23:31 +01:00
MangoPig
9bceb2312d Merge branch 'Features/Frontend/App-Shell' 2026-06-14 15:23:31 +01:00
MangoPig
4c219c0084 Feat: Allowed hosts configuration 2026-06-14 15:23:14 +01:00
MangoPig
a75293fce4 Feat: Frontend hardening 2026-06-14 15:22:19 +01:00
31 changed files with 887 additions and 108 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.DS_Store
.git
.gitignore
**/.pnpm-store
**/.output
**/dist
**/node_modules
Commands
Docker
Documentation
Env

View File

@@ -1,11 +1,32 @@
project_root := justfile_directory()
frontend_dir := project_root + "/Frontend"
frontend_bake := project_root + "/Frontend/docker-bake.hcl"
proxy_bake := project_root + "/Proxy/docker-bake.hcl"
local_compose := project_root + "/Docker/docker-compose.local.prod.yaml"
# Build the Frontend production image locally.
# Build the local production proxy image locally.
build:
cd '{{frontend_dir}}' && docker buildx bake -f '{{frontend_bake}}' prod
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod
# Rebuild the Frontend production image locally.
# Start the local production stack in the background using the current image.
up:
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Build first, then start the local production stack in the background.
start: build
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Rebuild the local production proxy image locally.
rebuild:
cd '{{frontend_dir}}' && docker buildx bake -f '{{frontend_bake}}' --set '*.no-cache=true' prod
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' --set '*.no-cache=true' prod
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Stop and remove the local production stack.
down:
docker compose -f '{{local_compose}}' down --remove-orphans --volumes
# Follow logs for the local production stack.
logs:
docker compose -f '{{local_compose}}' logs -f
# Restart the local production stack.
restart:
docker compose -f '{{local_compose}}' restart

View File

@@ -1 +1,7 @@
# Reserved for a future local production compose stack.
services:
proxy:
image: moku/work-proxy:local-prod
container_name: moku-work-proxy-local
restart: unless-stopped
ports:
- "8080:80"

View File

@@ -11,7 +11,19 @@
- [ ] Project-Structure
- [ ] Stack-Decisions
- [ ] Proxy
- [ ] Local-Prod-NGINX-Proxy
- [ ] Static-Frontend-Serving
- [ ] First-Request-Web-Loader
- [ ] Bootstrap-Document
- [ ] Route-Intent-Handoff
- [ ] Tiny-First-Paint-Budget
- [ ] Dev-and-Prod-Builds
- [x] Local-Dev-Just-Commands
- [x] Local-Dev-Docker-Compose
- [ ] Local-Prod-Just-Commands
- [ ] Local-Prod-Docker-Compose
- [ ] Frontend-Production-Dockerfile
- [ ] Frontend-docker-bake
#### Backend

View File

@@ -1,5 +1,8 @@
# syntax=docker/dockerfile:1.7
# Frontend development image only.
# Production static serving is owned by Proxy/Local/Dockerfile.
FROM node:22-alpine AS base
WORKDIR /app
@@ -16,27 +19,11 @@ FROM dependencies AS development
ENV NODE_ENV=development
ENV PORT=3333
ARG ALLOWED_HOSTS
ENV ALLOWED_HOSTS=${ALLOWED_HOSTS}
COPY . .
EXPOSE 3333
CMD ["pnpm", "dev", "--host", "0.0.0.0", "--port", "3333"]
FROM dependencies AS build
ENV NODE_ENV=production
COPY . .
RUN pnpm build
FROM base AS production
ENV NODE_ENV=production
ENV PORT=3333
COPY --from=build /app /app
EXPOSE 3333
CMD ["pnpm", "start", "--host", "0.0.0.0", "--port", "3333"]

View File

@@ -17,30 +17,18 @@ target "dev" {
tags = ["moku/work-frontend:dev"]
}
target "prod" {
inherits = ["_app"]
target = "production"
tags = ["moku/work-frontend:prod"]
}
target "dev-image" {
inherits = ["_app"]
target = "development"
tags = ["${REGISTRY}/moku/work-frontend:dev-${TAG}"]
}
target "prod-image" {
inherits = ["_app"]
target = "production"
tags = ["${REGISTRY}/moku/work-frontend:prod-${TAG}"]
}
group "local" {
targets = ["dev", "prod"]
targets = ["dev"]
}
group "registry" {
targets = ["dev-image", "prod-image"]
targets = ["dev-image"]
}
group "default" {

View File

@@ -11,19 +11,22 @@
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "vite start",
"build:static": "pnpm build && node ./scripts/render-static-index.mjs",
"typecheck": "tsc --noEmit",
"start": "vite preview",
"preview": "vite preview"
},
"dependencies": {
"lucide-solid": "^0.542.0",
"@solidjs/start": "2.0.0-alpha.2",
"@solidjs/vite-plugin-nitro-2": "^0.1.0",
"lucide-solid": "^0.542.0",
"postcss": "^8.5.15",
"sass": "^1.101.0",
"solid-js": "^1.9.5",
"vite": "^7.0.0"
},
"devDependencies": {
"@types/node": "^25.9.3",
"autoprefixer": "^10.5.0",
"cssnano": "^8.0.2",
"postcss-preset-env": "^11.3.0",

View File

@@ -10,10 +10,10 @@ importers:
dependencies:
'@solidjs/start':
specifier: 2.0.0-alpha.2
version: 2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
version: 2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
'@solidjs/vite-plugin-nitro-2':
specifier: ^0.1.0
version: 0.1.0(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
version: 0.1.0(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
lucide-solid:
specifier: ^0.542.0
version: 0.542.0(solid-js@1.9.11)
@@ -28,8 +28,11 @@ importers:
version: 1.9.11
vite:
specifier: ^7.0.0
version: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
version: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
devDependencies:
'@types/node':
specifier: ^25.9.3
version: 25.9.3
autoprefixer:
specifier: ^10.5.0
version: 10.5.0(postcss@8.5.15)
@@ -1262,6 +1265,9 @@ packages:
'@types/micromatch@4.0.10':
resolution: {integrity: sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==}
'@types/node@25.9.3':
resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
@@ -3217,6 +3223,9 @@ packages:
unctx@2.5.0:
resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==}
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
@@ -4476,13 +4485,13 @@ snapshots:
dependencies:
solid-js: 1.9.11
'@solidjs/start@2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
'@solidjs/start@2.0.0-alpha.2(crossws@0.4.4(srvx@0.11.8))(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
dependencies:
'@babel/core': 7.29.0
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
'@solidjs/meta': 0.29.4(solid-js@1.9.11)
'@tanstack/server-functions-plugin': 1.134.5(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
'@tanstack/server-functions-plugin': 1.134.5(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
'@types/babel__traverse': 7.28.0
'@types/micromatch': 4.0.10
cookie-es: 2.0.0
@@ -4504,17 +4513,17 @@ snapshots:
source-map-js: 1.2.1
srvx: 0.9.8
terracotta: 1.1.0(solid-js@1.9.11)
vite: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
vite-plugin-solid: 2.11.10(solid-js@1.9.11)(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
vite: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
vite-plugin-solid: 2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
transitivePeerDependencies:
- '@testing-library/jest-dom'
- crossws
- supports-color
'@solidjs/vite-plugin-nitro-2@0.1.0(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
'@solidjs/vite-plugin-nitro-2@0.1.0(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
dependencies:
nitropack: 2.13.1
vite: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
vite: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
@@ -4549,7 +4558,7 @@ snapshots:
'@speed-highlight/core@1.2.14': {}
'@tanstack/directive-functions-plugin@1.134.5(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
'@tanstack/directive-functions-plugin@1.134.5(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/core': 7.29.0
@@ -4559,7 +4568,7 @@ snapshots:
babel-dead-code-elimination: 1.0.12
pathe: 2.0.3
tiny-invariant: 1.3.3
vite: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
vite: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
transitivePeerDependencies:
- supports-color
@@ -4576,7 +4585,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@tanstack/server-functions-plugin@1.134.5(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
'@tanstack/server-functions-plugin@1.134.5(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/core': 7.29.0
@@ -4585,7 +4594,7 @@ snapshots:
'@babel/template': 7.28.6
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
'@tanstack/directive-functions-plugin': 1.134.5(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
'@tanstack/directive-functions-plugin': 1.134.5(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
babel-dead-code-elimination: 1.0.12
tiny-invariant: 1.3.3
transitivePeerDependencies:
@@ -4629,6 +4638,10 @@ snapshots:
dependencies:
'@types/braces': 3.0.5
'@types/node@25.9.3':
dependencies:
undici-types: 7.24.6
'@types/resolve@1.20.2': {}
'@types/unist@3.0.3': {}
@@ -6753,6 +6766,8 @@ snapshots:
magic-string: 0.30.21
unplugin: 2.3.11
undici-types@7.24.6: {}
unenv@2.0.0-rc.24:
dependencies:
pathe: 2.0.3
@@ -6876,7 +6891,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-plugin-solid@2.11.10(solid-js@1.9.11)(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)):
vite-plugin-solid@2.11.10(solid-js@1.9.11)(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)):
dependencies:
'@babel/core': 7.29.0
'@types/babel__core': 7.20.5
@@ -6884,12 +6899,12 @@ snapshots:
merge-anything: 5.1.7
solid-js: 1.9.11
solid-refresh: 0.6.3(solid-js@1.9.11)
vite: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
vitefu: 1.1.2(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
vite: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
vitefu: 1.1.2(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0))
transitivePeerDependencies:
- supports-color
vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0):
vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0):
dependencies:
esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3)
@@ -6898,15 +6913,16 @@ snapshots:
rollup: 4.59.0
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 25.9.3
fsevents: 2.3.3
jiti: 2.6.1
sass: 1.101.0
sass-embedded: 1.100.0
terser: 5.46.0
vitefu@1.1.2(vite@7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)):
vitefu@1.1.2(vite@7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)):
optionalDependencies:
vite: 7.3.1(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
vite: 7.3.1(@types/node@25.9.3)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.46.0)
webidl-conversions@3.0.1: {}

View File

@@ -0,0 +1,49 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
const manifestPath = resolve("dist/client/.vite/manifest.json");
const outputPath = resolve("dist/client/index.html");
const themeBootstrapScript = `
(() => {
try {
const storageKey = "theme";
const stored = localStorage.getItem(storageKey);
const theme = stored === "light" || stored === "dark"
? stored
: (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.setAttribute("data-theme", theme);
} catch {
document.documentElement.setAttribute("data-theme", "light");
}
})();
`.trim();
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
const entry = manifest["src/entry-client.tsx"];
if (!entry?.file) {
throw new Error("Could not find src/entry-client.tsx in the client manifest.");
}
const cssLinks = Array.isArray(entry.css) ? entry.css.map((href) => ` <link rel="stylesheet" href="/${href}">`).join("\n") : "";
const html = `<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="/favicon.ico">
<script>${themeBootstrapScript}</script>
${cssLinks}
</head>
<body>
<div id="app"></div>
<script type="module" src="/${entry.file}"></script>
</body>
</html>
`;
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, html, "utf8");

View File

@@ -1,4 +1,21 @@
// Path: Frontend/src/entry-client.tsx
// @refresh reload
import type { JSX } from "solid-js";
import { mount, StartClient } from "@solidjs/start/client";
mount(() => <StartClient />, document.getElementById("app")!);
const getAppRoot = (): HTMLElement => {
const appRoot = document.getElementById("app");
if (!(appRoot instanceof HTMLElement)) {
throw new Error("App root element '#app' was not found.");
}
return appRoot;
};
const mountApp = (): void => {
mount((): JSX.Element => <StartClient />, getAppRoot());
};
mountApp();

View File

@@ -1,6 +1,7 @@
// Path: Frontend/src/entry-server.tsx
// @refresh reload
import type { JSX } from "solid-js";
import { createHandler, StartServer } from "@solidjs/start/server";
const themeBootstrapScript = `
@@ -19,22 +20,32 @@ const themeBootstrapScript = `
})();
`;
export default createHandler(() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<script innerHTML={themeBootstrapScript} />
{assets}
</head>
<body>
<div id="app">{children}</div>
{scripts}
</body>
</html>
)}
/>
));
type DocumentRenderProps = {
assets?: JSX.Element;
children?: JSX.Element;
scripts?: JSX.Element;
};
const renderDocument = ({ assets, children, scripts }: DocumentRenderProps): JSX.Element => {
return (
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<script innerHTML={themeBootstrapScript} />
{assets}
</head>
<body>
<div id="app">{children}</div>
{scripts}
</body>
</html>
);
};
const serverHandler = createHandler((): JSX.Element => {
return <StartServer document={renderDocument} />;
});
export default serverHandler;

View File

@@ -1 +1,3 @@
// Path: Frontend/src/global.d.ts
/// <reference types="@solidjs/start/env" />

View File

@@ -16,7 +16,7 @@ export const resolvePreferredTheme = (): Theme => {
export const getDocumentTheme = (): Theme => (document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light");
export const setTheme = (theme: Theme) => {
export const setTheme = (theme: Theme): void => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem(THEME_STORAGE_KEY, theme);
};

View File

@@ -1,3 +1,11 @@
// Path: Frontend/src/lib/icons/index.ts
export { Bell, Check, ChevronDown, ChevronLeft, ChevronRight, Folder, FolderOpen, Home, LayoutGrid, MoreHorizontal, Plus, Search, Settings, User, X } from "lucide-solid";
export { default as Bell } from "lucide-solid/icons/bell";
export { default as ChevronDown } from "lucide-solid/icons/chevron-down";
export { default as Folder } from "lucide-solid/icons/folder";
export { default as Home } from "lucide-solid/icons/house";
export { default as LayoutGrid } from "lucide-solid/icons/layout-grid";
export { default as Plus } from "lucide-solid/icons/plus";
export { default as Search } from "lucide-solid/icons/search";
export { default as Settings } from "lucide-solid/icons/settings";
export { default as User } from "lucide-solid/icons/user";

View File

@@ -1,5 +0,0 @@
/* Path: Frontend/src/styles/tools/_functions.scss */
@function rem($pixels, $base: 16) {
@return ($pixels / $base) * 1rem;
}

View File

@@ -1,19 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"strict": true,
"noEmit": true,
"types": ["vite/client"],
"isolatedModules": true,
"paths": {
"~/*": ["./src/*"]
}
}
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client", "node"],
"isolatedModules": true,
"paths": {
"~/*": ["./src/*"]
}
}
}

View File

@@ -1,17 +1,28 @@
// Path: Frontend/vite.config.ts
import { nitroV2Plugin as nitro } from "@solidjs/vite-plugin-nitro-2";
import { defineConfig } from "vite";
import { solidStart } from "@solidjs/start/config";
const extraAllowedHosts = (process.env.ALLOWED_HOSTS ?? "")
.split(",")
.map((host) => host.trim())
.filter(Boolean);
export default defineConfig({
plugins: [solidStart({ ssr: false })],
server: {
allowedHosts: ["localhost", ...extraAllowedHosts],
},
preview: {
allowedHosts: ["localhost", ...extraAllowedHosts],
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "/src/styles/tools/functions" as *;\n@use "/src/styles/tools/breakpoints" as *;\n@use "/src/styles/tools/mixins" as *;\n`,
additionalData: `@use "/src/styles/tools/breakpoints" as *;\n@use "/src/styles/tools/mixins" as *;\n`,
},
},
},
plugins: [solidStart(), nitro()],
});

35
Loader/README.md Normal file
View File

@@ -0,0 +1,35 @@
# Loader
Tiny first-request bootstrap document for Moku.
## Purpose
The loader is intended to be the smallest possible first paint shown before the
real application is handed off.
Long term responsibilities:
- capture original route intent
- collect lightweight client capability hints
- make a tiny boot decision
- hand off to login or app
## Route intent contract
The loader writes bootstrap state to `sessionStorage`:
- `moku.loader.intent` — the intended route path
- `moku.loader.meta` — JSON metadata for the current bootstrap event
The loader also writes a short-lived cookie:
- `moku_loader_seen=1`
The proxy uses that cookie to decide whether to keep serving the loader or to
serve the real app document on the next request.
## Current handoff behavior
For now, the loader simply hands off to the originally requested route. That
keeps the first implementation tiny while leaving a clear place to add auth or
perf-tier decisions later.

44
Loader/index.html Normal file
View File

@@ -0,0 +1,44 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>Moku Loader</title>
<link rel="stylesheet" href="/__loader/styles.css" />
<script type="module" src="/__loader/scripts/main.js"></script>
</head>
<body>
<main class="loader-shell" aria-busy="true" aria-live="polite">
<section class="loader-panel" aria-label="Application bootstrap">
<p class="loader-eyebrow">Moku bootstrap</p>
<h1 class="loader-title">Starting Moku</h1>
<p class="loader-copy">Capturing route intent and preparing the lightest possible handoff.</p>
<div class="loader-indicators" aria-hidden="true">
<span class="loader-dot"></span>
<span class="loader-dot"></span>
<span class="loader-dot"></span>
</div>
<dl class="loader-meta">
<div class="loader-meta-row">
<dt>Status</dt>
<dd id="loader-status">Initializing loader</dd>
</div>
<div class="loader-meta-row">
<dt>Intent</dt>
<dd id="loader-intent">Waiting for capture</dd>
</div>
</dl>
</section>
</main>
<noscript>
<section class="loader-noscript">
<strong>JavaScript is required.</strong>
<span>Moku's bootstrap loader needs JavaScript to hand off to the app.</span>
</section>
</noscript>
</body>
</html>

9
Loader/scripts/config.js Normal file
View File

@@ -0,0 +1,9 @@
// Path: Loader/scripts/config.js
export const LOADER_INTENT_STORAGE_KEY = "moku.loader.intent";
export const LOADER_META_STORAGE_KEY = "moku.loader.meta";
export const LOADER_SEEN_COOKIE_NAME = "moku_loader_seen";
export const LOADER_DEFAULT_INTENT = "/";
export const LOADER_ROOT_PREFIX = "/__loader";
export const LOADER_HANDOFF_DELAY_MS = 720;
export const LOADER_SEEN_COOKIE_MAX_AGE_SECONDS = 1800;

25
Loader/scripts/dom.js Normal file
View File

@@ -0,0 +1,25 @@
// Path: Loader/scripts/dom.js
export const getStatusElement = () => {
return document.getElementById("loader-status");
};
export const getIntentElement = () => {
return document.getElementById("loader-intent");
};
export const setStatus = (value) => {
const statusElement = getStatusElement();
if (statusElement instanceof HTMLElement) {
statusElement.textContent = value;
}
};
export const setIntentPreview = (value) => {
const intentElement = getIntentElement();
if (intentElement instanceof HTMLElement) {
intentElement.textContent = value;
}
};

13
Loader/scripts/handoff.js Normal file
View File

@@ -0,0 +1,13 @@
// Path: Loader/scripts/handoff.js
import { LOADER_HANDOFF_DELAY_MS } from "./config.js";
export const scheduleHandoff = (target, onBeforeHandoff) => {
window.setTimeout(() => {
if (typeof onBeforeHandoff === "function") {
onBeforeHandoff();
}
window.location.assign(target);
}, LOADER_HANDOFF_DELAY_MS);
};

19
Loader/scripts/hints.js Normal file
View File

@@ -0,0 +1,19 @@
// Path: Loader/scripts/hints.js
export const collectClientHints = () => {
const navigatorConnection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
return {
capturedAt: new Date().toISOString(),
language: navigator.language,
languages: Array.isArray(navigator.languages) ? navigator.languages : [],
hardwareConcurrency: navigator.hardwareConcurrency ?? null,
deviceMemory: navigator.deviceMemory ?? null,
networkType: navigatorConnection?.effectiveType ?? null,
saveData: navigatorConnection?.saveData ?? null,
viewport: {
width: window.innerWidth,
height: window.innerHeight,
},
};
};

32
Loader/scripts/intent.js Normal file
View File

@@ -0,0 +1,32 @@
// Path: Loader/scripts/intent.js
import { LOADER_DEFAULT_INTENT, LOADER_ROOT_PREFIX } from "./config.js";
export const getExplicitIntent = () => {
const params = new URLSearchParams(window.location.search);
const next = params.get("next");
if (typeof next === "string" && next.startsWith("/")) {
return next;
}
return null;
};
export const getCurrentPathIntent = () => {
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (current === "/" || current === LOADER_ROOT_PREFIX || current === `${LOADER_ROOT_PREFIX}/` || current.startsWith(`${LOADER_ROOT_PREFIX}/`)) {
return LOADER_DEFAULT_INTENT;
}
return current;
};
export const resolveIntent = () => {
return getExplicitIntent() ?? getCurrentPathIntent();
};
export const resolveHandoffTarget = (intent) => {
return intent || LOADER_DEFAULT_INTENT;
};

26
Loader/scripts/main.js Normal file
View File

@@ -0,0 +1,26 @@
// Path: Loader/scripts/main.js
import { setIntentPreview, setStatus } from "./dom.js";
import { scheduleHandoff } from "./handoff.js";
import { resolveHandoffTarget, resolveIntent } from "./intent.js";
import { markLoaderSeen, persistLoaderState } from "./storage.js";
const bootLoader = () => {
const intent = resolveIntent();
const handoffTarget = resolveHandoffTarget(intent);
setStatus("Capturing route intent");
setIntentPreview(intent);
persistLoaderState(intent);
window.setTimeout(() => {
setStatus("Preparing handoff");
}, 180);
scheduleHandoff(handoffTarget, () => {
setStatus("Handing off to application");
markLoaderSeen();
});
};
bootLoader();

24
Loader/scripts/storage.js Normal file
View File

@@ -0,0 +1,24 @@
// Path: Loader/scripts/storage.js
import { LOADER_INTENT_STORAGE_KEY, LOADER_META_STORAGE_KEY, LOADER_SEEN_COOKIE_MAX_AGE_SECONDS, LOADER_SEEN_COOKIE_NAME } from "./config.js";
import { collectClientHints } from "./hints.js";
export const persistLoaderState = (intent) => {
const payload = {
intent,
hints: collectClientHints(),
};
try {
window.sessionStorage.setItem(LOADER_INTENT_STORAGE_KEY, intent);
window.sessionStorage.setItem(LOADER_META_STORAGE_KEY, JSON.stringify(payload));
} catch (error) {
console.warn("[Loader] Unable to persist bootstrap state.", error);
}
return payload;
};
export const markLoaderSeen = () => {
document.cookie = `${LOADER_SEEN_COOKIE_NAME}=1; Path=/; Max-Age=${LOADER_SEEN_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
};

177
Loader/styles.css Normal file
View File

@@ -0,0 +1,177 @@
/* Path: Loader/styles.css */
:root {
color-scheme: dark;
--loader-bg: #07111f;
--loader-panel: rgba(15, 23, 42, 0.92);
--loader-panel-border: rgba(148, 163, 184, 0.18);
--loader-panel-highlight: rgba(255, 255, 255, 0.04);
--loader-text: #f8fafc;
--loader-text-muted: #94a3b8;
--loader-accent: #60a5fa;
--loader-accent-soft: rgba(96, 165, 250, 0.16);
--loader-shadow: 0 24px 64px rgba(2, 6, 23, 0.42);
--loader-radius: 1.25rem;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
min-height: 100dvh;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background: radial-gradient(circle at top, rgba(37, 99, 235, 0.18), transparent 36%), linear-gradient(180deg, #091528 0%, var(--loader-bg) 100%);
color: var(--loader-text);
}
.loader-shell {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 1.5rem;
}
.loader-panel {
width: min(100%, 30rem);
padding: 1.5rem;
border: 1px solid var(--loader-panel-border);
border-radius: var(--loader-radius);
background: linear-gradient(180deg, var(--loader-panel-highlight), transparent 16%), var(--loader-panel);
box-shadow: var(--loader-shadow);
backdrop-filter: blur(18px);
}
.loader-eyebrow {
margin: 0 0 0.625rem;
color: var(--loader-text-muted);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.loader-title {
margin: 0;
font-size: clamp(1.75rem, 5vw, 2.25rem);
line-height: 1.05;
}
.loader-copy {
margin: 0.875rem 0 0;
max-width: 30ch;
color: var(--loader-text-muted);
font-size: 0.975rem;
line-height: 1.55;
}
.loader-indicators {
margin-top: 1.25rem;
display: inline-flex;
gap: 0.5rem;
}
.loader-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 999px;
background: var(--loader-accent-soft);
animation: loader-pulse 1.4s ease-in-out infinite;
}
.loader-dot:nth-child(2) {
animation-delay: 0.18s;
}
.loader-dot:nth-child(3) {
animation-delay: 0.36s;
}
.loader-meta {
margin: 1.5rem 0 0;
padding-top: 1rem;
border-top: 1px solid rgba(148, 163, 184, 0.12);
}
.loader-meta-row {
display: grid;
grid-template-columns: 4.5rem minmax(0, 1fr);
gap: 0.75rem;
align-items: start;
}
.loader-meta-row + .loader-meta-row {
margin-top: 0.625rem;
}
.loader-meta dt {
color: var(--loader-text-muted);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.loader-meta dd {
margin: 0;
min-width: 0;
color: var(--loader-text);
font-size: 0.925rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.loader-noscript {
position: fixed;
right: 1rem;
bottom: 1rem;
left: 1rem;
display: grid;
gap: 0.375rem;
padding: 0.875rem 1rem;
border: 1px solid rgba(248, 113, 113, 0.28);
border-radius: 0.875rem;
background: rgba(69, 10, 10, 0.92);
color: #fecaca;
}
@keyframes loader-pulse {
0%,
100% {
transform: translateY(0);
background: var(--loader-accent-soft);
}
50% {
transform: translateY(-0.125rem);
background: var(--loader-accent);
}
}
@media (max-width: 47.99rem) {
.loader-shell {
padding: 1rem;
}
.loader-panel {
padding: 1.25rem;
}
.loader-meta-row {
grid-template-columns: 1fr;
gap: 0.25rem;
}
}

99
Proxy/Local/Dockerfile Normal file
View File

@@ -0,0 +1,99 @@
# syntax=docker/dockerfile:1.7
## Frontend
## =========================
ARG NGINX_VERSION=1.29.8
FROM node:22-alpine AS frontend-dependencies
WORKDIR /workspace/Frontend
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
COPY Frontend/package.json Frontend/pnpm-lock.yaml Frontend/tsconfig.json Frontend/vite.config.ts ./
RUN pnpm install --frozen-lockfile
FROM frontend-dependencies AS frontend-build
ENV NODE_ENV=production
COPY Frontend/ ./
RUN pnpm build:static
## Proxy
## =========================
FROM alpine:3.22 AS precompressed-assets
RUN apk add --no-cache brotli gzip
WORKDIR /workspace
COPY Loader ./html/__loader
COPY --from=frontend-build /workspace/Frontend/dist/client ./html
RUN find ./html -type f \( \
-name '*.html' -o \
-name '*.css' -o \
-name '*.js' -o \
-name '*.json' -o \
-name '*.svg' -o \
-name '*.txt' -o \
-name '*.xml' -o \
-name '*.webmanifest' \
\) -exec gzip -kf -9 {} \; \
&& find ./html -type f \( \
-name '*.html' -o \
-name '*.css' -o \
-name '*.js' -o \
-name '*.json' -o \
-name '*.svg' -o \
-name '*.txt' -o \
-name '*.xml' -o \
-name '*.webmanifest' \
\) -exec brotli -kf -Z {} \;
FROM alpine:3.22 AS brotli-modules
ARG NGINX_VERSION
RUN apk add --no-cache \
alpine-sdk \
cmake \
git \
linux-headers \
openssl-dev \
pcre2-dev \
wget \
zlib-dev
WORKDIR /tmp
RUN wget -O "nginx-${NGINX_VERSION}.tar.gz" "https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz" \
&& tar -xzf "nginx-${NGINX_VERSION}.tar.gz" \
&& git clone --recurse-submodules https://github.com/google/ngx_brotli.git
WORKDIR /tmp/nginx-${NGINX_VERSION}
RUN cd /tmp/ngx_brotli/deps/brotli \
&& mkdir -p out \
&& cd out \
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DCMAKE_C_FLAGS="-fPIC" -DCMAKE_CXX_FLAGS="-fPIC" .. \
&& cmake --build . --config Release -j"$(getconf _NPROCESSORS_ONLN)" \
&& cd /tmp/nginx-${NGINX_VERSION} \
&& ./configure --with-compat --add-dynamic-module=/tmp/ngx_brotli \
&& make modules
FROM nginx:${NGINX_VERSION}-alpine AS production
COPY Proxy/Local/nginx.conf /etc/nginx/nginx.conf
COPY Proxy/Local/default.conf /etc/nginx/conf.d/default.conf
COPY --from=brotli-modules /tmp/nginx-${NGINX_VERSION}/objs/ngx_http_brotli_filter_module.so /usr/lib/nginx/modules/
COPY --from=brotli-modules /tmp/nginx-${NGINX_VERSION}/objs/ngx_http_brotli_static_module.so /usr/lib/nginx/modules/
COPY --from=precompressed-assets /workspace/html /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

39
Proxy/Local/default.conf Normal file
View File

@@ -0,0 +1,39 @@
map $http_cookie $moku_bootstrap_document {
default /__loader/index.html;
"~*(^|; )moku_loader_seen=1($|;)" /index.html;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location = / {
add_header Cache-Control "no-store";
rewrite ^ $moku_bootstrap_document last;
}
location ^~ /__loader/ {
access_log off;
add_header Cache-Control "no-store";
try_files $uri =404;
}
location / {
try_files $uri $uri/ $moku_bootstrap_document;
}
location /favicon.ico {
try_files $uri =404;
access_log off;
log_not_found off;
}
location /_build/ {
access_log off;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
}

60
Proxy/Local/nginx.conf Normal file
View File

@@ -0,0 +1,60 @@
load_module /usr/lib/nginx/modules/ngx_http_brotli_filter_module.so;
load_module /usr/lib/nginx/modules/ngx_http_brotli_static_module.so;
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_static on;
gzip_vary on;
gzip_proxied any;
gzip_min_length 1024;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/manifest+json
application/xml
application/rss+xml
image/svg+xml;
brotli on;
brotli_static on;
brotli_comp_level 5;
brotli_min_length 1024;
brotli_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/manifest+json
application/xml
application/rss+xml
image/svg+xml;
include /etc/nginx/conf.d/*.conf;
}

36
Proxy/docker-bake.hcl Normal file
View File

@@ -0,0 +1,36 @@
variable "REGISTRY" {
default = "registry.example.com"
}
variable "TAG" {
default = "latest"
}
target "_proxy" {
context = "."
dockerfile = "Proxy/Local/Dockerfile"
}
target "prod" {
inherits = ["_proxy"]
target = "production"
tags = ["moku/work-proxy:local-prod"]
}
target "prod-image" {
inherits = ["_proxy"]
target = "production"
tags = ["${REGISTRY}/moku/work-proxy:prod-${TAG}"]
}
group "local" {
targets = ["prod"]
}
group "registry" {
targets = ["prod-image"]
}
group "default" {
targets = ["prod"]
}