diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2dc1dfb..fabc52d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,17 @@ jobs: restore-keys: ${{ runner.os }}-pnpm- - run: pnpm install --frozen-lockfile + + # Cache the AS Lite tarball across runs -- 61 MB pulled from a GH + # release on every run otherwise. Key on the pinned version + + # checksum so a bundle bump invalidates the cache automatically. + - name: Cache AdvantageScope Lite bundle + uses: actions/cache@v4 + with: + path: packages/web/.advantagescope-cache + key: advantagescope-${{ hashFiles('packages/web/advantagescope/version.txt', 'packages/web/advantagescope/checksums.txt') }} + - run: pnpm fetch:advantagescope + - run: pnpm typecheck - run: pnpm build # web must build before worker (worker bundles web/dist via [assets]) - run: pnpm test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3a9dc8f..bfbe3cf 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -37,6 +37,17 @@ jobs: restore-keys: ${{ runner.os }}-pnpm- - run: pnpm install --frozen-lockfile + + # Cache the AS Lite tarball across deploys -- 61 MB pulled from a + # GH release on every run otherwise. Key on the pinned version + + # checksum so a bundle bump invalidates the cache automatically. + - name: Cache AdvantageScope Lite bundle + uses: actions/cache@v4 + with: + path: packages/web/.advantagescope-cache + key: advantagescope-${{ hashFiles('packages/web/advantagescope/version.txt', 'packages/web/advantagescope/checksums.txt') }} + - run: pnpm fetch:advantagescope + - run: pnpm typecheck - run: pnpm test - run: pnpm lint diff --git a/.gitignore b/.gitignore index 1635499..5caf511 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,12 @@ package-lock.json # setup.sh backs up wrangler.toml on first successful run wrangler.toml.bak +# AdvantageScope Lite bundle is fetched at install time from a tarball +# produced by packages/web/scripts/publish-advantagescope-bundle.mjs. +# Pinned by packages/web/advantagescope/version.txt + checksums.txt. +packages/web/public/advantagescope/ +packages/web/.advantagescope-cache/ + # Local agent / review artifacts (session-scoped worktrees + per-run # reviewer JSON dumps). .claude/ diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md new file mode 100644 index 0000000..9249734 --- /dev/null +++ b/ATTRIBUTION.md @@ -0,0 +1,81 @@ +# Attribution + +RavenScope embeds and redistributes third-party software. Full notices +below. + +## AdvantageScope (embedded viewer) + +The in-browser session viewer under `/sessions/:id/view` is +**AdvantageScope Lite**, served verbatim as a static bundle at +`/v/:id/*`. Source: . + +AdvantageScope is distributed under a BSD-3-Clause-style license: + +> Copyright (c) 2021-2025 Littleton Robotics. All rights reserved. +> +> Redistribution and use in source and binary forms, with or without +> modification, are permitted provided that the following conditions are met: +> +> - Redistributions of source code must retain the above copyright +> notice, this list of conditions and the following disclaimer. +> - Redistributions in binary form must reproduce the above copyright +> notice, this list of conditions and the following disclaimer in the +> documentation and/or other materials provided with the distribution. +> - Neither the name of Littleton Robotics, FRC 6328 ("Mechanical Advantage"), +> AdvantageScope, nor the names of other AdvantageScope contributors may be +> used to endorse or promote products derived from this software without +> specific prior written permission. +> +> THIS SOFTWARE IS PROVIDED BY LITTLETON ROBOTICS AND OTHER ADVANTAGESCOPE +> CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT +> NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY NONINFRINGEMENT +> AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL +> LITTLETON ROBOTICS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +> INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +> NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +> DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +> OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +> NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +> EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +### Local modifications + +RavenScope applies one ~8-line patch to AdvantageScope's source during +the bundle build, committed at +[`packages/web/advantagescope/main.ts.patch`](packages/web/advantagescope/main.ts.patch). +The patch adds a `?log=` URL query parameter to AdvantageScope +Lite's `initHub()` so that the session viewer can deep-link directly +into a single log without the user interacting with the download +popup. Applied at build time by +[`packages/web/scripts/publish-advantagescope-bundle.mjs`](packages/web/scripts/publish-advantagescope-bundle.mjs). + +The patch is non-invasive and upstreamable; when AdvantageScope ships +native URL-based auto-open support, the patch will be removed. + +The pinned AdvantageScope release is recorded in +[`packages/web/advantagescope/version.txt`](packages/web/advantagescope/version.txt). + +### Bundled third-party dependencies + +AdvantageScope in turn redistributes numerous npm packages. The +complete transitive notice file is generated during AdvantageScope's +own build by `getLicenses.mjs` and lands at +`lite/static/docs/build/licenses.html` within the bundle. That file +ships verbatim to `/advantagescope/docs/build/licenses.html` on the +RavenScope origin when the bundle is deployed. + +## AdvantageScopeAssets (field and robot 3D models, joystick layouts) + +AdvantageScope's bundled default assets (field models, robot models, +joystick layouts) are downloaded from the +[`AdvantageScopeAssets`](https://github.com/Mechanical-Advantage/AdvantageScopeAssets) +`archive-v1` release by AdvantageScope's +[`bundleLiteAssets.mjs`](https://github.com/Mechanical-Advantage/AdvantageScope/blob/main/bundleLiteAssets.mjs) +during AdvantageScope's install step. RavenScope then redistributes +these assets unchanged under +`packages/web/public/advantagescope/bundledAssets/`. + +Licenses for individual asset files are carried per-asset in their +respective `config.json` (field `sourceUrl`) or alongside each asset +in the AdvantageScopeAssets repository. See that repository for the +authoritative notices. diff --git a/README.md b/README.md index 84be425..202aabe 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ Lightweight hosted telemetry bucket for FRC teams, built as a stripped-down alternative to [`RavenBrain`](https://github.com/RunnymedeRobotics1310/RavenBrain). Runs entirely on Cloudflare (Workers, D1, R2, Durable Objects) with magic-link email auth. Sign in with an email, mint an API key, point RavenLink at it, -and your match telemetry streams into a hosted session list. Download any -session as a `.wpilog` and open it in AdvantageScope. +and your match telemetry streams into a hosted session list. Open any +session directly in an embedded AdvantageScope Lite viewer, or download +it as a `.wpilog` for use with the desktop app. - No passwords. No FRC-API calls. No database to host. - Free-tier friendly: one Worker, one D1, one R2, two Durable Object classes, @@ -269,9 +270,39 @@ sign-in. the user/workspace on request, then the address signs up fresh. - **30-day cookie TTL, no server-side revocation.** A stolen cookie is valid until it expires, or until `SESSION_SECRET` is rotated. -- **No AdvantageScope deep-link.** v1 ships with `.wpilog` download - only. A future initiative may add an `advantagescope://` launch - button or an in-browser AdvantageScope port. +- **Embedded AdvantageScope Lite is beta-track.** The viewer binds to + AdvantageScope's `v27.x` (2027-targeted) Lite build. The pinned tag + lives in [`packages/web/advantagescope/version.txt`](packages/web/advantagescope/version.txt) + and is bumped via the workflow below. Video tab, Phoenix + Diagnostics, Hoot log format, XR, pop-out windows, and tab-layout + JSON export are inherited omissions. See + [`ATTRIBUTION.md`](ATTRIBUTION.md) for license + patch details. + +### Embedded viewer: bump AdvantageScope versions + +Clicking "Open viewer" on a session loads AdvantageScope Lite in an +iframe at `/v/:id/`. The Lite bundle is built from a pinned +AdvantageScope tag by a local developer ritual (not in CI — the full +build needs Emscripten 4.0.12). To bump: + +```bash +# one-time per machine: install emsdk 4.0.12 +git clone https://github.com/emscripten-core/emsdk.git ~/src/emsdk +cd ~/src/emsdk && ./emsdk install 4.0.12 && ./emsdk activate 4.0.12 +source ~/src/emsdk/emsdk_env.sh + +# edit packages/web/advantagescope/version.txt, set `as=` and `bundle=` + +# build + tar + update checksums.txt +AS_PATH=~/src/1310/AdvantageScope pnpm publish:advantagescope-bundle + +# sanity: re-fetch from local cache and verify +pnpm fetch:advantagescope +pnpm -F @ravenscope/web build + +# optionally: publish the tarball to a RavenScope GitHub release and +# set release-url in version.txt so CI / fresh clones can download it. +``` ## Ergonomic target: R8 measurement diff --git a/docs/brainstorms/2026-04-23-001-feat-embed-advantagescope-requirements.md b/docs/brainstorms/2026-04-23-001-feat-embed-advantagescope-requirements.md new file mode 100644 index 0000000..9db8468 --- /dev/null +++ b/docs/brainstorms/2026-04-23-001-feat-embed-advantagescope-requirements.md @@ -0,0 +1,255 @@ +# Embed AdvantageScope in RavenScope web UI — requirements + +**Date:** 2026-04-23 +**Status:** requirements, ready for `/ce-plan` +**Scope classification:** Deep — feature (existing product shape anchors decisions) + +## Problem + +Today a RavenScope user downloads a session's `.wpilog` and opens it in the +AdvantageScope desktop app. That works, but it requires every teammate who +wants to look at post-match data to install a multi-hundred-MB Electron app. +For casual viewing (mentors on phones, students on a locked-down school +laptop, a quick glance from a scouting tablet) this is a meaningful friction +point and blunts RavenScope's "signup-and-go" positioning. + +We want logs to open **inline** in the browser, with the same viewing +experience (timeline, line graphs, 3D field, mechanism views, tables, +console, odometry) that teams already know from desktop AdvantageScope. + +## Non-problem / out of scope + +- **Building our own log viewer.** AdvantageScope is the reference viewer for + WPILog; re-implementing even a small subset is a multi-month project and + drifts behind upstream forever. +- **Replacing the download flow.** `Download .wpilog` stays — users who *do* + have desktop AdvantageScope and want to mix sessions across logs will keep + using it. Inline viewing is additive. +- **Live / realtime streaming into the viewer.** Session is already complete + by the time the user views it; we only need AdvantageScope's historical + data source path, not NT4 / live server. +- **Video tab, Phoenix Diagnostics, Hoot log format, AdvantageScope XR, + pop-out windows, tab-layout JSON export.** These are the documented + omissions of AdvantageScope Lite; we inherit those omissions. + +## Users & primary outcome + +- **Primary actor:** an FRC team member signed into RavenScope on any device + with a modern browser (desktop, iPad, Chromebook). +- **Core outcome:** from a session row in RavenScope, click once, land in a + full-featured AdvantageScope view of that session's data. Zero install, + zero download, no additional sign-in. + +## Solution shape + +Embed **AdvantageScope Lite** (the browser-runnable variant shipped under +`lite/` in the AdvantageScope repo, BSD-3-Clause) as a static asset bundle +inside RavenScope's web origin, and re-implement the tiny HTTP contract +Lite expects on the RavenScope Worker so that every iframe instance sees +exactly one log: the session the user is viewing. + +### Why this shape works + +AdvantageScope Lite was designed to be served from a generic HTTP host +with a `WEBROOT` path prefix. Its frontend uses **only relative-URL +fetches** — two endpoints total: + +- `GET /assets` + `GET /assets/` — field / robot / joystick models +- `GET /logs?folder=

