feat(web): serve workspace from HTTP service (#446)

* feat(web): add the ReMe workspace frontend

* feat(web): serve workspace from HTTP service

* test(web): satisfy pylint docstring checks

* fix(web): use same-origin API safely

* fix(web): preserve API route semantics
This commit is contained in:
jinliyl 2026-08-11 23:32:24 +08:00 committed by GitHub
parent b8f48c8004
commit 9533c17d51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 442 additions and 15 deletions

View file

@ -23,6 +23,20 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: website/package-lock.json
- name: Build web workspace
working-directory: website
run: |
npm ci
npm run build:static
rm -rf ../reme/web
mkdir -p ../reme/web
cp -R dist-static/. ../reme/web/
- name: Set up Python
uses: actions/setup-python@v6
with:
@ -38,6 +52,7 @@ jobs:
WHEEL="$(ls dist/*.whl)"
pip install "${WHEEL}[core]"
python -c "import reme; print(reme.__version__)"
python -c "from importlib.resources import files; assert (files('reme') / 'web' / 'index.html').is_file()"
- name: Publish package to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:

View file

@ -128,6 +128,10 @@ reme start service.port=8181
After startup, check the service status. If you use a custom port, replace `2333` in the URL below with that port.
The HTTP service also serves the bundled ReMe Workspace at <http://127.0.0.1:2333/> when the web build is available.
Set `service.web_enabled=false` to disable it, or use `service.web_static_dir` / `REME_WEB_STATIC_DIR` to provide a custom
static build.
```bash
reme version
curl -s http://127.0.0.1:2333/version -H 'Content-Type: application/json' -d '{}'

View file

@ -121,6 +121,9 @@ reme start service.port=8181
启动后可以检查服务状态;如果使用了自定义端口,请将下面 URL 中的 `2333` 替换为对应端口。
如果安装包中包含 Web 构建产物HTTP 服务还会在 <http://127.0.0.1:2333/> 提供 ReMe Workspace。可以设置
`service.web_enabled=false` 关闭,或通过 `service.web_static_dir` / `REME_WEB_STATIC_DIR` 指定自定义静态目录。
```bash
reme version
curl -s http://127.0.0.1:2333/version -H 'Content-Type: application/json' -d '{}'

View file

@ -81,6 +81,7 @@ include-package-data = true
[tool.setuptools.package-data]
"*" = ["py.typed", "**/*.yaml", "**/*.json"]
"reme" = ["web/**"]
"reme.components.tokenizer" = ["stopwords"]
[tool.setuptools.dynamic]

View file

@ -44,6 +44,9 @@ class BaseService(BaseComponent):
def start_service(self, app: "Application") -> None:
"""Block on serving requests until shutdown."""
def finalize_service(self, app: "Application") -> None:
"""Register routes that must be added after every configured job."""
# ----- Shared helpers ------------------------------------------------
def _lifespan(self, app: "Application", host: str, port: int):
@ -97,4 +100,5 @@ class BaseService(BaseComponent):
"""Build the service, register jobs, then start serving (blocking)."""
self.build_service(app)
self.add_jobs(app)
self.finalize_service(app)
self.start_service(app)

View file

@ -3,19 +3,21 @@
import asyncio
import warnings
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import TYPE_CHECKING
import uvicorn
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from .base_service import BaseService
from ..component_registry import R
from ..job import BaseJob, StreamJob
from ...constants import REME_DEFAULT_HOST, REME_DEFAULT_PORT
from ...schema import Request, Response
from ...utils import execute_stream_task
from ...utils import execute_stream_task, resolve_web_static_dir
if TYPE_CHECKING:
from ...application import Application
@ -33,10 +35,19 @@ _WEBSOCKET_DEPRECATION_PATTERNS = (
class HttpService(BaseService):
"""Map non-stream jobs to JSON POST endpoints and StreamJobs to SSE endpoints."""
def __init__(self, host: str = REME_DEFAULT_HOST, port: int = REME_DEFAULT_PORT, **kwargs):
def __init__(
self,
host: str = REME_DEFAULT_HOST,
port: int = REME_DEFAULT_PORT,
web_enabled: bool = True,
web_static_dir: str | None = None,
**kwargs,
):
super().__init__(**kwargs)
self.host: str = host
self.port: int = port
self.web_enabled = web_enabled
self.web_static_dir = web_static_dir
# ----- BaseService contract ------------------------------------------
@ -69,6 +80,52 @@ class HttpService(BaseService):
warnings.filterwarnings("ignore", category=DeprecationWarning, message=pattern)
uvicorn.run(self.service, host=self.host, port=self.port, **self.kwargs)
def finalize_service(self, app: "Application") -> None:
"""Serve the optional workspace UI after all job endpoints are registered."""
del app
if not self.web_enabled:
return
static_dir = resolve_web_static_dir(self.web_static_dir)
if static_dir is None:
self.logger.info("Web workspace is unavailable; no static build was found")
return
index_file = static_dir / "index.html"
assets_dir = static_dir / "assets"
if assets_dir.is_dir():
self.service.mount(
"/assets",
StaticFiles(directory=str(assets_dir)),
name="web-assets",
)
no_cache_headers = {"Cache-Control": "no-cache, no-store, must-revalidate"}
post_only_paths = {
route.path
for route in self.service.routes
if "POST" in (getattr(route, "methods", None) or set())
and "GET" not in (getattr(route, "methods", None) or set())
}
@self.service.get("/{full_path:path}", include_in_schema=False)
async def workspace_spa(full_path: str):
if full_path in {"docs", "redoc", "openapi.json"}:
raise HTTPException(status_code=404, detail="Not Found")
if f"/{full_path}" in post_only_paths:
raise HTTPException(
status_code=405,
detail="Method Not Allowed",
headers={"Allow": "POST"},
)
if full_path and not Path(full_path).is_absolute():
static_file = (static_dir / full_path).resolve()
if static_file.is_relative_to(static_dir) and static_file.is_file():
return FileResponse(static_file)
return FileResponse(index_file, headers=no_cache_headers)
# ----- Endpoint factories --------------------------------------------
def _add_json_job(self, job: BaseJob) -> None:

View file

@ -1,5 +1,6 @@
service:
backend: http
web_enabled: true
jobs:
index_update_loop:

View file

@ -15,6 +15,7 @@ from .logo_utils import print_logo
from .service_utils import find_reme, locate_reme, precheck_start, cli_find_reme, running_service_config
from .similarity_utils import cosine_similarity, batch_cosine_similarity
from .token_utils import estimate_token_count
from .web_static import REME_WEB_STATIC_DIR, resolve_web_static_dir
from .agent_state_io import AsStateHandler
from .counter import (
global_counter_add,
@ -46,6 +47,8 @@ __all__ = [
"cosine_similarity",
"batch_cosine_similarity",
"estimate_token_count",
"REME_WEB_STATIC_DIR",
"resolve_web_static_dir",
"AsStateHandler",
"global_counter_add",
"global_counter_add_many",

30
reme/utils/web_static.py Normal file
View file

@ -0,0 +1,30 @@
"""Resolve the optional ReMe workspace frontend build."""
from __future__ import annotations
import os
from pathlib import Path
REME_WEB_STATIC_DIR = "REME_WEB_STATIC_DIR"
def resolve_web_static_dir(configured_dir: str | None = None) -> Path | None:
"""Return the first directory containing a built workspace ``index.html``."""
package_dir = Path(__file__).resolve().parent.parent
repository_dir = package_dir.parent
cwd = Path.cwd()
candidates = [
configured_dir,
os.getenv(REME_WEB_STATIC_DIR),
package_dir / "web",
repository_dir / "website" / "dist-static",
cwd / "website" / "dist-static",
cwd / "web_dist",
]
for candidate in candidates:
if not candidate:
continue
path = Path(candidate).expanduser().resolve()
if path.is_dir() and (path / "index.html").is_file():
return path
return None

View file

@ -0,0 +1,102 @@
"""HTTP service coverage for the optional bundled web workspace."""
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi.testclient import TestClient
from reme.components.service.http_service import HttpService
from reme.utils import REME_WEB_STATIC_DIR, resolve_web_static_dir
class _FakeApplication:
def __init__(self) -> None:
self.config = SimpleNamespace(app_name="ReMe test")
self.started = False
self.closed = False
async def start(self) -> None:
"""Record that the application lifespan started."""
self.started = True
async def close(self) -> None:
"""Record that the application lifespan closed."""
self.closed = True
def _static_build(tmp_path: Path) -> Path:
static_dir = tmp_path / "web"
assets_dir = static_dir / "assets"
assets_dir.mkdir(parents=True)
(static_dir / "index.html").write_text("<main>ReMe workspace</main>", encoding="utf-8")
(static_dir / "favicon.svg").write_text("<svg></svg>", encoding="utf-8")
(assets_dir / "app.js").write_text("console.log('reme')", encoding="utf-8")
return static_dir
def test_http_service_serves_workspace_without_shadowing_jobs(tmp_path: Path) -> None:
"""Serve workspace files and preserve explicitly registered job routes."""
static_dir = _static_build(tmp_path)
app = _FakeApplication()
service = HttpService(web_static_dir=str(static_dir))
service.build_service(app) # type: ignore[arg-type]
@service.service.post("/status")
async def status():
return {"success": True}
service.finalize_service(app) # type: ignore[arg-type]
with TestClient(service.service) as client:
assert client.get("/").text == "<main>ReMe workspace</main>"
assert client.get("/memory/topic").text == "<main>ReMe workspace</main>"
assert client.get("/favicon.svg").text == "<svg></svg>"
assert client.get("/assets/app.js").text == "console.log('reme')"
assert client.post("/status").json() == {"success": True}
assert client.get("/status").status_code == 405
assert client.get("/status").headers["allow"] == "POST"
assert client.get("/").headers["cache-control"] == "no-cache, no-store, must-revalidate"
assert app.started is True
assert app.closed is True
def test_http_service_can_disable_workspace(tmp_path: Path) -> None:
"""Leave the root route unregistered when workspace serving is disabled."""
app = _FakeApplication()
service = HttpService(web_enabled=False, web_static_dir=str(_static_build(tmp_path)))
service.build_service(app) # type: ignore[arg-type]
service.finalize_service(app) # type: ignore[arg-type]
with TestClient(service.service) as client:
assert client.get("/").status_code == 404
def test_http_service_does_not_serve_symlinks_outside_static_dir(tmp_path: Path) -> None:
"""Do not expose files reached through symlinks outside the static build."""
static_dir = _static_build(tmp_path)
secret_file = tmp_path / "secret.txt"
secret_file.write_text("private", encoding="utf-8")
try:
(static_dir / "escape.txt").symlink_to(secret_file)
except OSError as error:
pytest.skip(f"Symbolic links are unavailable: {error}")
app = _FakeApplication()
service = HttpService(web_static_dir=str(static_dir))
service.build_service(app) # type: ignore[arg-type]
service.finalize_service(app) # type: ignore[arg-type]
with TestClient(service.service) as client:
assert client.get("/escape.txt").text == "<main>ReMe workspace</main>"
def test_static_dir_configuration_precedes_environment(monkeypatch, tmp_path: Path) -> None:
"""Prefer an explicit static directory over the environment setting."""
configured = _static_build(tmp_path / "configured")
environment = _static_build(tmp_path / "environment")
monkeypatch.setenv(REME_WEB_STATIC_DIR, str(environment))
assert resolve_web_static_dir(str(configured)) == configured.resolve()
assert resolve_web_static_dir() == environment.resolve()

View file

@ -1,2 +1,4 @@
NEXT_PUBLIC_REME_API_URL=http://127.0.0.1:2333
NEXT_PUBLIC_REME_WORKSPACE_EXTENSIONS=md,txt
VITE_REME_API_URL=http://127.0.0.1:2333
VITE_REME_WORKSPACE_EXTENSIONS=md,txt

1
website/.gitignore vendored
View file

@ -37,6 +37,7 @@ yarn-error.log*
.vercel
/dist/
/dist-static/
/.wrangler/
/outputs/
/work/

View file

@ -2,6 +2,7 @@
.next/
.vinext/
dist/
dist-static/
out/
# Dependencies

View file

@ -24,6 +24,27 @@ Open <http://localhost:3000>. The frontend connects to
NEXT_PUBLIC_REME_API_URL=http://127.0.0.1:8000 npm run dev
```
## ReMe-hosted static build
ReMe can serve the same workspace from its FastAPI process. Build the static
variant and restart ReMe:
```bash
cd website
npm ci
npm run build:static
cd ..
reme start
```
Open <http://127.0.0.1:2333>. The static build uses same-origin requests by
default. For standalone static development, run `npm run dev:static` and set
`VITE_REME_API_URL` to the running ReMe service URL when necessary.
The regular `npm run build` command remains the vinext/Sites deployment build;
`npm run build:static` creates `dist-static/` exclusively for FastAPI and Python
package distribution.
The workspace hides dotfiles and dot-directories. It displays only Markdown and
text files by default. Configure the allowed extensions as a comma-separated
list in `.env.local`:
@ -38,5 +59,6 @@ Useful checks:
npm run lint
npx tsc --noEmit
npm run build
npm run build:static
npm test
```

View file

@ -0,0 +1,10 @@
export function normalizeReMeApiUrl(value: string): string {
return value.replace(/\/$/, "");
}
export function displayReMeApiEndpoint(
apiUrl: string,
origin?: string,
): string {
return apiUrl || origin || "/";
}

View file

@ -12,10 +12,15 @@ import {
workspaceFileListing,
type WorkspaceFileListing,
} from "./workspace-files";
import { displayReMeApiEndpoint, normalizeReMeApiUrl } from "./api-endpoint";
export const REME_API_URL = (
process.env.NEXT_PUBLIC_REME_API_URL || "http://127.0.0.1:2333"
).replace(/\/$/, "");
export const REME_API_URL = normalizeReMeApiUrl(
process.env.NEXT_PUBLIC_REME_API_URL || "http://127.0.0.1:2333",
);
export const REME_API_ENDPOINT = displayReMeApiEndpoint(
REME_API_URL,
typeof window === "undefined" ? undefined : window.location.origin,
);
const message = (key: TranslationKey, status: number) =>
translate(useLanguageStore.getState().language, key, {
status: String(status),

View file

@ -22,7 +22,7 @@ import {
getAppConfig,
listWorkspaceFiles,
readWorkspaceFile,
REME_API_URL,
REME_API_ENDPOINT,
} from "../api";
import { useI18n } from "../i18n";
import { useWorkspaceStore } from "../store";
@ -54,9 +54,9 @@ const loadWorkspace = () =>
Promise.all([getAppConfig(), listWorkspaceFiles([...extensions])]);
const remeEndpoint = (() => {
try {
return new URL(REME_API_URL).host;
return new URL(REME_API_ENDPOINT).host;
} catch {
return REME_API_URL;
return REME_API_ENDPOINT;
}
})();
@ -340,7 +340,7 @@ export default function FilesNavigator({
/>
))}
</div>
<div className="workspace-path" title={REME_API_URL}>
<div className="workspace-path" title={REME_API_ENDPOINT}>
<span
className={`status-dot ${status === "error" ? "offline" : ""}`}
/>

View file

@ -17,7 +17,7 @@ import {
getReMeStatus,
getReMeVersion,
rebuildReMeIndex,
REME_API_URL,
REME_API_ENDPOINT,
} from "./api";
import { useI18n } from "./i18n";
import type { AppConfig, ReMeResponse } from "./types";
@ -357,7 +357,7 @@ export default function SettingsCenter({
</div>
<div>
<small>{t("apiEndpoint")}</small>
<code>{REME_API_URL}</code>
<code>{REME_API_ENDPOINT}</code>
</div>
</section>
</div>

31
website/index.html Normal file
View file

@ -0,0 +1,31 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Browse, edit, and discuss your local-first ReMe memory workspace."
/>
<link rel="icon" href="/favicon.svg" />
<title>ReMe Workspace</title>
<script>
(function () {
try {
var theme = localStorage.getItem("reme-theme") || "system";
var dark =
theme === "dark" ||
(theme === "system" &&
matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.dataset.theme = dark ? "dark" : "light";
} catch (_error) {
// Local storage can be unavailable in privacy-restricted contexts.
}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/static/main.tsx"></script>
</body>
</html>

View file

@ -7,10 +7,12 @@
},
"scripts": {
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev --force",
"dev:static": "vite --config vite.static.config.ts",
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
"build:static": "vite build --config vite.static.config.ts",
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
"test": "npm run build && node --test tests/*.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
"test": "npm run build && npm run build:static && node --test tests/*.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern dist-static --ignore-pattern .next",
"format": "tsc --noEmit && prettier --write .",
"format:check": "tsc --noEmit && prettier --check .",
"db:generate": "drizzle-kit generate"

11
website/static/main.tsx Normal file
View file

@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "../app/globals.css";
import { ReMeWorkspace } from "../app/workspace";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ReMeWorkspace />
</React.StrictMode>,
);

View file

@ -0,0 +1,26 @@
import {
lazy,
Suspense,
type ComponentType,
type LazyExoticComponent,
} from "react";
type DynamicOptions = {
ssr?: boolean;
};
export default function dynamic<Props extends object>(
loader: () => Promise<{ default: ComponentType<Props> }>,
options?: DynamicOptions,
): ComponentType<Props> {
void options;
const LazyComponent: LazyExoticComponent<ComponentType<Props>> = lazy(loader);
return function DynamicComponent(props: Props) {
return (
<Suspense fallback={null}>
<LazyComponent {...props} />
</Suspense>
);
};
}

View file

@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
displayReMeApiEndpoint,
normalizeReMeApiUrl,
} from "../app/api-endpoint.ts";
test("same-origin API requests retain a visible endpoint", () => {
const apiUrl = normalizeReMeApiUrl("/");
assert.equal(apiUrl, "");
assert.equal(
displayReMeApiEndpoint(apiUrl, "http://127.0.0.1:2333"),
"http://127.0.0.1:2333",
);
assert.equal(displayReMeApiEndpoint(apiUrl), "/");
});
test("configured API endpoint is preferred over the browser origin", () => {
const apiUrl = normalizeReMeApiUrl("http://localhost:8181/");
assert.equal(apiUrl, "http://localhost:8181");
assert.equal(
displayReMeApiEndpoint(apiUrl, "http://localhost:3000"),
"http://localhost:8181",
);
});

View file

@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { readFile, readdir } from "node:fs/promises";
import test from "node:test";
import { fileURLToPath } from "node:url";
test("static build contains the ReMe workspace entry and assets", async () => {
const output = fileURLToPath(new URL("../dist-static/", import.meta.url));
const html = await readFile(
new URL("../dist-static/index.html", import.meta.url),
"utf8",
);
const assets = await readdir(
new URL("../dist-static/assets/", import.meta.url),
);
const javascript = (
await Promise.all(
assets
.filter((name) => name.endsWith(".js"))
.map((name) =>
readFile(new URL(`../dist-static/assets/${name}`, import.meta.url)),
),
)
).join("\n");
assert.match(html, /<title>ReMe Workspace<\/title>/i);
assert.match(html, /<div id="root"><\/div>/i);
assert.match(html, /<script type="module"[^>]+src="\/assets\//i);
assert.ok(
assets.some((name) => name.endsWith(".js")),
output,
);
assert.ok(
assets.some((name) => name.endsWith(".css")),
output,
);
assert.doesNotMatch(javascript, /http:\/\/127\.0\.0\.1:2333/);
});

View file

@ -0,0 +1,32 @@
import react from "@vitejs/plugin-react";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, loadEnv } from "vite";
const staticDir = fileURLToPath(new URL("./static/", import.meta.url));
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
return {
plugins: [react()],
resolve: {
alias: {
"next/dynamic": path.resolve(staticDir, "next-dynamic.tsx"),
},
},
define: {
"process.env.NEXT_PUBLIC_REME_API_URL": JSON.stringify(
env.VITE_REME_API_URL || "/",
),
"process.env.NEXT_PUBLIC_REME_WORKSPACE_EXTENSIONS": JSON.stringify(
env.VITE_REME_WORKSPACE_EXTENSIONS ?? "",
),
},
build: {
outDir: "dist-static",
emptyOutDir: true,
sourcemap: mode !== "production",
},
};
});