Skip to content

Commit ace6a4e

Browse files
feat(website): the browser client, env website new and env website dev (FD-4683)
This PR's change, rebased as one commit onto #445. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 94cfc68 commit ace6a4e

28 files changed

Lines changed: 2258 additions & 40 deletions

‎packages/agentenv-protocol/examples/website/notes/README.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ them. The page shows the universe's time.
99
| `website.toml` | The site's spec: its name, domains, health path, source, seed, collections, personas, roles, routes, state read and clock (see `docs/website-spec.md`). |
1010
| `site.py` | The server: the `Notes` WebsiteEnvironment, declared from `website.toml`, its `@api` routes, and `serve()` on `PORT` (8000). |
1111
| `data/seed.json` | The seed data: two users (`ann`, `ben`), their notes, and `current_user`, the acting user. |
12-
| `web/` | A minimal Vite app (`index.html`, `src/main.js`, `src/style.css`). Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/notes/` alike; opened at a path without its final slash, such as `/website/notes`, the page first reloads itself with it. |
12+
| `web/` | A minimal Vite app (`index.html`, `src/main.js`, `src/style.css`, and `src/site-client.js`, a copy of the SDK's browser client). Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/notes/` alike; opened at a path without its final slash, such as `/website/notes`, the page first reloads itself with it. |
1313
| `Dockerfile` | The server image: Node builds `web/`, then Python installs this repo's `packages/agentenv-protocol` with uvicorn. It installs `curl`, which the gateway's compose health check runs. |
1414
| `env.toml` | The website env as a bundle entry, pointing at `website.toml`. |
1515

@@ -38,7 +38,8 @@ them. The page shows the universe's time.
3838
- **Time.** Notes are stamped with `self.now()`, which follows the gateway's clock once `clock/v1`'s `sync_time` names
3939
it. The frontend reads `X-Universe-Now` from every API response into `universeNow()` and shows it in the header.
4040
It uses this helper rather than `clock_patch=True`, so the page works the same against a server written without the
41-
SDK.
41+
SDK. The helper and the page's `api()`, `session()` and `signIn()` come from `src/site-client.js`, a copy of
42+
`agentenv_protocol/website/client/site-client.js`, which a test keeps it the same as.
4243
- **Readiness.** `@ready_check seeded` holds `/healthz` at 503 until the store has users, for example after a
4344
`data/reset` and before the next `data/add`.
4445

@@ -54,6 +55,10 @@ PYTHONPATH=<repo>/packages/agentenv-protocol/src python site.py
5455
Open http://localhost:8000. To work on the frontend, run `npm run dev` in `web/` while `site.py` runs; Vite proxies
5556
`/api` to port 8000.
5657

58+
Or run `agent-env env website dev` from this folder: it builds `web/` when it is out of date and rebuilds it as it
59+
changes, serves `site.py` with reload, syncs it to a local stand-in for the gateway's clock, loads `data/seed.json`
60+
through the data plane after every reload, and runs the conformance check once.
61+
5762
## Test it
5863