` + `GET /logs/?folder=

` — log directory + listing and log bytes + +Everything else is static files under `lite/static/`. This means we can +mount Lite at a session-scoped path prefix like `/v//` and the +four endpoints become: + +``` +/v// → serves lite/static/index.html +/v//bundles/... → serves lite/static/bundles/... +/v//www/... → serves lite/static/www/... +/v//assets → RavenScope Worker: returns the default FRC + asset manifest (+ any per-team custom assets + in a later phase) +/v//assets/

→ RavenScope Worker / R2: returns the asset + file bytes +/v//logs → RavenScope Worker: returns a single-entry + list for this one session: [{ name: ".wpilog", + size: N }] +/v//logs/ → RavenScope Worker: streams the session's + cached WPILog bytes from R2 (reusing the + existing `/api/sessions/:id/wpilog` logic) +``` + +Because Lite's fetches are relative to the current page, the `` +acts as both URL scope *and* context for the Worker: the Worker routes +`/v/:id/*` and always knows which session this particular iframe is for. + +### Deployment / build pipeline + +1. **Pin an AdvantageScope release tag** in `packages/web/advantagescope/version.txt` + (or equivalent). Start with whatever `v27.x` is current at planning time. +2. **Install-time fetch script** (Node, runs from `packages/web` postinstall + or an explicit `pnpm fetch:advantagescope`): + - Downloads the AdvantageScope Systemcore release artifact from GitHub + releases for the pinned tag. + - Extracts `lite/static/**` into `packages/web/public/advantagescope/` + (served as static assets by the Worker's web handler). + - Separately downloads `AllAssetsDefaultFRC.zip` from + AdvantageScopeAssets releases (public, stable URL) and extracts into + a staging dir that gets uploaded to R2 under a versioned prefix + (`advantagescope-assets/v1/...`). One-time; the Worker's `/assets` + handler serves from that prefix. +3. **Verifiable upgrade path.** Bumping the version = edit the pinned tag, + re-run the fetch script, review the diff, commit. No AS source in the + repo. +4. **License compliance.** BSD-3-Clause requires reproduction of the + copyright notice and license text. Ship an `ATTRIBUTION.md` (or add to + existing README) that lists AdvantageScope + all bundled AdvantageScope + assets and their licenses. + +### Auth & origin model + +- Lite is served from the **same origin** as RavenScope, so the existing + signed cookie already protects the `/v/:id/*` routes. +- The Worker enforces that the authenticated user owns (or has access to) + `:id` for all four dynamic endpoints — same policy as + `/api/sessions/:id/wpilog` today. +- No cross-origin handshake, no signed URLs, no extra auth token for the + iframe. + +### UX entry point + +On the session detail page, add a primary action next to +`Download .wpilog`: + +- **"Open viewer"** → full-screen route `/sessions/:id/view` that hosts an + iframe at `/v/:id/` with a thin RavenScope chrome (session name + close + button in a small header bar, or simply an unobtrusive overlay close). + +### Auto-loading the one log (v1 UX wart) + +AdvantageScope Lite today has no URL-param auto-open: the user must go +File → Download Logs → type a folder path → pick the file. For a session +viewer where there is exactly one log that the user already selected by +navigating to this page, that's clearly wrong. + +**Recommended v1 approach:** inject a tiny RavenScope bootstrap script +into `lite/static/index.html` during the install-time extraction step. +The script: + +- On DOMContentLoaded, uses Lite's existing in-page message plumbing + (the same postMessage channel the download popup uses to tell the hub + to open files) to dispatch a synthetic "open-files" message for the + single known log in this session, using a constant folder path (e.g. + `"ravenscope"`). +- The Worker's `/v/:id/logs/*` handler ignores the folder parameter and + always returns the one session log. + +This keeps AdvantageScope's source untouched and is the minimum patch +needed to make single-session deep-linking work. If the upstream +maintainers accept a proper `?log=...&folder=...` URL param, we can drop +the bootstrap script in a future upgrade. + +**Fallback if injection turns out to be brittle:** upstream a patch, or +carry a tiny diff applied by the install script. Decision deferred to +planning. + +## Success criteria + +1. From a session detail page, one click lands the user in a fully + functional AdvantageScope view of that session's data, with no + additional login, no download, no install. +2. Line graph, 3D field, table, console, odometry, mechanism, and + joystick tabs all render correctly out of the box for a session whose + WPILog contains the typical signals RavenLink emits (verified with + the committed sample session and the RavenScope seed dataset). +3. The AdvantageScope bundle upgrade workflow is a single command + (`pnpm fetch:advantagescope`) plus a commit, reproducible in CI, with + no manual file copy. +4. No AdvantageScope source files live in the RavenScope repo. Only the + pinned tag, the install script, the small bootstrap patch (if used), + and `ATTRIBUTION.md`. +5. Auth enforcement on `/v/:id/*` is byte-identical to the policy on + `/api/sessions/:id/wpilog` — a non-owner gets 403/404 on the iframe + endpoints, which means the embedded AS instance simply shows its + own "file not found" state. +6. No measurable impact on RavenScope cold-start time (bundle is + cache-friendly static assets on Workers). + +## Scope boundaries + +### In v1 + +- Embed AS Lite pinned to a specific release; serve from Worker static + assets under `/v/:id/*`. +- Ship default FRC asset bundle (fields, example robots, joysticks) in + R2 under a versioned prefix. Per-team custom asset upload is **not** + in v1. +- Auto-open the current session's single log via injected bootstrap + script (or minimal patch if bootstrap proves unreliable). +- Full-screen viewer route `/sessions/:id/view` linked from session + detail with an "Open viewer" button. +- `ATTRIBUTION.md` + README mention of AdvantageScope embedding. + +### Deferred for later + +- **Per-team custom asset upload UI.** Teams with custom fields/robots + currently need the desktop app or a custom deploy; we can add this + behind a settings page once v1 lands and usage signals demand it. +- **Multi-session merge.** AS Lite supports merging multiple logs; our + `/logs` always returns one. Later we could allow selecting 2+ sessions + from the RavenScope session list and passing them as a merge set. +- **Deep link to a specific tab / field / timestamp.** Would require a + slightly larger URL contract (upstream change ideal). +- **Upstream contribution** of a proper `?log=&folder=` URL-based + auto-open to AdvantageScope Lite. Worth opening an issue with the + Mechanical-Advantage team once v1 ships, but not blocking. + +### Outside this product's identity + +- **Becoming a general AdvantageScope host.** RavenScope embeds Lite + *for RavenScope sessions*. We do not expose a generic "upload any + WPILog and view it" surface — that's a different product and would + distract from the telemetry-bucket identity. +- **Editing / annotating logs.** Viewing only. Any write path lives in + the existing RavenScope session model, not in the viewer. + +## Dependencies & assumptions + +### Hard dependencies + +- AdvantageScope Lite ships a stable-enough HTTP contract (`/assets`, + `/logs`) that we can target. Verified by reading `lite/lite_server.py` + and the Lite frontend at pinned tag. +- BSD-3-Clause permits redistribution of built artifacts with attribution. + Verified. +- RavenScope Worker's session-owner authZ check is reusable from the + existing `/api/sessions/:id/wpilog` route. + +### Assumptions worth flagging at plan time + +- **AdvantageScope Lite is currently flagged 2027 (v27.x) beta and + Systemcore-oriented.** We're binding to an actively-moving codebase. + Risk mitigated by pinning releases and testing the upgrade script + against a known-good sample session before each version bump. +- **The `AllAssetsDefaultFRC.zip` artifact URL at + `github.com/Mechanical-Advantage/AdvantageScopeAssets/releases/...` + is assumed stable.** If that URL pattern changes we update the fetch + script. +- **The Cloudflare Workers static-asset size budget accommodates + `lite/static/**`** (a few MB of minified JS + CSS + bundled assets). + Large asset files (field models, bundledAssets/ images) may be + better served from R2 rather than Workers static assets — decision + belongs in planning. +- **Lite's `hub.ts` will happily accept a synthetic `open-files` + postMessage from our bootstrap script.** Low-risk based on reading + `src/main/lite/main.ts:219`, but needs a proof-of-concept spike in + the plan's first step. +- **No user is on a browser that blocks same-origin iframes from + accessing the enclosing page's cookies under Lax sameSite.** Standard + assumption for a SPA on one origin. + +## Handoff + +Next step: `/ce-plan` against this document to produce an implementation +plan with step-by-step tasks, a concrete spike for the bootstrap-script +auto-open, the install script shape, and R2 layout for bundled assets. diff --git a/docs/plans/2026-04-23-003-feat-embed-advantagescope-plan.md b/docs/plans/2026-04-23-003-feat-embed-advantagescope-plan.md new file mode 100644 index 0000000..4c6dc3b --- /dev/null +++ b/docs/plans/2026-04-23-003-feat-embed-advantagescope-plan.md @@ -0,0 +1,1088 @@ +--- +title: "feat: Embed AdvantageScope Lite in RavenScope web UI" +type: feat +status: active +date: 2026-04-23 +deepened: 2026-04-23 +origin: docs/brainstorms/2026-04-23-001-feat-embed-advantagescope-requirements.md +--- + +# feat: Embed AdvantageScope Lite in RavenScope web UI + +## Overview + +Embed the browser-runnable variant of AdvantageScope (AdvantageScope Lite, BSD-3, +shipped under `lite/static/` in the AdvantageScope repo) inside the RavenScope +web UI so users can open a RavenScope session's WPILog in a full-featured viewer +with one click — no desktop install, no separate download. + +The entire embed is built on two observations: + +1. AS Lite's frontend only needs four HTTP endpoints from its host (`/assets`, + `/assets/

