feat(web): pwa offline caching, dockerized SPA, full-stack nginx smoke
This commit is contained in:
@@ -3,3 +3,6 @@
|
||||
library/
|
||||
.superpowers/
|
||||
backend/booklib*
|
||||
web/node_modules
|
||||
web/dist
|
||||
web/dev-dist
|
||||
|
||||
@@ -36,3 +36,18 @@ go test -p 1 -count=1 ./...
|
||||
## 改 schema 前必读
|
||||
|
||||
`db.Migrate` 只执行 `schema.sql` 的 `CREATE TABLE IF NOT EXISTS`——对已存在的库**加列/改列不会生效**。任何列变更之前,必须先引入 `schema_migrations` 版本表 + 有序迁移脚本,否则老部署会静默跑在旧结构上。
|
||||
|
||||
## 前端开发(web/)
|
||||
|
||||
- `cd web && npm install`;`npm run dev`(:5173,`/api` 代理到 :8080)。
|
||||
- 后端起法:`docker compose -f deploy/docker-compose.dev.yml up -d --wait` 起 PG(:5433)/Redis(:6380),
|
||||
然后 `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable \
|
||||
REDIS_URL=redis://localhost:6380 JWT_SECRET=dev go run ./cmd/server`。
|
||||
- 门槛:`npm run check`(tsc + vitest + vite build)。
|
||||
|
||||
## 生产部署(含前端)
|
||||
|
||||
- `.env` 配 `JWT_SECRET/ADMIN_USER/ADMIN_PASSWORD` 后 `docker compose up -d --build`;
|
||||
打开 http://localhost:8080(web=SPA+nginx,/api 反代 api:8080)。
|
||||
- PWA:不可变资源(封面/CBZ 页/原文件)SW cache-first,读过的内容离线可翻;登出会清 SW 缓存。
|
||||
- 冒烟:`bash scripts/smoke.sh`(后端直连)、`bash scripts/smoke-web.sh`(经 nginx 全栈,需 :8080 空闲)。
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY deploy/web-dist /usr/share/nginx/html
|
||||
COPY --from=build /src/dist /usr/share/nginx/html
|
||||
|
||||
+2
-3
@@ -1,11 +1,10 @@
|
||||
server {
|
||||
listen 80;
|
||||
client_max_body_size 200m;
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
|
||||
location /api/ {
|
||||
set $upstream http://api:8080; # 变量式 → 每次按 DNS 解析,scale 后轮询到新副本
|
||||
proxy_pass $upstream; # 无 URI 部分:保留 /api 前缀转发
|
||||
# 静态主机名:启动时经 /etc/resolv.conf 解析(Docker 127.0.0.11 / podman aardvark 均兼容)
|
||||
proxy_pass http://api:8080; # 无 URI 部分:保留 /api 前缀转发
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<!doctype html><title>booklib</title><p>backend up — frontend lands in Plan 2.</p>
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# 全栈部署冒烟:经 nginx 验证 SPA 静态、/api 反代、登录、SW/manifest。
|
||||
# 前提:.env 里有 JWT_SECRET/ADMIN_USER/ADMIN_PASSWORD;宿主 :8080 空闲。
|
||||
set -euo pipefail
|
||||
BASE=${BASE:-http://localhost:8080}
|
||||
[ -f .env ] && set -a && . ./.env && set +a
|
||||
: "${JWT_SECRET:?JWT_SECRET 未设置}"; : "${ADMIN_USER:?ADMIN_USER 未设置}"; : "${ADMIN_PASSWORD:?ADMIN_PASSWORD 未设置}"
|
||||
|
||||
say(){ echo "smoke-web: $1"; }
|
||||
die(){ echo "SMOKE-WEB FAIL: $1"; exit 1; }
|
||||
|
||||
say "build web + api images"
|
||||
docker compose build web api
|
||||
|
||||
say "up --wait"
|
||||
# docker compose 支持 --wait;podman-compose 不支持 → 回退 up -d + 轮询健康
|
||||
if docker compose up -d --wait postgres redis api web 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
say "no --wait support: up -d + poll healthz"
|
||||
docker compose up -d postgres redis api web
|
||||
for _ in $(seq 1 60); do
|
||||
curl -fsS --max-time 3 "$BASE/api/healthz" >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
curl -fsS --max-time 5 "$BASE/api/healthz" >/dev/null || die "api healthz 未就绪"
|
||||
fi
|
||||
|
||||
say "SPA index served by nginx"
|
||||
INDEX=$(curl -fsS "$BASE/")
|
||||
echo "$INDEX" | grep -q 'id="root"' || die "index 缺 #root"
|
||||
ASSET=$(echo "$INDEX" | sed -nE 's#.*src="(/assets/[^"]+\.js)".*#\1#p' | head -1)
|
||||
[ -n "$ASSET" ] || die "index 未引用 /assets/*.js"
|
||||
curl -fsS -o /dev/null "$BASE$ASSET" || die "asset $ASSET 拉取失败"
|
||||
|
||||
say "SPA deep link falls back to index"
|
||||
curl -fsS "$BASE/admin/users" | grep -q 'id="root"' || die "try_files 回退失败"
|
||||
|
||||
say "api via /api/ proxy"
|
||||
curl -fsS "$BASE/api/healthz" >/dev/null || die "healthz via nginx"
|
||||
TOK=$(curl -fsS "$BASE/api/auth/login" -H 'content-type: application/json' \
|
||||
-d "{\"username\":\"$ADMIN_USER\",\"password\":\"$ADMIN_PASSWORD\"}" \
|
||||
| sed -E 's/.*"token":"([^"]+)".*/\1/')
|
||||
[ -n "$TOK" ] || die "nginx 反代登录失败"
|
||||
curl -fsS "$BASE/api/auth/me" -H "authorization: Bearer $TOK" | grep -q "$ADMIN_USER" || die "me via nginx"
|
||||
|
||||
say "pwa artifacts"
|
||||
curl -fsS "$BASE/sw.js" >/dev/null || die "sw.js 缺失"
|
||||
curl -fsS "$BASE/manifest.webmanifest" >/dev/null || die "manifest 缺失"
|
||||
|
||||
say "ALL WEB SMOKE PASSED"
|
||||
@@ -47,6 +47,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setToken(null);
|
||||
setTok(null);
|
||||
qc.clear();
|
||||
if (typeof caches !== "undefined") {
|
||||
void caches.delete("booklib-api");
|
||||
void caches.delete("booklib-immutable");
|
||||
}
|
||||
},
|
||||
}),
|
||||
[meQ.data, meQ.isPending, meQ.isError, token, qc],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { queryClient } from "./lib/qc";
|
||||
import "./index.css";
|
||||
import "./pwa";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
// autoUpdate:新版本静默装好即用;个人应用无需升级确认弹窗
|
||||
registerSW({ immediate: true });
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
"types": ["vite/client", "vite-plugin-pwa/client"]
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
|
||||
+44
-2
@@ -1,8 +1,50 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
includeAssets: ["icon.svg"],
|
||||
manifest: {
|
||||
name: "BookLib 个人书库",
|
||||
short_name: "BookLib",
|
||||
description: "个人书库/漫画库在线阅读",
|
||||
theme_color: "#09090b",
|
||||
background_color: "#09090b",
|
||||
display: "standalone",
|
||||
icons: [{ src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,svg,woff2}"],
|
||||
navigateFallback: "/index.html",
|
||||
runtimeCaching: [
|
||||
{
|
||||
// ?v={hash} 的封面/CBZ 页/原文件 = 不可变 → cache-first(spec §6.1/§6.3)
|
||||
urlPattern: ({ url }) =>
|
||||
url.pathname.startsWith("/api/books/") &&
|
||||
url.search.includes("v=") &&
|
||||
(/\/cover$/.test(url.pathname) || /\/pages\/\d+$/.test(url.pathname) || /\/file$/.test(url.pathname)),
|
||||
handler: "CacheFirst",
|
||||
options: { cacheName: "booklib-immutable", expiration: { maxEntries: 3000, purgeOnQuotaError: true } },
|
||||
},
|
||||
{
|
||||
// 普通 API 请求 network-first,离线可读最近一次列表/进度
|
||||
urlPattern: ({ url }) => url.pathname.startsWith("/api/"),
|
||||
handler: "NetworkFirst",
|
||||
options: {
|
||||
cacheName: "booklib-api",
|
||||
networkTimeoutSeconds: 5,
|
||||
expiration: { maxAgeSeconds: 3600, maxEntries: 200 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: { proxy: { "/api": "http://localhost:8080" } },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user