Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@
/dist/
*.test
coverage.out

# Prods configuration
prods.ini

.pnpm-store
2 changes: 1 addition & 1 deletion LICENSE_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ MIT, ISC, 0BSD, BSD-2-Clause, BSD-3-Clause, and Apache-2.0 dependencies are norm

## Audited dependency snapshot

Audit date: 2026-09-16. Exact inputs and expected counts are recorded in [`licenses/audit.json`](licenses/audit.json).
Audit date: 2026-09-18. Exact inputs and expected counts are recorded in [`licenses/audit.json`](licenses/audit.json).

| Scope | Result |
| --- | --- |
Expand Down
25 changes: 25 additions & 0 deletions README-zh_TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,31 @@ Browser / crawler
- Restore 使用 durable journal;`prepared` 之前可安全放棄,之後只能用同一 operation 依各 root 證據向前完成。
- Admin 使用不透明 server-side session、server-side authorization、same-origin CSRF 與可信任 Host/proxy 設定。

## 本地 UI 開發(Admin + Public HMR)

正式環境仍內嵌建置後的 Admin/Public 資產。本地 React HMR 請以 Vite 當瀏覽器入口,並反代到 Go:

1. Terminal A — 啟動 Prods(`base_url` 維持指向 Go listen,例如 `http://127.0.0.1:3310`):

```sh
./prods
```

Vite proxy 會把瀏覽器的 `Origin`/`Referer`(`:5173`)改寫成 Go 的 `base_url` origin,避免 Admin POST(例如 `/admin/login`)被 `EnforceHost` 回 403。

2. Terminal B — 啟動 Vite proxy(此流程才需要 Node.js/pnpm):

```sh
pnpm --dir web/ui install --frozen-lockfile
pnpm --dir web/ui dev
```

3. 瀏覽 `http://127.0.0.1:5173/` 與 `http://127.0.0.1:5173/admin`(`http://localhost:5173` 也可)。不要用 `:3310` 做 UI 修改;該埠仍提供內嵌 bundle,沒有 HMR。

若 Go listen 不是 `http://127.0.0.1:3310`,可用 `PRODS_DEV_PROXY_TARGET` 覆寫 proxy 目標(須與 `prods.ini` 的 `base_url` 一致)。

限制:Public HMR 只涵蓋 islands JS 與 `public.css`;Go 模板/SSR HTML 變更仍需整頁重整。Admin Fast Refresh 在改到 root mount 或 provider 外層時可能整頁 remount。發行驗證仍以 `pnpm --dir web/ui build`、`go build` 與直接執行 binary 為準。

## 從原始碼建置與驗證

所需工具:
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,31 @@ Key boundaries:
- Restore uses a durable journal. Before `prepared` it can be abandoned safely; after `prepared` it only rolls forward through the same operation with per-root evidence.
- Admin uses opaque server-side sessions, server-side authorization, same-origin CSRF protection, and trusted Host/proxy settings.

## Local UI development (Admin + Public HMR)

Production still embeds built Admin and Public assets. For local React HMR, run Vite as the browser front door and proxy to the Go process:

1. Terminal A — start Prods (keep `base_url` on the Go listen address, e.g. `http://127.0.0.1:3310`):

```sh
./prods
```

The Vite proxy rewrites the browser `Origin` / `Referer` (`:5173`) to the Go `base_url` origin so Admin POSTs (for example `/admin/login`) are not rejected by `EnforceHost` with 403.

2. Terminal B — start the Vite proxy (requires Node.js / pnpm only for this workflow):

```sh
pnpm --dir web/ui install --frozen-lockfile
pnpm --dir web/ui dev
```

3. Browse `http://127.0.0.1:5173/` and `http://127.0.0.1:5173/admin` (`http://localhost:5173` also works). Do not use `:3310` for UI edits; that port still serves the embedded bundle without HMR.

Override the Go proxy target with `PRODS_DEV_PROXY_TARGET` when listen is not `http://127.0.0.1:3310` (it must match `base_url` in `prods.ini`).