`, `/logs`, `/logs/`), and its fetches are all **relative**. + This means the whole app can be scoped to a path prefix we control. +2. Mounting Lite under `/v/:id/*` lets the Worker treat `:id` as both URL scope + and session identity — every dynamic handler knows which session it's serving + without any extra routing param, postMessage handshake, or signed URL. + +--- + +## Problem Frame + +Today, the only way to visualize a RavenScope session's telemetry is "Download +.wpilog → open in desktop AdvantageScope." That works, but it requires every +teammate who wants to look at post-match data to install the Electron app. +Mentors on phones, students on locked-down school laptops, scouting tablets — +these users either install nothing or have no option. + +The fix is to expose the viewer *inside* the web app. Building our own is +multi-month; AdvantageScope already has a browser variant. We ship that. + +See origin: `docs/brainstorms/2026-04-23-001-feat-embed-advantagescope-requirements.md`. + +--- + +## Requirements Trace + +- R1. One click from a session detail page lands the user in a fully functional + AdvantageScope view of that session's data, with no additional login, no + download, no install. (origin success criterion 1) +- R2. Line graph, 3D field, table, console, odometry, mechanism, and joystick + tabs render correctly out of the box for a session whose WPILog contains the + typical signals RavenLink emits. (origin success criterion 2) +- R3. The AdvantageScope bundle upgrade workflow is a single command + (`pnpm fetch:advantagescope`) plus a commit, reproducible in CI, no manual + file copy. (origin success criterion 3) +- R4. No AdvantageScope source files in the RavenScope repo. Only the pinned + tag, the install script, a small bootstrap patch (if used), and + `ATTRIBUTION.md`. (origin success criterion 4) +- R5. Auth enforcement on `/v/:id/*` is byte-identical to the policy on + `/api/sessions/:id/wpilog` — a non-owner gets 404 on the iframe endpoints, + and the embedded AS instance simply shows its own "file not found" state. + (origin success criterion 5) +- R6. No measurable impact on RavenScope cold-start time. (origin success + criterion 6) + +--- + +## Scope Boundaries + +- No per-team custom asset upload UI in this plan (explicitly deferred in + origin — will ship a later phase once v1 lands and usage signals demand it). +- No multi-session merge through the embedded viewer. `/v/:id/logs` always + returns one file: the session in the URL. +- No deep-linking to a specific AS tab, field, or timestamp. That would need a + larger URL contract (upstream change ideal). +- No live / realtime NT4 streaming into the viewer. Session is complete by the + time the user views it — historical data source only. +- No video tab, Phoenix Diagnostics, Hoot format, AdvantageScope XR, pop-out + windows, or tab-layout JSON export. These are documented omissions of AS + Lite and we inherit them. +- No general "upload-any-WPILog-and-view-it" surface. RavenScope embeds Lite + *for RavenScope sessions* — not as a generic log host. (origin: "Outside + this product's identity") + +### Deferred to Follow-Up Work + +- **Upstream contribution of `?log=&folder=` URL-param auto-open to AS Lite.** + Worth opening an issue with the Mechanical-Advantage team once v1 ships, so + the bootstrap-script shim (U1, U6) can eventually be dropped. Separate + effort, not in this plan. +- **Institutional-learnings captures.** Several decisions here (same-origin + iframe posture, install-time artifact fetch + checksum pattern, BSD-3 + attribution convention) are strong candidates for the team's first entries + in a `docs/solutions/` store. Deferred to a follow-up chore. + +--- + +## Context & Research + +### Relevant Code and Patterns + +- `packages/worker/src/routes/wpilog.ts` — the route whose auth posture and + R2 streaming pattern `/v/:id/logs/` must replicate byte-for-byte. + Workspace-scoped auth (not user-scoped): `and(eq(sessions.id, id), + eq(sessions.workspaceId, user.workspaceId))`. Cache-Control is + `private, max-age=0, must-revalidate`. +- `packages/worker/src/routes/sessions.ts` — five more instances of the same + inline workspace-scope pattern; candidate for helper extraction (U4). +- `packages/worker/src/storage/r2.ts` — the single choke point for all R2 I/O. + Every op is wrapped in `chargeOrThrow` for quota accounting (Class A/B ops, + bytes). Transparent gzip on write/read via `gzipEncode` / + `readPlainBlobStream`. Follow this module for the new asset handler. +- `packages/worker/src/storage/keys.ts` — key-prefix helpers + (`wpilogKey`, `batchKey`, etc.). Add `advantagescopeAssetKey(version, path)`. +- `packages/worker/src/auth/require-cookie-user.ts` + `auth/user.ts` — the + Hono middleware chain the new route group mounts under. `ContextVariableMap` + augmentation exposes `c.var.user`. +- `packages/worker/src/index.ts:13–` — route registration pattern. Sub-apps + declared as `new Hono<{ Bindings: Env }>()` per feature, mounted via + `app.route(prefix, subApp)`. +- `packages/worker/wrangler.toml:6–15` — Workers Static Assets config; SPA + fallback; `run_worker_first = ["/api/*"]`. Must become + `["/api/*", "/v/*"]`. +- `packages/web/src/routes/session-detail.tsx:89–91` — where the "Open + viewer" button slots in next to "Download .wpilog". +- `packages/web/src/app.tsx:21–32` — React Router v6 route table; `AuthGate` + wrap pattern for protected routes. +- `packages/web/src/lib/api.ts:109` — `sessionDownloadUrl(id)` helper; + parallel helper `sessionViewerUrl(id)` follows the same convention. +- `scripts/setup.sh` — shebang-bash style and R2 bucket verification precedent + (confirms "no public R2" is actively enforced). + +### Institutional Learnings + +- `docs/solutions/` does not exist yet; this feature is greenfield for + institutional knowledge on AS embed, install-time artifact fetch, and + Workers static-asset posture. Capture these after v1 lands. + +Adjacent context surfaced during research: +- `docs/plans/2026-04-17-001-feat-ravenscope-greenfield-plan.md:25, 65, 132, + 295, 333` — AS integration was explicitly deferred in the greenfield plan; + this plan is the "embedded web-port" branch of that decision. +- `README.md:134` — hard convention: R2 has no public access, no `r2.dev` + subdomain, no custom public domain. All R2 reads go through the Worker. + Drives the Worker-proxy static posture in U6. + +### External References + +- AdvantageScope Lite docs: + `~/src/1310/AdvantageScope/docs/docs/more-features/advantagescope-lite.md` + (omissions we inherit: no video tab, no Hoot, no XR, no pop-out, no layout + JSON export). +- AdvantageScope Lite HTTP contract: + `~/src/1310/AdvantageScope/lite/lite_server.py` — the four endpoints we're + re-implementing, plus the static-asset serve pattern. +- AdvantageScope Lite frontend fetch call sites: + `~/src/1310/AdvantageScope/src/main/lite/main.ts:194, 219, 336` — the + `open-files` postMessage shape the U1 spike bootstrap script must dispatch. +- Default FRC asset bundle: + `https://github.com/Mechanical-Advantage/AdvantageScopeAssets/releases/download/bundles-v1/AllAssetsDefaultFRC.zip`. + +--- + +## Key Technical Decisions + +- **Worker-proxy posture for Lite's static files.** Every request to + `/v/:id/*` runs through the Worker, including JS/CSS/HTML. For the four + dynamic paths the Worker returns session-scoped data; for everything else + the Worker authorizes `:id` and then proxies to + `env.ASSETS.fetch(new Request(origin + "/advantagescope" + rest))`. + Rationale: makes R5 (byte-identical auth) literal. Alternative "serve Lite + from `/advantagescope/` directly and only gate `/v/:id/{logs,assets}`" + leaves Lite's JS loadable without auth, which is fine in practice but drifts + from the success-criterion language. Cost of proxy is sub-ms; no measurable + impact on R6. +- **Content-type sanity check on the static proxy.** `wrangler.toml:9` sets + `not_found_handling = "single-page-application"`, and this setting is + inherited by `env.ASSETS.fetch()` calls — a missing path under + `/advantagescope/...` would return RavenScope's SPA `index.html` with + HTTP 200, which Lite would then attempt to execute as JS in a hard-to- + diagnose way. The static proxy handler inspects the response's + `Content-Type` (and the upstream request's expected extension) and + returns 404 when they disagree (e.g., text/html for a `.js` request). + Pattern is new to this repo; introduce with a short comment. +- **Lite bundle ships via Workers Static Assets, not R2.** AS's + `bundleLiteAssets.mjs` populates `lite/static/bundledAssets/` (default + FRC fields, joysticks, example robots) during the Lite build itself. Once + we vendor the *built* `lite/static/**` tree into + `packages/web/public/advantagescope/`, the default assets are already + present — served by Workers Static Assets alongside the JS/CSS. This + collapses the originally-planned R2 asset upload. Consequence: deploys + are atomic (no mid-session version skew across isolates), and the + `/v/:id/assets[/*]` handlers delegate to `env.ASSETS.fetch()` the same + way as the static catch-all. +- **Lite build output comes from a developer ritual + RavenScope-owned GH + release, not from an upstream AS release asset.** AS does not publish + `lite/static/**` as a downloadable release asset; their Lite artifact is + a Systemcore `.ipk` produced only as an expiring GitHub Actions workflow + artifact. RavenScope therefore owns the build-and-publish step: a + developer with a local AS clone at a pinned tag runs + `pnpm publish:advantagescope-bundle` (which invokes AS's own build + pipeline: `npm ci && ASCOPE_DISTRIBUTION=LITE npm run compile && npm run + docs:build-embed`) and uploads the resulting tarball to a RavenScope + GitHub release (e.g., `advantagescope-lite-v27.1.0.tar.gz`). `pnpm + fetch:advantagescope` then downloads from *RavenScope's* stable release + URL — not from AS's. This preserves the origin doc's "don't replicate + the whole repo" constraint (no AS source in RavenScope) while giving CI + a stable, non-expiring download target. Alternatives considered: + committing the built `lite/static/**` into RavenScope (rejected: bloats + git history by ~few MB per version bump); git submodule + build in CI + (rejected: drags AS's full toolchain into RavenScope CI including + Emscripten); fetching AS workflow artifacts via GitHub API (rejected: + 90-day expiry, requires auth token). +- **Install-time fetch, not `postinstall`.** Adding a `postinstall` hook + would hit GitHub on every `pnpm install --frozen-lockfile` in CI and + every fresh clone. Instead, an explicit `pnpm fetch:advantagescope` + script (developer runs once per version bump; CI runs it as a pre-build + step). The downloaded `packages/web/public/advantagescope/` tree is + `.gitignore`d — reproducible from the pinned tag, not committed. +- **Bootstrap script produced by U1 spike, committed only after U1 returns + green.** The spike validates whether AS Lite's `open-files` postMessage + is reachable from an injected module-script. Only after that outcome is + known does U2 commit the finalized `bootstrap.js`. If U1 shows the + accessor is module-closure-scoped and unreachable, the plan's fallback + is a tiny `patch(1)`-applied diff against `src/main/lite/main.ts` + maintained alongside `version.txt` — the publish script from U2b applies + it during the AS build. Upstream contribution of `?log=&folder=` remains + the long-term fix. +- **Workspace-scoped auth, matching existing routes.** Non-owner gets 404 + `not_found`, not 403. Mirrors `packages/worker/src/routes/wpilog.ts:38` + exactly. Extract `loadOwnedSession(c, id)` helper in U4 so all six call + sites (existing 5 + new) share one line. +- **Factor `getOrBuildWpilog` out of `routes/wpilog.ts`.** `/v/:id/logs/` + must stream the same cached bytes as `/api/sessions/:id/wpilog`. Extract + the cache-check + generate-on-miss body into a reusable helper (U5) so + policy drift is structurally impossible. +- **Iframe sandbox posture: + `sandbox="allow-scripts allow-same-origin"` only.** `allow-same-origin` + is required for the session cookie to flow on Lite's relative fetches + (same-origin fetches send SameSite=Lax cookies automatically). The + combination does grant Lite's JS access to RavenScope's `localStorage`, + `sessionStorage`, and the ability to `window.parent.postMessage(...)` — + RavenScope does not register a parent-frame `message` listener, and the + session cookie is `HttpOnly` (`packages/worker/src/auth/cookie.ts:140`) + so it cannot be read from JS. `allow-downloads` is intentionally omitted + (AS Lite's export flows are not required for v1). If a future RavenScope + feature adds a parent-window message listener, the iframe becomes an + attack surface that must be hardened with an origin check. + +--- + +## Open Questions + +### Resolved During Planning + +- **How does Lite's main window wire up the `open-files` message?** Via + `hubPort` (MessagePort) set up during app boot in `src/main/lite/main.ts`. + The bootstrap script must access the same port. Exact accessor TBD by the + U1 spike. If `hubPort` is closure-scoped and unreachable from an injected + module-script, fallback is a tiny `patch(1)`-applied diff to `main.ts` + applied by the publish script (U2b) during the AS build. +- **Which auth unit does the workspace scope hang off of?** `workspaceId`, not + `userId`. Verified against `packages/worker/src/routes/wpilog.ts:31–36`. +- **Where do Lite's static files live at rest?** Workers Static Assets under + `packages/web/public/advantagescope/` (Vite copies `public/**` into + `dist/`, which wrangler uploads via the `ASSETS` binding). Not in the + Worker script bundle — no impact on the 10 MiB script cap. +- **Where does the AS Lite build output come from?** From a RavenScope-owned + GitHub release, populated by a developer ritual (U2b, + `pnpm publish:advantagescope-bundle`) that runs AS's own build pipeline + against a pinned tag in a local AS checkout. AS does not publish + `lite/static/**` as a release asset; relying on their CI `.ipk` + workflow-artifact is infeasible (90-day expiry, auth-gated). See Key + Technical Decisions for full rationale. +- **Where do the default FRC assets come from?** From AS's own + `bundleLiteAssets.mjs`, which runs as part of AS's `postinstall` during + the U2b build step. The assets land in `lite/static/bundledAssets/` and + ship alongside Lite's JS as part of the tarball. No separate R2 upload + is needed. The originally-planned `bundles-v1/AllAssetsDefaultFRC.zip` + download is *not* used — AS Lite's runtime expects assets from + `archive-v1`-tagged individual zips that `bundleLiteAssets.mjs` has + already consumed, and mixing the two sources would risk version skew. +- **Should the `/v/:id/` route rewrite Lite's `index.html` on the fly or + inject the bootstrap at install time?** Install time (during the + publish-bundle build, U2b). Simpler and keeps the Worker hot path free + of HTML parsing. +- **What happens when `env.ASSETS.fetch()` is called for a path that + doesn't exist?** Returns RavenScope's SPA `index.html` with HTTP 200 + because of `not_found_handling = "single-page-application"` — NOT 404. + U6's static-proxy handler guards against this by content-type sanity- + checking the response before forwarding. + +### Deferred to Implementation + +- **Exact mechanism of the bootstrap postMessage dispatch.** **Resolved by + U1 code-read (2026-04-23).** `hubPort` in + `~/src/1310/AdvantageScope/src/main/lite/main.ts:43` is a module-scoped + `let`, never attached to `window`. An injected-only bootstrap `` line into + `AS/lite/static/index.html` just before ``. +- Tars the resulting `AS/lite/static/**` into + `.advantagescope-cache/.tar.gz`. +- Computes SHA-256 and writes it to + `packages/web/advantagescope/checksums.txt`. +- Uses `gh release create` (or `gh release upload` if the release already + exists) to publish the tarball to a RavenScope-owned release. The + `gh` CLI is already part of the developer toolkit for this repo (PRs + are created with it). +- Prints the final tag + URL; developer commits `version.txt` and + `checksums.txt`. + +**Patterns to follow:** +- `scripts/setup.sh` — one-shot, developer-driven, shell out to external + CLI tools (`wrangler`, `gh`), verify state before acting. +- Node `child_process` `execFileSync` with inherited stdio for long-running + sub-commands (npm install, rollup compile). No shell strings. + +**Test scenarios:** +- Test expectation: none — this is a developer ritual that runs against + real AS code and real GitHub API. Correctness is verified end-to-end + by U2 pulling the resulting tarball back down and U6's integration + tests loading it. + +**Verification:** +- Running `AS_PATH=... pnpm publish:advantagescope-bundle` against a + pinned AS tag produces a RavenScope release with a tarball attached, + and updates `checksums.txt`. After committing, `pnpm + fetch:advantagescope` on a clean clone pulls that tarball and + populates `packages/web/public/advantagescope/` as expected. +- The first successful end-to-end pass of U7 (manually open the viewer + in a browser) against the v1 bundle constitutes the end-to-end + verification of the whole U2b → U2 → U6 → U7 pipeline. + +--- + +- [ ] U4. **Extract `loadOwnedSession` auth helper** + +**Goal:** Replace the inlined workspace-scoped session SELECT repeated 5× +across `routes/sessions.ts` and `routes/wpilog.ts` with a single helper. +Same behavior, same error shape (404 `not_found`), byte-identical auth. + +**Requirements:** R5 (auth byte-identical to `/api/sessions/:id/wpilog` on +the new routes). + +**Dependencies:** None. + +**Files:** +- Create: `packages/worker/src/auth/session-owner.ts` +- Create: `packages/worker/src/auth/session-owner.test.ts` +- Modify: `packages/worker/src/routes/wpilog.ts` (replace inline SELECT). +- Modify: `packages/worker/src/routes/sessions.ts` (replace 4 inline SELECTs). + +**Approach:** +- Export `loadOwnedSession(c: Context<{ Bindings: Env }>, id: string): + Promise` that: + 1. Calls `requireCookieKind(c.var.user)`. + 2. Runs the existing `db.select().from(telemetrySessions).where(and( + eq(telemetrySessions.id, id), + eq(telemetrySessions.workspaceId, user.workspaceId))).limit(1)` query. + 3. Returns the row or `null`. +- Each call site becomes: + ``` + const session = await loadOwnedSession(c, id) + if (!session) return c.json({ error: "not_found" }, 404) + ``` +- JSDoc block explains invariants: workspace-scoped, not user-scoped; never + throws on missing; caller must 404 on null to preserve existing behavior. + +**Execution note:** Characterization-first. Write the helper's test alongside +the refactor; rerun the existing `routes/wpilog.test.ts` and +`routes/sessions.test.ts` to confirm behavior is byte-identical. + +**Patterns to follow:** +- `packages/worker/src/auth/require-cookie-user.ts` — module organization + (single exported function, JSDoc invariants block). + +**Test scenarios:** +- Happy path: session exists in caller's workspace → returns the row. +- Error path: session exists but belongs to a different workspace → returns + `null`. +- Error path: session does not exist → returns `null`. +- Edge case: `id` is an empty string or malformed → returns `null` (no + throw). +- Integration: the existing `routes/wpilog` tests still pass without + modification after the refactor. + +**Verification:** +- `grep` across `packages/worker/src/routes/` finds zero remaining inline + instances of + `eq(telemetrySessions.workspaceId` except in the helper itself. +- `pnpm -F @ravenscope/worker test` is green. + +--- + +- [ ] U5. **Extract `getOrBuildWpilog` helper** + +**Goal:** Factor the cache-check + generate-on-miss body out of the existing +`/api/sessions/:id/wpilog` route so the new `/v/:id/logs/` handler can +reuse it verbatim. Policy drift between the two paths must be structurally +impossible. + +**Requirements:** R5 (byte-identical behavior between the two viewing paths). + +**Dependencies:** U4. + +**Files:** +- Create: `packages/worker/src/wpilog/get-or-build.ts` +- Create: `packages/worker/src/wpilog/get-or-build.test.ts` +- Modify: `packages/worker/src/routes/wpilog.ts` — the route handler becomes + a thin shell calling `getOrBuildWpilog(c, session)` and wrapping the result + in the response headers (`Content-Type`, `Content-Disposition`, + `Cache-Control`). + +**Approach:** +- `getOrBuildWpilog(c, session)` returns either `{ stream, bytes, isCached: + boolean }` or throws a typed error (already-handled upstream patterns: + `QuotaExceededError` → `handleQuotaExceeded`). +- Logic lifted from `routes/wpilog.ts:47–93`: check `session.wpilogKey` + + `session.wpilogGeneratedAt` against the cache-mark; if hit, `classB: 1` + charge and read from R2; if miss, regenerate + write + update D1. +- Caller is responsible for wrapping in `Response` with headers, so the two + callers can set different `Content-Disposition` (`attachment` vs no + attachment) while sharing the cache policy. + +**Execution note:** Characterization-first. The extraction is pure refactor; +the existing wpilog response bytes must remain byte-identical, verified by +the existing golden-file regression test. + +**Patterns to follow:** +- `packages/worker/src/storage/r2.ts` — wrapping I/O ops with + `chargeOrThrow`, streaming responses from R2 bodies. + +**Test scenarios:** +- Happy path (cache hit): session has a recent `wpilogKey` and + `wpilogGeneratedAt`; helper returns the R2-cached stream, charges one + Class B, does not touch the generator. +- Happy path (cache miss): session has no `wpilogKey`; helper runs the + generator, writes to R2, updates D1, returns the freshly-generated stream. +- Happy path (stale cache): `wpilogGeneratedAt` is older than session's most + recent ingest batch; helper treats as miss. +- Error path: `QuotaExceededError` from R2 propagates and is recognizable by + the caller. +- Integration: existing `routes/wpilog` tests + the golden-file byte- + identical test both pass. + +**Verification:** +- `pnpm -F @ravenscope/worker test` is green. +- `routes/wpilog.ts` is ~15 lines shorter and contains no inline R2 ops; + all heavy lifting lives in `wpilog/get-or-build.ts`. + +--- + +- [ ] U6. **New Worker route group `/v/:id/*` + wrangler wiring** + +**Goal:** The four session-scoped dynamic handlers plus the static-proxy +catch-all, all under one auth gate. Plus the critical `wrangler.toml` +change so SPA fallback doesn't eat the route. + +**Requirements:** R1, R2, R5, R6. + +**Dependencies:** U1, U2 (static files must exist on disk for +`env.ASSETS.fetch` to resolve), U4, U5. (U3 no longer exists; asset +storage moved to Workers Static Assets alongside Lite's JS/CSS.) + +**Files:** +- Create: `packages/worker/src/routes/advantagescope.ts` +- Create: `packages/worker/src/routes/advantagescope.test.ts` +- Modify: `packages/worker/src/index.ts` — register the new route group: + `app.route("/v", advantagescopeRoutes)`. Order: after `httpsOnly`, + alongside `/api/*` registrations. +- Modify: `packages/worker/wrangler.toml` — `run_worker_first = ["/api/*", + "/v/*"]`. + +**Approach:** +- Route group: `advantagescopeRoutes = new Hono<{ Bindings: Env }>()`. +- `advantagescopeRoutes.use("*", requireCookieUser)` at the top. +- First-pass handler resolves `:id` via `loadOwnedSession(c, id)`; 404s on + null. Everything downstream knows the session. +- Four dynamic paths (exact matches, declared before the static catch-all): + - `GET /:id/logs` — returns + `[{ name: ".wpilog", size: }]`. `size` comes from the + cached R2 object's metadata if present (HEAD via the wrapper) or the + freshly-generated bytes' length otherwise. Trigger `getOrBuildWpilog` + here so that the later `/logs/` request is always a fast cache + hit — Lite's UI expects the bytes to arrive quickly after listing. + - `GET /:id/logs/:name{.*}` — ignores both `name` and `?folder` (returns + the session's log regardless — covered by the explicit test below). + Delegates to `getOrBuildWpilog` and streams the R2 body with + `Content-Type: application/octet-stream`, + `Cache-Control: private, max-age=0, must-revalidate`. No + `Content-Disposition` (inline, not download). + - `GET /:id/assets` — delegates to the shared static-proxy helper + targeting `/advantagescope/assets-manifest.json` (a static file + produced by U2b during the AS build — a JSON map of filename → + optional config.json contents, shaped like AS's Python server's + `/assets` response). Response headers match the proxy default + (`public, max-age=31536000, immutable` for versioned asset paths — + see next bullet). Because `assets-manifest.json` is content-addressed + by the whole `advantagescope/` deploy (atomic with each Worker + deploy), it is safe to cache indefinitely at the browser. + - `GET /:id/assets/:path{.*}` — path-safety check (`..` rejected, no + leading slash), then delegates to the static-proxy helper targeting + `/advantagescope/bundledAssets/`. Cache-Control `public, + max-age=31536000, immutable`. +- Static-proxy catch-all: `GET /:id/*` for anything not matched above. + Rewrites to `new URL(env.ASSETS_ORIGIN + "/advantagescope/" + rest)` + and calls `env.ASSETS.fetch(rewritten)`. Path-traversal guard: reject + `rest` containing `..` or starting with `/`. +- **Content-type sanity check** on every static-proxy response: because + `wrangler.toml:9` sets `not_found_handling = "single-page-application"`, + a missing path under `/advantagescope/...` returns RavenScope's SPA + `index.html` with HTTP 200. The helper inspects the response's + `Content-Type` against the expected type inferred from the requested + extension (e.g., `.js` → `application/javascript`, `.css` → `text/css`, + `.json` → `application/json`, `.glb` / `.png` / etc.). On mismatch, the + helper returns 404 instead of forwarding the SPA HTML. `index.html` + (the one case where HTML is expected) is handled as an explicit + special case. +- Root-of-iframe `GET /:id/` (no trailing path) → rewrite to + `/advantagescope/index.html`, proxy (special-cased as HTML-allowed). +- All R2-touching handlers (only `/logs` and `/logs/`) quota-charge + via `chargeOrThrow` before the op. + +**Technical design:** *(directional guidance, not implementation spec)* + +Pseudo-code of handler dispatch shape, in priority order — declare +specific routes before the catch-all, Hono matches first registered: + + advantagescopeRoutes.get("/:id", rootHandler) // iframe root + advantagescopeRoutes.get("/:id/logs", listOneLog) + advantagescopeRoutes.get("/:id/logs/:name{.*}", streamLog) + advantagescopeRoutes.get("/:id/assets", serveManifest) + advantagescopeRoutes.get("/:id/assets/:path{.*}", serveAsset) + advantagescopeRoutes.get("/:id/*", staticProxy) // catch-all last + +Pseudo-code of the static-proxy sanity-check shape: + + function proxyStatic(c, staticPath, {allowHtml = false}): + auth already enforced by middleware + loadOwnedSession + rewritten = new URL(staticPath, selfOrigin(c)) + res = await c.env.ASSETS.fetch(rewritten) + expectedType = typeFromExtension(staticPath) + if not allowHtml and res.contentType is "text/html": + return 404 // SPA fallback swallowed the request + if expectedType and not res.contentType.matches(expectedType): + return 404 + return res + +**Execution note:** Write integration tests (`advantagescope.test.ts`) +for the full auth matrix first, before the handlers. Include a +**golden-file cross-route** test asserting that `GET /api/sessions/S/wpilog` +and `GET /v/S/logs/anything.wpilog` return byte-identical payload bytes +(headers differ by design). The unauthenticated + cross-workspace + +happy-path triplet plus this golden-cross-route pair is the whole risk +of R5. + +**Patterns to follow:** +- `packages/worker/src/routes/wpilog.ts` — exact shape of the session + loading + R2 stream + response-headers idiom. +- `packages/worker/src/storage/r2.ts` — all R2 access via the wrapper + module, never raw `env.BLOBS.get(...)`. + +**Test scenarios:** +- Happy path (R1): authenticated owner of session S → `GET /v/S/` returns + `index.html` bytes; `GET /v/S/bundles/main.js` returns JS bytes; + `GET /v/S/logs` returns `[{ name, size }]`; `GET /v/S/logs/` + streams the same bytes as `GET /api/sessions/S/wpilog`. +- Happy path (R2): `GET /v/S/assets` returns a JSON manifest with entries + for at least one field model, one robot model, and one joystick layout; + `GET /v/S/assets/` streams non-empty bytes and the + response Content-Type matches the extension. +- Error path (R5): unauthenticated request → redirect to sign-in (matches + existing cookie-auth posture from other protected routes). +- Error path (R5): authenticated user from workspace W1 requests a session + owned by workspace W2 → `404 {"error":"not_found"}` on all five path + shapes. Covers R5. +- Error path: session S exists but has no cached WPILog yet → handler + triggers generation via `getOrBuildWpilog`, streams the fresh bytes + (Covers R5: behavior matches existing `/api/sessions/:id/wpilog` cold- + path). +- Edge case (SPA-fallback guard): request for + `/v/S/bundles/does-not-exist.js` — `env.ASSETS.fetch` returns SPA + `index.html` with 200; our handler detects the content-type mismatch + and returns 404 instead of forwarding the HTML. This is the specific + failure mode identified in the plan's risk table. +- Edge case: static-proxy path traversal — `GET /v/S/../api/auth` → 400 or + 404, never reaches `env.ASSETS.fetch` with an escaped path. +- Edge case: `GET /v/S/logs/anything.wpilog?folder=/tmp/whatever` returns + S's log regardless of `name` and `folder` values (we ignore both). +- Integration (R5 golden cross-route): existing + `/api/sessions/:id/wpilog` test golden file is also compared against + `GET /v/:id/logs/.wpilog`; bytes must match exactly. +- Integration: `run_worker_first` config change verified — a request to + `/v/unknown-id/` hits the Worker and returns 404, NOT the SPA's + `index.html`. +- Performance sanity (R6): no regression in cold-start timing — the new + handler does zero R2 work on the static-proxy path, and the only + persistent-storage hit is the existing wpilog cache-check. + +**Verification:** +- `curl` against a deployed staging worker confirms the full matrix of + paths returns the expected bytes and headers. +- Running a browser against `/sessions/S/view` loads AS Lite, shows + populated tabs (line graph, table, 3D field with a default FRC field + model), no console errors related to missing endpoints or CSP. + +--- + +- [ ] U7. **Session viewer React route + "Open viewer" button** + +**Goal:** Add `/sessions/:id/view` as a full-screen route in the SPA that +hosts the `/v/:id/` iframe, and surface the entry point from the session +detail page. + +**Requirements:** R1 (one-click from session detail). + +**Dependencies:** U6 (the target URL must exist). + +**Files:** +- Create: `packages/web/src/routes/session-view.tsx` +- Create: `packages/web/src/routes/session-view.test.tsx` +- Modify: `packages/web/src/app.tsx` — add the route inside the `AuthGate` + block. +- Modify: `packages/web/src/routes/session-detail.tsx` — add "Open viewer" + button next to "Download .wpilog". +- Modify: `packages/web/src/lib/api.ts` — add `sessionViewerUrl(id)` + helper. + +**Approach:** +- `SessionView` component: minimal RavenScope chrome (session name, "Back" + button linking to `/sessions/:id`) at the top; full-bleed iframe below + with `src={sessionViewerUrl(id)}`, `sandbox="allow-scripts allow-same- + origin"`, `loading="eager"`. `allow-same-origin` is required for the + session cookie to flow on Lite's relative fetches; `allow-scripts` is + required for Lite to boot. `allow-downloads`, `allow-popups`, + `allow-forms` are intentionally omitted for v1. +- Iframe styled `w-full h-[calc(100vh-)]`; border-less. + Works on mobile/tablet viewports (no horizontal scroll at the + RavenScope chrome level). +- `sessionViewerUrl(id): string` returns `/v/${encodeURIComponent(id)}/` + — note trailing slash so relative fetches in AS Lite resolve under the + `:id` prefix. +- Session-detail "Open viewer" button: `` + wrapping a ``; placed + BEFORE "Download .wpilog" in the action cluster so viewing is the new + default primary action. +- Respect `AuthGate`: an expired cookie redirects to + `/sign-in?next=/sessions//view`, then returns the user here after + re-auth. Verify this chain works manually. + +**Patterns to follow:** +- `packages/web/src/routes/session-detail.tsx:89–91` — existing action + button cluster. +- `packages/web/src/components/AuthGate.tsx` — protected-route wrap. + +**Test scenarios:** +- Happy path: "Open viewer" click on session detail navigates to + `/sessions/:id/view`; iframe mounts with the right `src`. +- Edge case: direct navigation to `/sessions/:id/view` without a cookie + → redirect to `/sign-in?next=/sessions//view`; after sign-in, + returns to the viewer. +- Edge case: `:id` that the user doesn't own → iframe loads, shows AS + Lite's own "file not found" state (the Worker returned 404 on all the + dynamic endpoints). RavenScope chrome still renders. +- Integration: the "Download .wpilog" button still works and is still + visible; nothing regresses on the existing session detail UI. + +**Verification:** +- Manually: from a session row, clicking "Open viewer" lands in a working + AS Lite viewer within ~2 seconds (network-bound). +- Vitest: `session-view.test.tsx` asserts the iframe `src`, the Back link + target, and that rendering does not crash without data loading (the + viewer itself handles its data). + +--- + +- [ ] U8. **CI integration + `.gitignore` + `ATTRIBUTION.md`** + +**Goal:** Make the build reproducible from a fresh clone in CI, and satisfy +BSD-3 attribution. + +**Requirements:** R3 (reproducible in CI), R4 (no AS source in repo). + +**Dependencies:** U2, U6. + +**Files:** +- Create: `ATTRIBUTION.md` (repo root) — AdvantageScope (BSD-3) + the + AdvantageScopeAssets bundles (respective licenses) with license text + reproduced per BSD-3 terms. +- Modify: `.gitignore` — ensure `packages/web/public/advantagescope/` and + `packages/web/.advantagescope-cache/` are ignored (may overlap with U2; + keep single source of truth). +- Modify: `.github/workflows/ci.yml` — add `pnpm -F @ravenscope/web + fetch:advantagescope` as a step before the build/test steps. +- Modify: `.github/workflows/deploy.yml` — same pre-build step. +- Modify: `README.md` — add a short "AdvantageScope Lite is embedded under + BSD-3 — see `ATTRIBUTION.md`" sentence near the existing AS mention. + +**Approach:** +- CI caches the `.advantagescope-cache/` directory keyed on `version.txt` + SHA so the GitHub download runs only when the pinned tag changes. +- `ATTRIBUTION.md` lists: + - AdvantageScope (BSD-3), copyright Littleton Robotics + - AdvantageScope default FRC asset bundle (upstream licenses carried) + - AS Lite's own bundled third-party deps (from AS's `getLicenses.mjs` + output; include as a transitive credits file) +- Include the required BSD-3 notice text verbatim. + +**Patterns to follow:** +- `.github/workflows/ci.yml` — existing step style, Node 20, pnpm 9.15.9. + +**Test scenarios:** +- Test expectation: none — configuration + text files. Correctness is + verified by: CI passing on a fresh clone; `packages/web/public/ + advantagescope/` absent from `git ls-files`; `ATTRIBUTION.md` present + and lists AS + default FRC assets. + +**Verification:** +- `git ls-files packages/web/public/advantagescope` returns nothing. +- A clean CI run passes, including `pnpm build` and `pnpm test`. +- `ATTRIBUTION.md` renders correctly on the GitHub UI. + +--- + +## System-Wide Impact + +- **Interaction graph:** The new `/v/:id/*` route group runs the same + cookie-auth middleware as `/api/*`. No changes to existing routes' + behavior other than the U4/U5 pure refactors. The iframe on + `/sessions/:id/view` is same-origin, shares the session cookie, and its + sub-requests go through the Worker. +- **Error propagation:** Non-owner access returns 404 `not_found` on every + endpoint (matching existing wpilog route). AS Lite's own error states + handle the UI-side surfacing, so RavenScope does not need a custom "not + found in viewer" screen. +- **State lifecycle risks:** R2 writes: none new. The originally-planned + `advantagescope-assets/v/` prefix was dropped when asset storage + moved to Workers Static Assets. U5 is a refactor that preserves existing + R2 write semantics on the wpilog-cache path. +- **API surface parity:** `/api/sessions/:id/wpilog` and `/v/:id/logs/` + must return byte-identical WPILog bytes for the same session. Guaranteed + structurally by U5's shared helper; explicitly covered by U6's golden + cross-route test. +- **Integration coverage:** U6's integration tests cover the auth matrix + across all five path shapes plus the SPA-fallback guard and the byte- + identical cross-route test. U1 spike ensures the auto-open behavior is + verified end-to-end before U2b locks in the bootstrap shape. +- **Unchanged invariants:** `/api/*` behavior, auth posture, SPA routing, + the existing session detail page's "Download .wpilog" button, and R2's + existing `sessions//` prefix are all unchanged. The only new + server-side state introduced by this plan is the + `packages/web/public/advantagescope/` static tree (atomic with each + deploy) and the RavenScope-owned GitHub release holding the tarballs. + +--- + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| AS Lite `open-files` postMessage shape isn't cleanly invokable from an injected script (e.g., `hubPort` is module-closure-scoped) | U1 is an explicit de-risking spike before `bootstrap.js` is committed. If the shim can't reach the port, U2b applies a tiny `main.ts.patch` during the AS build. `bootstrap.js` is committed only *after* U1 returns green, so the plan doesn't freeze a shape that turns out to be wrong. | +| AS Lite 2027/v27 beta is a moving target; pinned tag may go stale quickly | Every version bump is a tracked ritual: run U2b's publish script, re-run `fetch:advantagescope`, re-run the U6 auth matrix + U7 smoke test against a known-good seed session. Captured in a README checklist by U8. | +| Building AS Lite requires Emscripten (for `wasm:compile`) on the developer's machine | U2b documents Emscripten as a prerequisite in the README version-bump checklist. Not required in CI — only the published tarball is consumed there. | +| Workers Static Assets limits exceeded by combined Lite + default assets + RavenScope SPA | Workers Static Assets limits are ~20,000 files and 25 MiB per file. Lite's `bundles/` + `www/` + default `bundledAssets/` is well under the file-count and per-file caps. Measure `packages/web/dist/` total after U2 lands and document in U8's README note; escalate to a separate R2-served-assets posture if (unexpectedly) limits are hit. | +| `env.ASSETS.fetch()` silently returns SPA `index.html` for missing paths due to `not_found_handling = "single-page-application"` | U6's static-proxy helper does a content-type sanity check on every response and returns 404 when a non-HTML request gets an HTML response. Tested explicitly in U6's edge-case scenarios. | +| Same-origin iframe grants AS Lite JS access to RavenScope's localStorage, sessionStorage, and `window.parent.postMessage` | Session cookie is `HttpOnly` at `packages/worker/src/auth/cookie.ts:140`, so the most sensitive bearer-of-identity can't be read from JS. RavenScope does not register a parent-frame `message` listener today. U8's README note records this constraint: any future parent-frame listener MUST verify `event.origin` and `event.source` before acting. | +| iOS Safari ITP gates cookies/storage on same-origin iframed content under some navigation heuristics | Manual QA step in U7: verify the viewer works on an actual iPad Safari session (iPad mentor is a stated user in the origin doc). If ITP blocks, fall back to `sessionStorage`-based short-lived auth token handshake (out of scope for v1; revisit if observed). | +| `run_worker_first` change breaks an existing `/v/*` URL that resolves to the SPA today | `grep` the codebase (including README, docs, issue history) for `/v/` occurrences before landing U6. As of this plan there are none. Covered by a direct negative test in U6 (`GET /v/unknown` returns Worker 404, not SPA HTML). | +| BSD-3 attribution is incomplete | U8 builds `ATTRIBUTION.md` from AS's own `getLicenses.mjs` output (produced during U2b's build) as the source of truth for transitive deps, not hand-authored. | +| GitHub release URL for the RavenScope-owned bundle changes or the release gets deleted | Checksums.txt gives a hard integrity signal. If the URL 404s, U2's clear-error-message points the user at U2b. Rebuild + republish takes minutes. | + +--- + +## Documentation / Operational Notes + +- **README.md:** add a short "Viewing sessions" section linking to + `/sessions/:id/view` behavior; document the two-step version-bump + workflow (`publish:advantagescope-bundle` locally once, then + `fetch:advantagescope` on each build/CI run). +- **ATTRIBUTION.md:** new file; see U8. +- **Runbook:** no new on-call concerns; the only external dependency added + is the RavenScope GitHub release URL during CI — document the failure + mode (pin a version, re-run, investigate) in the CI step's error + message. +- **Monitoring:** no new metrics. The only R2 I/O on the viewer path is + the existing wpilog cache at `sessions//session.wpilog`, already + covered by existing quota dashboards via `chargeOrThrow`. Assets are + served from Workers Static Assets and do not touch R2. +- **Rollout:** no feature flag needed — the new route and UI button are + additive. If a serious bug surfaces post-deploy, remove the "Open viewer" + button via a trivial revert; the `/v/:id/*` route can coexist until a + fix lands without user-visible impact. + +--- + +## Sources & References + +- **Origin document:** + [docs/brainstorms/2026-04-23-001-feat-embed-advantagescope-requirements.md](../brainstorms/2026-04-23-001-feat-embed-advantagescope-requirements.md) +- **AdvantageScope repo (external, not vendored):** + `~/src/1310/AdvantageScope`, specifically: + - `lite/lite_server.py` — reference implementation of the 4-endpoint + contract we re-implement on the Worker + - `lite/static/` — what we download and serve + - `src/main/lite/main.ts:185–228` — `open-files` postMessage flow the + bootstrap shim mimics + - `docs/docs/more-features/advantagescope-lite.md` — feature omissions + we inherit +- **AdvantageScopeAssets:** + `https://github.com/Mechanical-Advantage/AdvantageScopeAssets/releases` +- **Prior RavenScope plans:** + - `docs/plans/2026-04-17-001-feat-ravenscope-greenfield-plan.md:25, 65, + 132, 295, 333` — deferred-AS-integration context + - `docs/plans/2026-04-23-002-feat-compress-r2-blobs-plan.md` — R2 I/O + funnels through `packages/worker/src/storage/r2.ts` (same constraint + here) +- **Related code:** + - `packages/worker/src/routes/wpilog.ts` + - `packages/worker/src/storage/r2.ts` + - `packages/worker/src/auth/require-cookie-user.ts` + - `packages/worker/wrangler.toml` + - `packages/web/src/routes/session-detail.tsx` + - `packages/web/src/app.tsx` diff --git a/package.json b/package.json index af40920..252cb76 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "lint": "pnpm -r lint", "dev:worker": "pnpm -F @ravenscope/worker dev", "dev:web": "pnpm -F @ravenscope/web dev", + "fetch:advantagescope": "pnpm -F @ravenscope/web fetch:advantagescope", + "publish:advantagescope-bundle": "pnpm -F @ravenscope/web publish:advantagescope-bundle", "format": "prettier --write ." }, "devDependencies": { diff --git a/packages/web/advantagescope/checksums.txt b/packages/web/advantagescope/checksums.txt new file mode 100644 index 0000000..bb0d816 --- /dev/null +++ b/packages/web/advantagescope/checksums.txt @@ -0,0 +1 @@ +7fb6765723aa93ac71db14cfd6e213045384d468e2c3226cac896cdadbd867c6 advantagescope-lite-v27.0.0-alpha-4.tar.gz diff --git a/packages/web/advantagescope/main.ts.patch b/packages/web/advantagescope/main.ts.patch new file mode 100644 index 0000000..0d94df4 --- /dev/null +++ b/packages/web/advantagescope/main.ts.patch @@ -0,0 +1,19 @@ +diff --git a/src/main/lite/main.ts b/src/main/lite/main.ts +index def5faf..ee94ceb 100644 +--- a/src/main/lite/main.ts ++++ b/src/main/lite/main.ts +@@ -265,6 +265,14 @@ async function initHub() { + if (state !== null) sendMessage(hubPort, "restore-state", JSON.parse(state)); + sendMessage(hubPort, "show-when-ready"); + ++ // RavenScope deep-link: if ?log= is present in the URL, auto- ++ // open that log so the embedded viewer skips the download popup. ++ // Applied as a local patch by RavenScope's publish-advantagescope-bundle. ++ const autoLog = new URLSearchParams(location.search).get("log"); ++ if (autoLog !== null && autoLog.length > 0) { ++ sendMessage(hubPort, "open-files", { files: [autoLog], merge: false }); ++ } ++ + // Add cursor event handlers + HUB_FRAME.contentWindow?.addEventListener("contextmenu", (event) => { + event.preventDefault(); diff --git a/packages/web/advantagescope/version.txt b/packages/web/advantagescope/version.txt new file mode 100644 index 0000000..510b88f --- /dev/null +++ b/packages/web/advantagescope/version.txt @@ -0,0 +1,3 @@ +as=v27.0.0-alpha-4 +bundle=advantagescope-lite-v27.0.0-alpha-4 +release-url=https://github.com/RunnymedeRobotics1310/RavenScope/releases/download/advantagescope-lite-v27.0.0-alpha-4/advantagescope-lite-v27.0.0-alpha-4.tar.gz diff --git a/packages/web/package.json b/packages/web/package.json index bc40859..0582928 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -9,7 +9,9 @@ "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", "lint": "tsc --noEmit", - "preview": "vite preview" + "preview": "vite preview", + "fetch:advantagescope": "node ./scripts/fetch-advantagescope.mjs", + "publish:advantagescope-bundle": "node ./scripts/publish-advantagescope-bundle.mjs" }, "dependencies": { "@radix-ui/react-collapsible": "^1.1.12", diff --git a/packages/web/scripts/fetch-advantagescope.mjs b/packages/web/scripts/fetch-advantagescope.mjs new file mode 100644 index 0000000..003ab97 --- /dev/null +++ b/packages/web/scripts/fetch-advantagescope.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +/** + * Fetch the pinned AdvantageScope Lite bundle into + * packages/web/public/advantagescope/ so Vite's build can copy it into + * dist/ and the Worker's Workers Static Assets can serve it under + * /advantagescope/**. + * + * Input: packages/web/advantagescope/version.txt + checksums.txt (both + * committed). version.txt pins the bundle tag; checksums.txt pins the + * SHA-256 of the tarball. + * + * Sources, in priority order: + * 1. packages/web/.advantagescope-cache/.tar.gz (local cache, + * populated by publish-advantagescope-bundle.mjs or a previous + * fetch). Used when the SHA matches. + * 2. GitHub release download (when present). URL lives in version.txt + * as `release-url=...`; left empty while bundles are produced + * locally only. + * + * Safety: checksum is verified before extraction. A matching SHA is + * how we trust the tarball's contents, so aggressive path-filtering on + * extraction is redundant. + */ +import { spawnSync } from "node:child_process" +import { createHash } from "node:crypto" +import { createReadStream, existsSync } from "node:fs" +import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises" +import { dirname, join, relative, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const WEB_DIR = resolve(__dirname, "..") +const REPO_ROOT = resolve(WEB_DIR, "..", "..") +const PIN_DIR = join(WEB_DIR, "advantagescope") +const CACHE_DIR = join(WEB_DIR, ".advantagescope-cache") +const TARGET_DIR = join(WEB_DIR, "public", "advantagescope") + +async function main() { + const pin = await readPin() + const expectedSha = await readChecksum(`${pin.bundle}.tar.gz`) + const tarballPath = join(CACHE_DIR, `${pin.bundle}.tar.gz`) + + if (!existsSync(tarballPath)) { + if (!pin.releaseUrl) { + fail( + `Bundle tarball not in cache and no release-url is pinned.\n` + + `Run: AS_PATH=~/src/1310/AdvantageScope pnpm publish:advantagescope-bundle`, + ) + } + await downloadTarball(pin.releaseUrl, tarballPath) + } + + const gotSha = await sha256(tarballPath) + if (gotSha !== expectedSha) { + fail( + `Tarball checksum mismatch for ${pin.bundle}\n` + + ` expected ${expectedSha}\n` + + ` got ${gotSha}`, + ) + } + + await rm(TARGET_DIR, { recursive: true, force: true }) + await mkdir(TARGET_DIR, { recursive: true }) + + // --strip-components=1 drops the top-level `static/` directory inside + // the tarball so files land at TARGET_DIR/index.html, bundles/, etc. + const result = spawnSync( + "tar", + ["-xzf", tarballPath, "-C", TARGET_DIR, "--strip-components=1"], + { stdio: "inherit" }, + ) + if (result.status !== 0) { + fail(`tar extract exited with status ${result.status}`) + } + + log(`extracted ${pin.bundle} into ${relPath(TARGET_DIR)}`) + + await writeAssetsManifest(TARGET_DIR) + log(`wrote assets-manifest.json`) +} + +/** + * Generate assets-manifest.json mirroring AS Lite's Python server + * /assets response shape: { "": }. + * Served verbatim by the Worker's /v/:id/assets route. + */ +async function writeAssetsManifest(root) { + const bundledDir = join(root, "bundledAssets") + const manifest = {} + if (existsSync(bundledDir)) { + for await (const filePath of walk(bundledDir)) { + const rel = relative(bundledDir, filePath).split("\\").join("/") + if (rel.startsWith(".") || rel.includes("/.")) continue + let contents = null + if (rel.endsWith("/config.json") || rel === "config.json") { + try { + contents = JSON.parse(await readFile(filePath, "utf8")) + } catch { + contents = null + } + } + manifest[rel] = contents + } + } + await writeFile( + join(root, "assets-manifest.json"), + JSON.stringify(manifest), + ) +} + +async function* walk(dir) { + for (const entry of await readdir(dir)) { + const full = join(dir, entry) + const s = await stat(full) + if (s.isDirectory()) yield* walk(full) + else if (s.isFile()) yield full + } +} + +async function readPin() { + const text = await readFile(join(PIN_DIR, "version.txt"), "utf8") + const pairs = Object.fromEntries( + text + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .filter((l) => !l.startsWith("#")) + .map((l) => { + const i = l.indexOf("=") + return [l.slice(0, i).trim(), l.slice(i + 1).trim()] + }), + ) + if (!pairs.bundle) fail("version.txt is missing `bundle=` line") + return { + as: pairs.as ?? "unknown", + bundle: pairs.bundle, + releaseUrl: pairs["release-url"] ?? null, + } +} + +async function readChecksum(filename) { + const text = await readFile(join(PIN_DIR, "checksums.txt"), "utf8") + for (const line of text.split("\n")) { + const m = line.match(/^([0-9a-f]{64})\s+(\S+)$/i) + if (m && m[2] === filename) return m[1].toLowerCase() + } + fail(`checksums.txt has no entry for ${filename}`) +} + +async function sha256(path) { + return new Promise((resolveHash, rejectHash) => { + const hash = createHash("sha256") + createReadStream(path) + .on("error", rejectHash) + .on("data", (c) => hash.update(c)) + .on("end", () => resolveHash(hash.digest("hex"))) + }) +} + +async function downloadTarball(url, dest) { + log(`downloading ${url}`) + const res = await fetch(url) + if (!res.ok) fail(`GET ${url} -> ${res.status}`) + await mkdir(dirname(dest), { recursive: true }) + await writeFile(dest, Buffer.from(await res.arrayBuffer())) +} + +function log(msg) { + // eslint-disable-next-line no-console + console.log(`[fetch-advantagescope] ${msg}`) +} + +function fail(msg) { + // eslint-disable-next-line no-console + console.error(`[fetch-advantagescope] ${msg}`) + process.exit(1) +} + +function relPath(p) { + if (p.startsWith(REPO_ROOT + "/")) return p.slice(REPO_ROOT.length + 1) + return p +} + +main().catch((err) => fail(String(err?.stack ?? err))) diff --git a/packages/web/scripts/publish-advantagescope-bundle.mjs b/packages/web/scripts/publish-advantagescope-bundle.mjs new file mode 100644 index 0000000..7ea538b --- /dev/null +++ b/packages/web/scripts/publish-advantagescope-bundle.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +/** + * Build the AdvantageScope Lite bundle against a local AS clone, + * apply RavenScope's patches, tar it up, and publish the result. Run + * once per AS version bump; CI does not run this script. + * + * Prerequisites (one-time per dev machine): + * - AS_PATH env var pointing at a local AdvantageScope clone. + * - Emscripten 4.0.12 activated in the current shell + * (`source ~/src/emsdk/emsdk_env.sh`). + * - `gh` CLI authenticated to the RavenScope org (only needed for + * the actual `gh release upload` step, which this script will skip + * with a clear note until we start publishing releases). + * + * Steps: + * 1. Checkout the AS tag from version.txt in AS_PATH. + * 2. Ensure node_modules are installed (ignore-scripts respected -- + * the user's global npmrc may set it; we run the postinstall + * chain manually to guarantee bundledAssets/ is populated). + * 3. Apply main.ts.patch (the ?log= URL-param auto-open hook). + * 4. npm run wasm:compile + ASCOPE_DISTRIBUTION=LITE npm run compile + + * npm run docs:build-embed. + * 5. Tar `lite/static/**` into .advantagescope-cache/.tar.gz. + * 6. Compute SHA-256 and write advantagescope/checksums.txt. + * 7. Print a done-summary and, if a GitHub release isn't already + * pinned, instruct the developer on the final `gh release` step. + */ +import { spawnSync } from "node:child_process" +import { createHash } from "node:crypto" +import { createReadStream, existsSync } from "node:fs" +import { mkdir, readFile, writeFile } from "node:fs/promises" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const WEB_DIR = resolve(__dirname, "..") +const PIN_DIR = join(WEB_DIR, "advantagescope") +const CACHE_DIR = join(WEB_DIR, ".advantagescope-cache") +const PATCH_PATH = join(PIN_DIR, "main.ts.patch") + +async function main() { + const asPath = process.env.AS_PATH + if (!asPath) fail("AS_PATH is not set; point it at a local AdvantageScope clone") + if (!existsSync(asPath)) fail(`AS_PATH does not exist: ${asPath}`) + if (!existsSync(join(asPath, "package.json"))) { + fail(`AS_PATH does not look like an AS clone: ${asPath}`) + } + + ensureEmcc() + + const pin = await readPin() + log(`building ${pin.bundle} against AS tag ${pin.as}`) + + sh(asPath, "git", ["fetch", "--tags"]) + sh(asPath, "git", ["checkout", pin.as]) + + // Reset any stale patch from a previous run so apply is clean. + sh(asPath, "git", ["checkout", "--", "src/main/lite/main.ts"]) + + // Run the postinstall chain explicitly (user's global npmrc may have + // ignore-scripts=true, which would skip the chain under a plain + // `npm ci`). + sh(asPath, "npm", ["ci"]) + sh(asPath, "npm", ["run", "--ignore-scripts=false", "postinstall"]) + + // Apply the RavenScope main.ts patch. + if (existsSync(PATCH_PATH)) { + sh(asPath, "git", ["apply", PATCH_PATH]) + log(`applied ${relPath(PATCH_PATH)}`) + } + + sh(asPath, "npm", ["run", "wasm:compile"]) + sh(asPath, "npm", ["run", "compile"], { ASCOPE_DISTRIBUTION: "LITE" }) + sh(asPath, "npm", ["run", "docs:build-embed"]) + + await mkdir(CACHE_DIR, { recursive: true }) + const tarballPath = join(CACHE_DIR, `${pin.bundle}.tar.gz`) + const liteDir = join(asPath, "lite") + // -h dereferences symlinks (AS's static/ has www and docs/build + // symlinked to outside the static/ dir); without -h the extraction + // target would have dangling symlinks that crash Vite's copyDir. + sh(liteDir, "tar", ["-czhf", tarballPath, "static"]) + log(`tarball written to ${relPath(tarballPath)}`) + + const sha = await sha256(tarballPath) + await writeFile( + join(PIN_DIR, "checksums.txt"), + `${sha} ${pin.bundle}.tar.gz\n`, + ) + log(`SHA-256: ${sha}`) + log(`wrote ${relPath(join(PIN_DIR, "checksums.txt"))}`) + + // Restore the AS checkout so subsequent dev doesn't get surprised by + // a patched working tree. + sh(asPath, "git", ["checkout", "--", "src/main/lite/main.ts"]) + + printNextSteps(pin, tarballPath) +} + +function ensureEmcc() { + const r = spawnSync("emcc", ["--version"], { stdio: "pipe" }) + if (r.status !== 0) { + fail( + `emcc not found on PATH.\n` + + `Run: source ~/src/emsdk/emsdk_env.sh (see packages/web/advantagescope/README for setup)`, + ) + } +} + +async function readPin() { + const text = await readFile(join(PIN_DIR, "version.txt"), "utf8") + const pairs = Object.fromEntries( + text + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .filter((l) => !l.startsWith("#")) + .map((l) => { + const i = l.indexOf("=") + return [l.slice(0, i).trim(), l.slice(i + 1).trim()] + }), + ) + if (!pairs.as) fail("version.txt is missing `as=` line") + if (!pairs.bundle) fail("version.txt is missing `bundle=` line") + return { as: pairs.as, bundle: pairs.bundle, releaseUrl: pairs["release-url"] ?? null } +} + +function sh(cwd, cmd, args, extraEnv = {}) { + log(`$ ${cmd} ${args.join(" ")} (in ${relPath(cwd)})`) + const r = spawnSync(cmd, args, { + cwd, + stdio: "inherit", + env: { ...process.env, ...extraEnv }, + }) + if (r.status !== 0) fail(`${cmd} exited with status ${r.status}`) +} + +async function sha256(path) { + return new Promise((resolveHash, rejectHash) => { + const hash = createHash("sha256") + createReadStream(path) + .on("error", rejectHash) + .on("data", (c) => hash.update(c)) + .on("end", () => resolveHash(hash.digest("hex"))) + }) +} + +function printNextSteps(pin, tarballPath) { + const lines = [ + "", + "Done.", + "", + "Next steps:", + " 1. git add packages/web/advantagescope/checksums.txt", + " 2. pnpm fetch:advantagescope # sanity-check local cache", + " 3. pnpm -F @ravenscope/web build # verify bundle ends up in dist/", + "", + ] + if (!pin.releaseUrl) { + lines.push( + " 4. (When publishing) Create a RavenScope GitHub release and attach", + ` the tarball:`, + ` gh release create ${pin.bundle} ${tarballPath} \\`, + ` --title "${pin.bundle}" \\`, + ` --notes "Lite bundle built from AS ${pin.as}"`, + " Then add a line to packages/web/advantagescope/version.txt:", + ` release-url=https://github.com///releases/download/${pin.bundle}/${pin.bundle}.tar.gz`, + "", + ) + } + // eslint-disable-next-line no-console + console.log(lines.join("\n")) +} + +function log(msg) { + // eslint-disable-next-line no-console + console.log(`[publish-advantagescope-bundle] ${msg}`) +} + +function fail(msg) { + // eslint-disable-next-line no-console + console.error(`[publish-advantagescope-bundle] ${msg}`) + process.exit(1) +} + +function relPath(p) { + const root = resolve(WEB_DIR, "..", "..") + if (p.startsWith(root + "/")) return p.slice(root.length + 1) + return p +} + +main().catch((err) => fail(String(err?.stack ?? err))) diff --git a/packages/web/src/app.tsx b/packages/web/src/app.tsx index a21a467..0d7216f 100644 --- a/packages/web/src/app.tsx +++ b/packages/web/src/app.tsx @@ -6,6 +6,7 @@ import { AcceptInvite } from "./routes/accept-invite" import { ApiKeysPage } from "./routes/api-keys" import { CheckEmail } from "./routes/check-email" import { SessionDetail } from "./routes/session-detail" +import { SessionView } from "./routes/session-view" import { Sessions } from "./routes/sessions" import { SignIn } from "./routes/sign-in" import { WorkspaceSettings } from "./routes/workspace-settings" @@ -28,6 +29,7 @@ export function App() { }> } /> } /> + } /> } /> } /> diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 02f30bc..7aef591 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -134,6 +134,18 @@ export function sessionDownloadUrl(id: string): string { return `/api/sessions/${id}/wpilog` } +/** + * URL for the embedded AdvantageScope Lite viewer iframe. The trailing + * slash matters -- AS Lite's relative fetches (logs?folder=..., + * assets/...) resolve against `/v/${id}/`, so they land on our worker + * route handlers. The `?log=` query param is consumed by AS Lite's + * RavenScope-applied main.ts patch to auto-open that file on boot; + * the name is arbitrary since /v/:id/logs/ ignores it. + */ +export function sessionViewerUrl(id: string): string { + return `/v/${encodeURIComponent(id)}/?log=session.wpilog` +} + export async function deleteSession(id: string): Promise { const { status } = await request(`/api/sessions/${id}`, { method: "DELETE" }) if (status !== 204) throw new Error(`delete ${id} returned ${status}`) diff --git a/packages/web/src/routes/session-detail.tsx b/packages/web/src/routes/session-detail.tsx index b70246b..7b5ca3a 100644 --- a/packages/web/src/routes/session-detail.tsx +++ b/packages/web/src/routes/session-detail.tsx @@ -87,8 +87,11 @@ export function SessionDetail() { Delete - + + + +

