diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c4d63c6..1abf3c9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,6 +36,7 @@ jobs: - nextjs - astro-with-wordpress - functions-php + - live-dashboard - kitchen-sink - migrate-from-cloudflare-pages - migrate-from-vercel diff --git a/README.md b/README.md index d1ce635..225a4e9 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ everything else, use the framework's dev server. | [`nextjs/`](./nextjs/) | Server-rendered Next.js, route handlers, and automatic OpenNext packaging | | [`astro-with-wordpress/`](./astro-with-wordpress/) | Astro built from WordPress through `@spacefast/wordpress` | | [`functions-php/`](./functions-php/) | Native PHP Functions with JSON, form bodies, validation, and verified auth context | +| [`live-dashboard/`](./live-dashboard/) | A no-build page reading team data through a Connectors role, with pinned arguments, a shared cache, and every connector state (Private Beta) | | [`recipes/`](./recipes/) | Canonical prompts, metadata, live outputs, and the public recipe feed | | [`kitchen-sink/`](./kitchen-sink/) | Zero, Pages, PHP, JavaScript, TypeScript, auth, mail, and routing in one project | | [`migrate-from-cloudflare-pages/`](./migrate-from-cloudflare-pages/) | Wrangler build settings and Pages convention files | @@ -54,6 +55,8 @@ agent can customize and publish. The pull-request workflow builds every top-level project through `sf build`, runs the Zero app tests, validates every recipe output, and builds the recipe -manifest, without creating a live version. `migrate-from-netlify` builds its +manifest, without creating a live version. Only recipes are published; +`live-dashboard` needs a team connection with site serving, so it is published +by hand (see its README). `migrate-from-netlify` builds its prebuilt output, because `sf build` has no flag to accept the Netlify features that still need a port. diff --git a/live-dashboard/README.md b/live-dashboard/README.md new file mode 100644 index 0000000..e1ff85a --- /dev/null +++ b/live-dashboard/README.md @@ -0,0 +1,130 @@ +# Live dashboard through a connector + +A one-file dashboard of euro exchange rates. The page has no backend, no build +step, and no API key: it calls the Space's own +`/__spacefast/connectors/rates/` route, and Spacefast answers with your +team's connection. + +> Connectors are in **Private Beta**. Your team needs to be in the Connectors +> program; until it is, `sf connectors create` answers `feature_unavailable`. + +| File | What it does | +| --- | --- | +| `sf.jsonc` | Declares the `rates` role: one `team` connection, two read tools, pinned arguments, and a 15-minute cache | +| `index.html` | Imports `/__spacefast/client.js`, watches both tools every 60 seconds, and renders each state the route answers. Each sparkline reads one day by pointer or by keyboard (focus it, then use the arrow keys, Home and End) | +| `styles.css` | Light and dark styles | + +The data comes from [Frankfurter](https://frankfurter.dev), a free, open-source +API for European Central Bank reference rates. It needs no account and no key, +and it publishes an OpenAPI spec, so anyone can reproduce this example. + +## Set it up + +You need `spacefast` 0.9.0 or later. Run these from this directory, signed in +to the team that will own the Space. + +```sh +# 1. Register the connector from its OpenAPI spec. +sf connectors create https://api.frankfurter.dev/v1/openapi.json --slug frankfurter + +# 2. Connect it for the team. The API takes no credential, so this connects at once. +sf connectors connections connect frankfurter --owner team + +# 3. Publish. A role with exactly one eligible connection binds itself. +sf publish + +# 4. Let the connection serve sites. Owners and admins turn it on: +sf connectors connections update --serving +# Members ask instead, and an owner or admin approves: +sf connectors serving create rates +``` + +`sf connectors connections list` prints the connection id, and +`sf connectors bindings` shows whether `rates` is bound and ready. + +Team roles serve Spaces that need a sign-in. To let anyone with the share link +(or the public) read through this role, a team owner or admin also runs: + +```sh +sf connectors public-serving set rates --enable +``` + +## What the page proves + +- **No key in the browser.** The page only names a role and a tool. The + connection and its credential stay with Spacefast. +- **Pinned arguments.** `base` and `symbols` are fixed in `sf.jsonc` and applied + on the server. A page that sends a different value gets + `connector_arg_pinned`, so this page can only read the four rates it declares. +- **A tool allowlist.** The role lists two read tools. Any other tool on the + connector answers `connector_tool_not_allowed`. +- **One shared read.** Every viewer asking the same question in the same + 15 minutes shares one upstream request (`cache: "15m"`). The 60-second + refresh on the page only checks that shared answer. The 30-day window is + worked out at each refresh in UTC days, so a tab left open overnight moves to + the new day's window and keeps sharing it with new visitors. When the answer came + from that shared read, the footer shows when it was fetched upstream + (`cachedAt`); otherwise it shows when the page received it. +- **Real states.** Before anything is connected, the route answers a state + instead of data, and the page shows its message: + +| State | When | What the page shows | +| --- | --- | --- | +| `loading` | First request in flight | "Reading rates…" | +| `ok` | Bound, connected, serving | Four rate tiles with 30-day sparklines | +| `unbound` | No connection bound to `rates` yet | "The site owner needs to connect this service." | +| `connect_required` | A role needs the viewer's own account (visitor roles) | The message and a **Connect** button to `connectUrl` | +| `forbidden` (HTTP 403, `connection_forbidden`) | Site serving is off, public serving is not allowed, or team policy blocks the connector | "You do not have permission to use this connection." | +| Any other error | The service or route is unavailable | The route's message and **Try again** | + +`forbidden` arrives as an RFC 9457 problem, so the client reports it as +`state: "error"` with `error.code === "connection_forbidden"`. The tile data +from the last good answer stays on screen when a refresh fails. + +The two tools are separate reads with separate states. If the 30-day history +read is loading, waiting for approval, needs a connection, or fails, the rate +tiles still show and the history read's own message takes the sparklines' +place. + +## The same page in React + +With a build step, install the client and let `sf dev` or `sf publish` write +the typed `spacefast.generated.ts` from this role (or run `sf client`): + +```sh +npm install @spacefast/client react +``` + +```tsx +import { useConnector } from "@spacefast/client/react"; +import { space } from "./spacefast.generated"; + +export function Rates() { + const latest = useConnector(space.connectors.rates["currentRates.getLatest"], {}, { every: "60s" }); + + if (latest.state === "loading") return