Limits: Public HMR covers islands JS and `public.css` only — Go template / SSR HTML changes still need a full reload. Admin Fast Refresh can remount when you edit the root mount or provider shell. Release validation remains `pnpm --dir web/ui build`, `go build`, and running the binary directly.

## Build and verify from source

Required tools:
Expand Down
4 changes: 2 additions & 2 deletions licenses/audit.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"schema_version": 1,
"audited_on": "2026-09-16",
"audited_on": "2026-09-18",
"inputs": {
"go.mod": "6da0f697298da66eb5925ec32aa0dc49e6b5999a93dd31b4ceb693882e9b620e",
"go.sum": "1bc375f9ab523c32daa36c4facb0f968b8eb15c2c0eee05bae6d551a49a4d7ae",
"web/ui/package.json": "7e674c2007c94b857d125ea8ae2828f0ceed83f57aa8347a683fc64a4a19203e",
"web/ui/package.json": "3efb972712963df5e035b5ae0dc5544827e271792fbed6631344c2fead0654bc",
"web/ui/pnpm-lock.yaml": "c8a6e141f6a610d07a8a57473f6b0233801b9e1aaf5954e97c9b123f86f07c65"
},
"go": {
Expand Down
1 change: 1 addition & 0 deletions web/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --config vite.dev.config.ts",
"build": "vite build --config vite.system.config.ts && node scripts/build-styles.mjs && vite build --config vite.public.config.ts && vite build --config vite.admin.config.ts",
"build:styles": "node scripts/build-styles.mjs",
"build:public": "node scripts/build-styles.mjs && vite build --config vite.public.config.ts",
Expand Down
58 changes: 58 additions & 0 deletions web/ui/scripts/dev-html-rewrite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
DEV_ORIGIN,
GO_ORIGIN,
rewriteDevHtml,
rewriteLocationHeader,
rewriteUpstreamBrowserOrigin,
} from "./dev-html-rewrite";

describe("rewriteDevHtml", () => {
it("rewrites admin shell for Vite HMR", () => {
const html = `<!doctype html><html><head>
<link rel="stylesheet" href="/static/admin/style.css"></head>
<body><div id="admin-root"></div>
<script type="module" src="/static/admin/admin.js"></script></body></html>`;
const out = rewriteDevHtml(html);
expect(out).not.toContain("/static/admin/style.css");
expect(out).not.toContain("/static/admin/admin.js");
expect(out).toContain('src="/src/admin/main.tsx"');
expect(out).toContain("/@vite/client");
expect(out).toContain("/src/dev/hmr-preamble.js");
expect(out).not.toContain("injectIntoGlobalHook");
});

it("rewrites public islands and stylesheet", () => {
const html = `<!doctype html><html><head>
<link rel="stylesheet" href="/static/public/public.css"></head>
<body><script type="module" src="/static/public/public-islands.js"></script></body></html>`;
const out = rewriteDevHtml(html);
expect(out).toContain('href="/src/public/style.css"');
expect(out).toContain('src="/src/public/main.tsx"');
expect(out).toContain("/@vite/client");
expect(out).toContain("/src/dev/hmr-preamble.js");
});

it("leaves unrelated HTML unchanged", () => {
const html = `<!doctype html><html><body><p>ok</p></body></html>`;
expect(rewriteDevHtml(html)).toBe(html);
});
});

describe("rewriteLocationHeader", () => {
it("maps Go origin to Vite origin", () => {
expect(rewriteLocationHeader(`${GO_ORIGIN}/admin`)).toBe(`${DEV_ORIGIN}/admin`);
expect(rewriteLocationHeader("http://localhost:3310/")).toBe(`${DEV_ORIGIN}/`);
});
});