diff --git a/packages/web/src/routes/session-view.test.tsx b/packages/web/src/routes/session-view.test.tsx new file mode 100644 index 0000000..f5279c8 --- /dev/null +++ b/packages/web/src/routes/session-view.test.tsx @@ -0,0 +1,76 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { cleanup, render, screen } from "@testing-library/react" +import { MemoryRouter, Route, Routes } from "react-router-dom" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { SessionView } from "./session-view" + +/** + * SessionView mounts an iframe whose src is byte-equal to the value + * produced by sessionViewerUrl(id). If this assertion drifts, the auto- + * open contract between the web app and the AS Lite patch (?log=...) + * has broken. + */ +describe("SessionView", () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + globalThis.fetch = vi.fn(async () => { + // session detail is used for the title but its absence shouldn't + // crash the component. + return new Response(null, { status: 401 }) + }) as typeof globalThis.fetch + }) + + afterEach(() => { + cleanup() + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + function renderAt(path: string) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, + }) + return render( + + + + } /> + + + , + ) + } + + it("iframe src matches sessionViewerUrl contract (/v/:id/?log=...)", () => { + renderAt("/sessions/abc-123/view") + const iframe = screen.getByTitle("AdvantageScope viewer") as HTMLIFrameElement + expect(iframe.getAttribute("src")).toBe("/v/abc-123/?log=session.wpilog") + }) + + it("iframe is sandboxed to allow-scripts + allow-same-origin (no downloads, no popups)", () => { + renderAt("/sessions/xyz/view") + const iframe = screen.getByTitle("AdvantageScope viewer") as HTMLIFrameElement + const sandbox = iframe.getAttribute("sandbox") ?? "" + expect(sandbox).toContain("allow-scripts") + expect(sandbox).toContain("allow-same-origin") + expect(sandbox).not.toContain("allow-downloads") + expect(sandbox).not.toContain("allow-popups") + expect(sandbox).not.toContain("allow-forms") + }) + + it("Back link points to the session detail page", () => { + renderAt("/sessions/abc-123/view") + const back = screen.getByRole("link", { name: /back/i }) as HTMLAnchorElement + expect(back.getAttribute("href")).toBe("/sessions/abc-123") + }) + + it("url-encodes the :id param into the iframe src", () => { + // Test that unusual id chars still produce a valid URL. + renderAt("/sessions/a%20b/view") + const iframe = screen.getByTitle("AdvantageScope viewer") as HTMLIFrameElement + // react-router decodes %20 → space in useParams(), and our helper + // then encodeURIComponent-s it back to %20. + expect(iframe.getAttribute("src")).toBe("/v/a%20b/?log=session.wpilog") + }) +}) diff --git a/packages/web/src/routes/session-view.tsx b/packages/web/src/routes/session-view.tsx new file mode 100644 index 0000000..3e67789 --- /dev/null +++ b/packages/web/src/routes/session-view.tsx @@ -0,0 +1,57 @@ +import { useQuery } from "@tanstack/react-query" +import { ChevronLeft } from "lucide-react" +import { Link, useParams } from "react-router-dom" +import { fetchSessionDetail, sessionViewerUrl } from "../lib/api" + +/** + * Full-bleed embedded AdvantageScope Lite viewer for a single session. + * Thin RavenScope chrome at the top (Back + session identity), iframe + * fills the rest of the viewport. + * + * iframe sandbox is `allow-scripts allow-same-origin` -- same-origin is + * required for the session cookie to flow on AS Lite's relative fetches + * under /v/:id/*. Session cookie is HttpOnly (see + * packages/worker/src/auth/cookie.ts), so AS Lite's JS cannot read it + * even with same-origin. allow-downloads is intentionally omitted for + * v1; AS Lite's export flows are not required. + */ +export function SessionView() { + const { id = "" } = useParams() + const detail = useQuery({ + queryKey: ["session", id], + queryFn: () => fetchSessionDetail(id), + enabled: !!id, + }) + + const title = detail.data + ? (detail.data.matchLabel ?? detail.data.sessionId) + : "…" + const subtitle = detail.data?.fmsEventName + + return ( +
+
+ + + Back + + / + + {title} + {subtitle && ( + — {subtitle} + )} + +
+