Reading rates…

; + if (!latest.data) return

{latest.message}

; + return ( + + ); +} +``` + +The generated types leave out the pinned `base` and `symbols`, so the +arguments object is empty. + +## Boundaries + +- `/__spacefast/client.js` and the connectors route exist only on a published + Space. Opened from disk or a plain static server, the page says so instead of + showing rates. `sf build` still packs it. +- This repository's workflow builds the example but never publishes it. The + Space needs a team in the Connectors program and a connection with site + serving, which only a person with those rights can set up, so publishing is + the manual steps above. diff --git a/live-dashboard/index.html b/live-dashboard/index.html new file mode 100644 index 0000000..7a75d5d --- /dev/null +++ b/live-dashboard/index.html @@ -0,0 +1,285 @@ + + + + + + Live Rates + + + +
+
+

Connectors · Private Beta

+

Euro, live

+

+ Reference rates from the European Central Bank, read through your team's + rates connector role. No backend, and no key in this page. +

+
+ +
+

Reading rates…

+
+ +
+
+ + + + diff --git a/live-dashboard/sf.jsonc b/live-dashboard/sf.jsonc new file mode 100644 index 0000000..b20bd39 --- /dev/null +++ b/live-dashboard/sf.jsonc @@ -0,0 +1,35 @@ +{ + "$schema": "https://spacefast.com/schemas/sf.json", + "name": "Live Rates", + "cleanUrls": true, + "listing": false, + "meta": { + "title": "Live Rates", + "description": "Euro exchange rates read through a team connector, with no backend and no keys in the page.", + }, + // One team role. The page calls /__spacefast/connectors/rates/; + // Spacefast answers with the team's connection, so no key reaches the browser. + "connectors": { + "rates": { + "connector": "frankfurter", + "as": "team", + "tools": ["currentRates.getLatest", "historicalRates.getStartDate"], + // Pinned on the server. A page that sends another base or symbols is refused. + "args": { + "currentRates.getLatest": { "base": "EUR", "symbols": ["USD", "GBP", "JPY", "CHF"] }, + "historicalRates.getStartDate": { "base": "EUR", "symbols": ["USD", "GBP", "JPY", "CHF"] }, + }, + // Every viewer shares one upstream read per tool and arguments for 15 minutes. + "cache": "15m", + }, + }, + "headers": [ + { + "source": "/*", + "headers": [ + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + ], + }, + ], +} diff --git a/live-dashboard/styles.css b/live-dashboard/styles.css new file mode 100644 index 0000000..f62c4f8 --- /dev/null +++ b/live-dashboard/styles.css @@ -0,0 +1,194 @@ +:root { + color-scheme: light; + --surface: #fcfcfb; + --tile: #ffffff; + --line: #e4e3df; + --text: #0b0b0b; + --text-2: #52514e; + --accent: #2a78d6; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + --surface: #1a1a19; + --tile: #232322; + --line: #353533; + --text: #f0efec; + --text-2: #c3c2b7; + --accent: #3987e5; + } +} + +:root[data-theme="dark"] { + color-scheme: dark; + --surface: #1a1a19; + --tile: #232322; + --line: #353533; + --text: #f0efec; + --text-2: #c3c2b7; + --accent: #3987e5; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--surface); + color: var(--text); + line-height: 1.5; +} + +main { + max-width: 56rem; + margin: 0 auto; + padding: 3rem 16px; +} + +.eyebrow { + margin: 0; + color: var(--text-2); + font-size: 0.8125rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +h1 { + margin: 0.25rem 0 0.5rem; + font-size: clamp(2rem, 6vw, 3rem); + line-height: 1.1; +} + +.lede { + max-width: 40rem; + margin: 0 0 2rem; + color: var(--text-2); +} + +code { + font-size: 0.9em; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 1rem; +} + +.tile { + padding: 1rem 1rem 0.75rem; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--tile); +} + +.tile h2 { + margin: 0; + color: var(--text-2); + font-size: 0.875rem; + font-weight: 500; +} + +.value { + margin: 0.25rem 0 0; + font-size: 2rem; + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.meta { + margin: 0 0 0.5rem; + color: var(--text-2); + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; +} + +.tile svg { + display: block; + width: 100%; + height: 3rem; + overflow: visible; + cursor: crosshair; +} + +.tile path { + fill: none; + stroke: var(--accent); + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} + +.tile .cursor { + stroke: var(--text-2); + stroke-width: 1; + opacity: 0; +} + +.tile .cursor.on { + opacity: 1; +} + +.notice { + padding: 1.5rem; + border: 1px dashed var(--line); + border-radius: 12px; + color: var(--text-2); +} + +.notice h2 { + margin: 0 0 0.25rem; + color: var(--text); + font-size: 1.125rem; +} + +.notice p { + margin: 0.25rem 0 0; +} + +.button { + display: inline-block; + margin-top: 0.5rem; + padding: 0.5rem 0.875rem; + border-radius: 8px; + background: var(--accent); + color: #fff; + font-weight: 500; + text-decoration: none; +} + +footer { + margin-top: 1.5rem; + color: var(--text-2); + font-size: 0.8125rem; +} + +footer strong { + display: block; + color: var(--text); + font-weight: 500; +} + +.notice.small { + margin-top: 1rem; + padding: 0.875rem 1rem; +} + +.notice.small h2 { + font-size: 0.9375rem; +} + +.stale { + margin: 0.75rem 0 0; + color: var(--text-2); + font-size: 0.8125rem; +} + +.tile svg:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 4px; + border-radius: 4px; +}