diff --git a/Documentation/TODO.md b/Documentation/TODO.md index 16045d7..4ef2f51 100644 --- a/Documentation/TODO.md +++ b/Documentation/TODO.md @@ -13,6 +13,10 @@ - [ ] 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 diff --git a/Loader/README.md b/Loader/README.md new file mode 100644 index 0000000..af94bfb --- /dev/null +++ b/Loader/README.md @@ -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. diff --git a/Loader/index.html b/Loader/index.html new file mode 100644 index 0000000..65f79df --- /dev/null +++ b/Loader/index.html @@ -0,0 +1,44 @@ + + + + + + + Moku Loader + + + + +
+
+

Moku bootstrap

+

Starting Moku

+

Capturing route intent and preparing the lightest possible handoff.

+ + + +
+
+
Status
+
Initializing loader
+
+
+
Intent
+
Waiting for capture
+
+
+
+
+ + + + diff --git a/Loader/scripts/config.js b/Loader/scripts/config.js new file mode 100644 index 0000000..138538b --- /dev/null +++ b/Loader/scripts/config.js @@ -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; diff --git a/Loader/scripts/dom.js b/Loader/scripts/dom.js new file mode 100644 index 0000000..11ec1e0 --- /dev/null +++ b/Loader/scripts/dom.js @@ -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; + } +}; diff --git a/Loader/scripts/handoff.js b/Loader/scripts/handoff.js new file mode 100644 index 0000000..9cef851 --- /dev/null +++ b/Loader/scripts/handoff.js @@ -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); +}; diff --git a/Loader/scripts/hints.js b/Loader/scripts/hints.js new file mode 100644 index 0000000..c636619 --- /dev/null +++ b/Loader/scripts/hints.js @@ -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, + }, + }; +}; diff --git a/Loader/scripts/intent.js b/Loader/scripts/intent.js new file mode 100644 index 0000000..8d8ebef --- /dev/null +++ b/Loader/scripts/intent.js @@ -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; +}; diff --git a/Loader/scripts/main.js b/Loader/scripts/main.js new file mode 100644 index 0000000..722dd43 --- /dev/null +++ b/Loader/scripts/main.js @@ -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(); diff --git a/Loader/scripts/storage.js b/Loader/scripts/storage.js new file mode 100644 index 0000000..8c8c490 --- /dev/null +++ b/Loader/scripts/storage.js @@ -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`; +}; diff --git a/Loader/styles.css b/Loader/styles.css new file mode 100644 index 0000000..a76a101 --- /dev/null +++ b/Loader/styles.css @@ -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; + } +} diff --git a/Proxy/Local/Dockerfile b/Proxy/Local/Dockerfile index 8826ed4..15a81cb 100644 --- a/Proxy/Local/Dockerfile +++ b/Proxy/Local/Dockerfile @@ -1,5 +1,10 @@ # syntax=docker/dockerfile:1.7 +## Frontend +## ========================= + +ARG NGINX_VERSION=1.29.8 + FROM node:22-alpine AS frontend-dependencies WORKDIR /workspace/Frontend @@ -17,10 +22,77 @@ COPY Frontend/ ./ RUN pnpm build:static -FROM nginx:1.29-alpine AS production +## 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=frontend-build /workspace/Frontend/dist/client /usr/share/nginx/html +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 diff --git a/Proxy/Local/default.conf b/Proxy/Local/default.conf index 449892a..adfa1ca 100644 --- a/Proxy/Local/default.conf +++ b/Proxy/Local/default.conf @@ -1,3 +1,8 @@ +map $http_cookie $moku_bootstrap_document { + default /__loader/index.html; + "~*(^|; )moku_loader_seen=1($|;)" /index.html; +} + server { listen 80; server_name _; @@ -5,8 +10,19 @@ server { 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/ /index.html; + try_files $uri $uri/ $moku_bootstrap_document; } location /favicon.ico { diff --git a/Proxy/Local/nginx.conf b/Proxy/Local/nginx.conf new file mode 100644 index 0000000..71ecfb6 --- /dev/null +++ b/Proxy/Local/nginx.conf @@ -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; +}