describe("rewriteUpstreamBrowserOrigin", () => {
it("maps Vite browser origins to the Go base URL origin", () => {
expect(rewriteUpstreamBrowserOrigin("http://localhost:5173")).toBe(GO_ORIGIN);
expect(rewriteUpstreamBrowserOrigin("http://127.0.0.1:5173")).toBe(GO_ORIGIN);
expect(rewriteUpstreamBrowserOrigin("http://localhost:5173/admin/login")).toBe(
`${GO_ORIGIN}/admin/login`,
);
expect(rewriteUpstreamBrowserOrigin("https://evil.example")).toBe("https://evil.example");
});
});
84 changes: 84 additions & 0 deletions web/ui/scripts/dev-html-rewrite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/** HTML / Location / Origin rewrites for the Vite-fronted local HMR proxy. */

export const GO_ORIGIN = "http://127.0.0.1:3310";
export const DEV_ORIGIN = "http://127.0.0.1:5173";

/** External only — Go CSP allows script-src 'self', not unsafe-inline. */
const HMR_SCRIPTS = [
`<script type="module" src="/src/dev/hmr-preamble.js"></script>`,
`<script type="module" src="/@vite/client"></script>`,
].join("");

/** Rewrite Go-served HTML so Admin / Public load Vite source entries with HMR. */
export function rewriteDevHtml(html: string): string {
let out = html;
const before = out;

out = out.replace(/<link\s+rel="stylesheet"\s+href="\/static\/admin\/style\.css"\s*>/gi, "");
out = out.replace(
/(<script\s+type="module"\s+src=")\/static\/admin\/admin\.js("><\/script>)/gi,
"$1/src/admin/main.tsx$2",
);
out = out.replace(
/(<script\s+type="module"\s+src=")\/static\/public\/public-islands\.js("><\/script>)/gi,
"$1/src/public/main.tsx$2",
);
out = out.replace(
/(<link\s+rel="stylesheet"\s+href=")\/static\/public\/public\.css("\s*>)/gi,
"$1/src/public/style.css$2",
);

if (out === before) {
return out;
}

if (!out.includes("/@vite/client")) {
if (/<\/head>/i.test(out)) {
out = out.replace(/<\/head>/i, `${HMR_SCRIPTS}</head>`);
} else if (/<body[^>]*>/i.test(out)) {
out = out.replace(/<body([^>]*)>/i, `<body$1>${HMR_SCRIPTS}`);
} else {
out = `${HMR_SCRIPTS}${out}`;
}
}

return out;
}

/** Keep redirects on the Vite origin during HMR sessions. */
export function rewriteLocationHeader(location: string | undefined): string | undefined {
if (!location) return location;
return location
.replaceAll(GO_ORIGIN, DEV_ORIGIN)
.replaceAll("http://localhost:3310", DEV_ORIGIN)
.replaceAll("http://[::1]:3310", DEV_ORIGIN);
}

/**
* Map browser Vite Origin/Referer to the Go base_url origin so EnforceHost
* accepts state-changing requests (e.g. POST /admin/login).
*/
export function rewriteUpstreamBrowserOrigin(
headerValue: string | undefined,
goOrigin: string = GO_ORIGIN,
): string | undefined {
if (!headerValue) return headerValue;
try {
const parsed = new URL(headerValue);
if (parsed.port !== "5173") return headerValue;
const host = parsed.hostname.toLowerCase();
if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") {
return headerValue;
}
const go = new URL(goOrigin);
parsed.protocol = go.protocol;
parsed.host = go.host;
// Origin has no path; Referer keeps path/query.
if (parsed.pathname === "/" && parsed.search === "" && parsed.hash === "") {
return goOrigin;
}
return parsed.toString();
} catch {
return headerValue;
}
}
6 changes: 6 additions & 0 deletions web/ui/src/dev/hmr-preamble.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import RefreshRuntime from "/@react-refresh";

RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
2 changes: 1 addition & 1 deletion web/ui/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "vite.*.config.ts"]
"include": ["src", "scripts", "vite.*.config.ts"]
}
Loading
Loading