5964
```bash
@@ -88,7 +93,7 @@ the container before using it.
8893
```bash
8994
agent-env env website put --id notes-site \
9095
--server-dockerfile packages/agentenv-protocol/examples/website/notes/Dockerfile \
91-
--server-docker-context . --health-path /healthz
96+
--server-docker-context .
9297
```
9398

9499
Run it from the repo root. `environment_name` comes from the card, which the spec names `notes`. The stored env

‎packages/agentenv-protocol/examples/website/notes/web/src/main.js‎

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import "./style.css";
2+
import { api, followUniverseClock, session, signIn, universeNow } from "./site-client.js";
23

3-
let offsetMs = 0;
44
let filter = "all";
55
let renders = 0;
66
const saving = new Map();
@@ -9,27 +9,6 @@ const landed = new Map();
99
const drafts = new Map();
1010
const CLOCK_REFRESH_MS = 5000;
1111

12-
export function universeNow() {
13-
return new Date(Date.now() + offsetMs);
14-
}
15-
16-
function followUniverse(response) {
17-
const at = Date.parse(response.headers.get("X-Universe-Now") || "");
18-
if (!Number.isNaN(at)) offsetMs = at - Date.now();
19-
}
20-
21-
async function api(path, { method = "GET", body } = {}) {
22-
const response = await fetch(path, {
23-
method,
24-
headers: body === undefined ? {} : { "Content-Type": "application/json" },
25-
body: body === undefined ? undefined : JSON.stringify(body),
26-
});
27-
followUniverse(response);
28-
const data = await response.json().catch(() => null);
29-
if (!response.ok) throw new Error(data?.error || `${method} ${path}: ${response.status}`);
30-
return data;
31-
}
32-
3312
const $ = (id) => document.getElementById(id);
3413

3514
function setDraft(id, kind, value, saved) {
@@ -77,7 +56,7 @@ function queueSave(id, fields) {
7756
}
7857

7958
function showError(error) {
80-
$("error").textContent = error ? error.message : "";
59+
$("error").textContent = error ? error.error || error.message : "";
8160
$("error").hidden = !error;
8261
}
8362

@@ -193,9 +172,9 @@ async function run(action) {
193172
}
194173

195174
async function start() {
196-
const [session, users] = await Promise.all([api("api/session"), api("api/users")]);
175+
const [signedIn, users] = await Promise.all([session(), api("api/users")]);
197176
$("user").replaceChildren(...users.map((user) => new Option(user.name || user.id, user.id)));
198-
let signedInAs = session.user ?? "";
177+
let signedInAs = signedIn.user ?? "";
199178
$("user").value = signedInAs;
200179
$("user").addEventListener("change", (event) => {
201180
const user = event.target.value;
@@ -208,7 +187,7 @@ async function start() {
208187
const failed = pending.find((state) => state.error);
209188
if (failed) throw failed.error;
210189
}
211-
await api("api/sign-in", { method: "POST", body: { user } });
190+
await signIn(user);
212191
signedInAs = user;
213192
drafts.clear();
214193
} finally {
@@ -245,7 +224,7 @@ async function start() {
245224

246225
tick();
247226
setInterval(tick, 1000);
248-
setInterval(() => fetch("healthz").then(followUniverse).catch(() => {}), CLOCK_REFRESH_MS);
227+
followUniverseClock({ path: "healthz", everyMs: CLOCK_REFRESH_MS });
249228
await render();
250229
}
251230

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// The browser client of a site on agentenv_protocol.website: JSON calls to the site's API, the universe's time from
2+
// X-Universe-Now, and the signed-in user. It has no dependencies. Paths are relative to the page, so a site works at
3+
// "/" and under the gateway's "/website/<name>/" alike: a leading "/" is dropped.
4+
5+
let offsetMs = 0;
6+
7+
// An answer other than success: `status` (0 when the server couldn't be reached), and the site's `code` and `error`.
8+
export class ApiError extends Error {
9+
constructor(status, code, error, body) {
10+
super(error);
11+
this.name = "ApiError";
12+
this.status = status;
13+
this.code = code;
14+
this.error = error;
15+
this.body = body;
16+
}
17+
}
18+
19+
// The universe's time: the X-Universe-Now of the latest answer, extrapolated at real speed since it came. An idle
20+
// page keeps it right with followUniverseClock().
21+
export function universeNow() {
22+
return new Date(Date.now() + offsetMs);
23+
}
24+
25+
// Read X-Universe-Now from `path`, the site's health path, every `everyMs`, so universeNow() follows a clock that
26+
// stops or runs at another speed while the page makes no calls. Returns a function that stops it.
27+
export function followUniverseClock({ path = "api/health", everyMs = 5000 } = {}) {
28+
const id = setInterval(() => fetch(relative(path), { credentials: "same-origin" }).then(follow).catch(() => {}), everyMs);
29+
return () => clearInterval(id);
30+
}
31+
32+
function follow(response) {
33+
const at = Date.parse(response.headers.get("X-Universe-Now") || "");
34+
if (!Number.isNaN(at)) offsetMs = at - Date.now();
35+
}
36+
37+
function relative(path) {
38+
return String(path).replace(/^\/+/, "");
39+
}
40+
41+
// Call the site's API: `body` is sent as JSON; the answer's JSON is returned, or an ApiError thrown.
42+
export async function api(path, { method = "GET", body, headers = {} } = {}) {
43+
const url = relative(path);
44+
let response;
45+
try {
46+
response = await fetch(url, {
47+
method,
48+
credentials: "same-origin",
49+
headers: body === undefined ? headers : { "Content-Type": "application/json", ...headers },
50+
body: body === undefined ? undefined : JSON.stringify(body),
51+
});
52+
} catch (cause) {
53+
throw new ApiError(0, "network_error", `${method} ${url}: ${cause?.message || cause}`, null);
54+
}
55+
follow(response);
56+
const text = await response.text();
57+
let data = null;
58+
try {
59+
data = text ? JSON.parse(text) : null;
60+
} catch {
61+
data = text;
62+
}
63+
if (!response.ok) {
64+
const error = data && typeof data === "object" && data.error ? data.error : `${method} ${url}: ${response.status}`;
65+
const code = data && typeof data === "object" && data.code ? data.code : "http_error";
66+
throw new ApiError(response.status, code, error, data);
67+
}
68+
return data;
69+
}
70+
71+
// Who the browser is signed in as: {user, role, signed_in}. A browser with no session is signed in as the site's
72+
// acting user.
73+
export function session() {
74+
return api("api/session");
75+
}
76+
77+
// Sign in as `user` through the site's own sign-in route, `api/sign-in` unless `path` names another.
78+
export function signIn(user, { path = "api/sign-in" } = {}) {
79+
return api(path, { method: "POST", body: { user } });
80+
}
81+
82+
export function signOut() {
83+
return api("api/session", { method: "DELETE" });
84+
}

‎packages/agentenv-protocol/src/agentenv_protocol/website/__init__.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
package, and uvicorn to serve. The class is optional: a server in any language
55
that meets the contract works, which ``python -m agentenv_protocol.website.conformance <url>`` checks. Whether a site
66
is ready is its ``WebsiteReadiness`` answer to ``ready``, which ``website_readiness`` reads of one site and
7-
``env_readiness`` of every site of an env. What a task left a site with is its data plane's ``data/get``."""
7+
``env_readiness`` of every site of an env. What a task left a site with is its data plane's ``data/get``. A site's pages
8+
can call its API with ``client/site-client.js``, a dependency-free ES module (with its ``.d.ts``) to copy into them:
9+
``api()``, ``universeNow()`` from ``X-Universe-Now``, ``session()``, ``signIn()`` and ``signOut()``."""
810

911
from __future__ import annotations
1012

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/** An answer other than success: `status` (0 when the server couldn't be reached), and the site's `code` and `error`. */
2+
export declare class ApiError extends Error {
3+
readonly status: number;
4+
readonly code: string;
5+
readonly error: string;
6+
readonly body: unknown;
7+
constructor(status: number, code: string, error: string, body: unknown);
8+
}
9+
10+
export interface ApiOptions {
11+
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | string;
12+
body?: unknown;
13+
headers?: Record<string, string>;
14+
}
15+
16+
export interface Session {
17+
user: string | null;
18+
role: string;
19+
signed_in: boolean;
20+
}
21+
22+
/** The universe's time: the X-Universe-Now of the latest answer, extrapolated at real speed since it came. */
23+
export declare function universeNow(): Date;
24+
/** Read X-Universe-Now from `path`, the site's health path, every `everyMs` (5000), so universeNow() follows a clock that stops or runs at another speed; returns a function that stops it. */
25+
export declare function followUniverseClock(options?: { path?: string; everyMs?: number }): () => void;
26+
27+
/** Call the site's API at `path`, relative to the page; returns the answer's JSON or throws an ApiError. */
28+
export declare function api<T = any>(path: string, options?: ApiOptions): Promise<T>;
29+
30+
/** Who the browser is signed in as; a browser with no session is signed in as the site's acting user. */
31+
export declare function session(): Promise<Session>;
32+
33+
/** Sign in as `user` through the site's own sign-in route, `api/sign-in` unless `path` names another. */
34+
export declare function signIn<T = { user: string }>(user: string, options?: { path?: string }): Promise<T>;
35+
36+
export declare function signOut(): Promise<Session>;
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// The browser client of a site on agentenv_protocol.website: JSON calls to the site's API, the universe's time from
2+
// X-Universe-Now, and the signed-in user. It has no dependencies. Paths are relative to the page, so a site works at
3+
// "/" and under the gateway's "/website/<name>/" alike: a leading "/" is dropped.
4+
5+
let offsetMs = 0;
6+
7+
// An answer other than success: `status` (0 when the server couldn't be reached), and the site's `code` and `error`.
8+
export class ApiError extends Error {
9+
constructor(status, code, error, body) {
10+
super(error);
11+
this.name = "ApiError";
12+
this.status = status;
13+
this.code = code;
14+
this.error = error;
15+
this.body = body;
16+
}
17+
}
18+
19+
// The universe's time: the X-Universe-Now of the latest answer, extrapolated at real speed since it came. An idle
20+
// page keeps it right with followUniverseClock().
21+
export function universeNow() {
22+
return new Date(Date.now() + offsetMs);
23+
}
24+
25+
// Read X-Universe-Now from `path`, the site's health path, every `everyMs`, so universeNow() follows a clock that
26+
// stops or runs at another speed while the page makes no calls. Returns a function that stops it.
27+
export function followUniverseClock({ path = "api/health", everyMs = 5000 } = {}) {
28+
const id = setInterval(() => fetch(relative(path), { credentials: "same-origin" }).then(follow).catch(() => {}), everyMs);
29+
return () => clearInterval(id);
30+
}
31+
32+
function follow(response) {
33+
const at = Date.parse(response.headers.get("X-Universe-Now") || "");
34+
if (!Number.isNaN(at)) offsetMs = at - Date.now();
35+
}
36+
37+
function relative(path) {
38+
return String(path).replace(/^\/+/, "");
39+
}
40+
41+
// Call the site's API: `body` is sent as JSON; the answer's JSON is returned, or an ApiError thrown.
42+
export async function api(path, { method = "GET", body, headers = {} } = {}) {
43+
const url = relative(path);
44+
let response;
45+
try {
46+
response = await fetch(url, {
47+
method,
48+
credentials: "same-origin",
49+
headers: body === undefined ? headers : { "Content-Type": "application/json", ...headers },
50+
body: body === undefined ? undefined : JSON.stringify(body),
51+
});
52+
} catch (cause) {
53+
throw new ApiError(0, "network_error", `${method} ${url}: ${cause?.message || cause}`, null);
54+
}
55+
follow(response);
56+
const text = await response.text();
57+
let data = null;
58+
try {
59+
data = text ? JSON.parse(text) : null;
60+
} catch {
61+
data = text;
62+
}
63+
if (!response.ok) {
64+
const error = data && typeof data === "object" && data.error ? data.error : `${method} ${url}: ${response.status}`;
65+
const code = data && typeof data === "object" && data.code ? data.code : "http_error";
66+
throw new ApiError(response.status, code, error, data);
67+
}
68+
return data;
69+
}
70+
71+
// Who the browser is signed in as: {user, role, signed_in}. A browser with no session is signed in as the site's
72+
// acting user.
73+
export function session() {
74+
return api("api/session");
75+
}
76+
77+
// Sign in as `user` through the site's own sign-in route, `api/sign-in` unless `path` names another.
78+
export function signIn(user, { path = "api/sign-in" } = {}) {
79+
return api(path, { method: "POST", body: { user } });
80+
}
81+
82+
export function signOut() {
83+
return api("api/session", { method: "DELETE" });
84+
}

‎packages/agentenv-protocol/tests/test_website_notes_example.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,9 @@ def test_the_built_page_loads_its_assets_and_api_under_the_gateways_prefix() ->
237237
assert not [path for path in called if path.startswith("/")], called
238238
assert browser.get(urljoin(page_url, "api/session")).json()["user"] == "ann"
239239
assert [u["id"] for u in browser.get(urljoin(page_url, "api/users")).json()] == ["ann", "ben"]
240+
241+
242+
def test_the_page_uses_the_sdks_browser_client_as_the_package_ships_it() -> None:
243+
shipped = Path(website.__code__.co_filename).parent / "client" / "site-client.js"
244+
assert (NOTES_DIR / "web" / "src" / "site-client.js").read_text(encoding="utf-8") == shipped.read_text(encoding="utf-8")
245+
assert 'from "./site-client.js"' in (NOTES_DIR / "web" / "src" / "main.js").read_text(encoding="utf-8")

0 commit comments

Comments
 (0)