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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,49 @@ jobs:
name: throttling-${{ matrix.profile }}
path: test-results/

# Multi-resolution visual layout matrix (tests/e2e/layout.spec.ts): one leg
# per device profile so a pull request names the exact resolution whose
# layout regressed. Each leg gates horizontal scrolling, viewport clipping
# and the committed layout baselines.
e2e-layout-matrix:
name: E2E layout matrix (${{ matrix.device }})
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
device: [iphone-se, iphone-14, pixel-7, ipad, laptop, display-4k]
steps:
- name: Checkout Codebase
uses: actions/checkout@v4

- name: Setup Node.js Environment
uses: actions/setup-node@v4
with:
node-version: '22.x'
cache: 'npm'

- name: Install Dependencies
run: npm ci || npm install

- name: Install Chromium
run: npx playwright install --with-deps chromium

- name: Run layout matrix leg
# Called directly instead of through `npm run test:layout` so the leg
# runs exactly one resolution (Playwright unions repeated --project).
run: npx playwright test --project=layout-${{ matrix.device }} --reporter=line

- name: Upload layout diff artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: layout-${{ matrix.device }}
path: |
test-results/
tests/e2e/layout.spec.ts-snapshots/
if-no-files-found: ignore

# Closes #540 — license gate (fails on GPL/unauthorized copyleft),
# install-script allowlist, registry/integrity hijack checks, and a
# committed licenses.json freshness check. Zero dependencies: no install.
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ yarn-error.log*
snapshots/
test-snapshots/

# Playwright run artifacts (layout baselines ARE committed)
test-results/
playwright-report/

# Rust/Soroban build output
contract/**/target/
contracts/**/target/
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ HelPhone is a React + Vite community emergency response application built on Ste
- `npm run lint` (`eslint .`) - Code style & quality checks.
- `npm run typecheck` (`tsc --noEmit`) - Strict TypeScript validation without building output.
- `npm test` - Vitest test suite execution.
- **GitHub Actions CI**: `.github/workflows/ci.yml` enforces quality, linting, type-checking, state export verification, and crypto matrix tests on all pull requests and pushes.
- **GitHub Actions CI**: `.github/workflows/ci.yml` enforces quality, linting, type-checking, state export verification, crypto matrix tests, and the multi-resolution layout matrix on pull requests.

### 3. Dynamic Feature Canary Rollouts & State Evaluation
- **Feature Flag Engine**: `src/lib/featureFlags.ts` evaluates feature flag toggles dynamically.
Expand All @@ -34,6 +34,7 @@ HelPhone is a React + Vite community emergency response application built on Ste

### 5. Performance, Storage Security & Network Resilience
- **HTTP Keep-Alive**: `server/middleware/keepAlive.ts` holds sockets open for 65 s (above the balancer's 60 s idle timeout) so sequential API and WebSocket traffic reuses one TCP connection. See [`docs/performance-optimization.md`](docs/performance-optimization.md).
- **HTTP/2 Push / Preload Manifest**: `server/middleware/http2Push.ts` reads Vite's `dist/.vite/manifest.json` at startup, walks the entry chunk graph and stamps `Link: </assets/index-Abc123.js>; rel=preload; as=script; type=module; crossorigin` on HTML responses (plus 103 Early Hints and, on HTTP/2, `pushStream`). The manifest is re-read when a release changes the asset hashes, so the header always matches what was deployed. See [`docs/performance-optimization.md`](docs/performance-optimization.md).
- **Map Overlay Rendering**: `src/lib/offscreenCanvas.ts` + `src/workers/canvas-worker.js` animate map markers in a Web Worker via OffscreenCanvas, with a main-thread fallback and measured FPS. See [`docs/performance-optimization.md`](docs/performance-optimization.md).
- **Client Storage Encryption**: `src/lib/pbkdf2Key.ts` + `src/lib/secureStorage.ts` derive an AES-256-GCM key via PBKDF2 (100k iterations, per-device salt in IndexedDB) to encrypt local data. See [`docs/security-architecture.md`](docs/security-architecture.md).
- **Network Resilience Testing**: `tests/e2e/throttling.spec.ts` emulates 2G, 3G, a 500 kbps cap, and offline via CDP, with a CI matrix leg per profile. See [`docs/network-resilience.md`](docs/network-resilience.md).
Expand Down Expand Up @@ -63,6 +64,15 @@ HelPhone is a React + Vite community emergency response application built on Ste

---

### 9. Multi-Resolution Visual Layout Matrix
- **Spec**: [`tests/e2e/layout.spec.ts`](tests/e2e/layout.spec.ts) replays the same layout gates over `/`, `/help` and `/ranking` on six device resolutions: iPhone SE (375×667), iPhone 14 (390×844), Pixel 7 (412×915), iPad (768×1024), Laptop (1366×768) and a 4K display (2560×1440).
- **Overflow Detection**: each leg fails when `document.documentElement.scrollWidth` exceeds `window.innerWidth` — horizontal DOM scrolling on a phone cannot be panned back — and when a visible element is clipped by the right edge of the viewport (this is what catches a fixed header bar whose links run past 375 px). Landmark geometry (nav, primary heading) is additionally asserted to stay inside the viewport.
- **Visual Baselines**: viewport screenshots live in [`tests/e2e/layout.spec.ts-snapshots/`](tests/e2e/layout.spec.ts-snapshots) with a 5 % pixel tolerance; non-replayable surfaces (Mapbox canvas, live RPC latency pill, video frames) are frozen or masked before capture so the shot records layout, not fresh data. Regenerate deliberately with `npm run test:layout:generate`.
- **Resolution Projects**: every device is its own Playwright project (`layout-iphone-se` … `layout-display-4k`) declared in `playwright.config.js`, so a failure names its resolution. `npm run test:layout` runs the whole matrix; `npx playwright test --project=layout-iphone-se` runs one leg.
- **CI Matrix**: the `e2e-layout-matrix` job in [`.github/workflows/ci.yml`](.github/workflows/ci.yml) fans out one leg per resolution on pull requests (`fail-fast: false`) and uploads `test-results/` plus the baselines when a leg fails.

---

## Quick Start

```bash
Expand All @@ -79,6 +89,9 @@ npm run typecheck
# Run complete Vitest test suite
npm test

# Run the multi-resolution Playwright layout matrix (6 device profiles)
npm run test:layout

# Export Soroban contract storage state manually
npm run export:state
```
Expand Down
67 changes: 67 additions & 0 deletions docs/performance-optimization.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,70 @@ registerRouteLoaders({ "/thing": loadThing });
`resourceHints`) before the click; the landing page's initial requests no
longer include the `mapbox` / `zk` chunks.
- Lighthouse (mobile, throttled): compare FCP before/after on `/`, `/help`, `/ranking`.

## HTTP/2 push & preload manifest (`server/middleware/http2Push.ts`)

Goal: get the entry page's compiled chunks into the browser's preload scanner
before the HTML has been parsed, using the asset hashes this release actually
shipped — with no hand-maintained list to drift.

### Layers

| Layer | Where | What |
| --- | --- | --- |
| Manifest emission | `vite.config.ts` → `build.manifest: true` | Every `vite build` writes `dist/.vite/manifest.json`: entry chunk, its static `imports`, and per-chunk `css`, all under fingerprinted names. |
| Manifest reader | `createManifestStore()` | Reads the manifest once at startup (not on first request), caches the parsed asset list, and re-checks the file's mtime/size on a throttled interval (`refreshIntervalMs`, default 5 s; `0` = every request). A changed manifest is re-parsed, so a new deploy's hashes replace the old ones without a restart. |
| Entry graph | `collectEntryAssets()` | Depth-first over `index.html` → `isEntry` chunks → `imports` → their `css`, deduplicated. `dynamicImports` are **not** walked: Mapbox / ZK / WASM chunks stay on-intent, matching the `modulePreload.resolveDependencies` filter in `vite.config.ts` (they are additionally dropped by `HEAVY_CHUNK_RE`). |
| Push header | `buildLinkHeader()` | `Link: </assets/index-Abc123.js>; rel=preload; as=script; type=module; crossorigin, </assets/index-XyZ987.css>; rel=preload; as=style; type=text/css` — capped at `maxAssets` (default 16) so the header stays under proxy header limits. |
| Middleware | `createHttp2PushMiddleware()` | Attached in `server/index.ts` before the static/HTML handlers. Applies to HTML document navigations only (never `/api`, `/zk`, `/metrics`, `/health`, assets with extensions, non-`GET`/`HEAD`, or `sec-fetch-dest` other than a document). Appends to any existing `Link` instead of overwriting it. |
| Early Hints | HTTP/1.1 | When the runtime exposes `res.writeEarlyHints`, the same list is sent as `103 Early Hints` before the document, then repeated in the final `Link` header. Best-effort: wrapped in `try/catch`, never fails a response. |
| Native push | HTTP/2 | If the origin really terminates HTTP/2 (`req.httpVersionMajor === 2` and `res.stream.pushStream` exists), each entry asset is pushed on its own stream from `dist/`, with `cache-control: public, max-age=31536000, immutable` (fingerprinted URLs). A missing file answers `404` on the pushed stream; a rejected push never throws. |

### Asset hash sync across releases

1. Startup load — the manifest is read when the middleware is constructed.
2. Throttled re-check — mtime/size comparison; only a *changed* manifest is re-parsed.
3. Forced re-read — `store.reload()` (deploy hook, tests) bypasses both caches and
re-runs file verification.
4. Optional `verifyFiles` (`HTTP2_PUSH_VERIFY_FILES=true`) drops assets whose file
is no longer on disk, so a header can never point at a pruned build.
5. Missing manifest = empty snapshot: API-only deploys and pre-build boots are
a no-op, and the header starts working as soon as `vite build` lands.

### Configuration

| Env var | Default | Notes |
| --- | --- | --- |
| `HTTP2_PUSH_ENABLED` | `true` | Master switch. |
| `HTTP2_PUSH_EARLY_HINTS` | `true` | 103 hints on HTTP/1.1. |
| `HTTP2_PUSH_NATIVE` | `true` | `pushStream` on HTTP/2 (browsers have mostly withdrawn push support; harmless when unsupported). |
| `HTTP2_PUSH_VERIFY_FILES` | `false` | Drop assets missing on disk. |
| `HTTP2_PUSH_INCLUDE_HEAVY` | `false` | Set `true` to push the Mapbox/ZK chunks too (usually a pessimization). |
| `HTTP2_PUSH_REFRESH_MS` | `5000` | Manifest mtime re-check interval; `0` = every request. |
| `HTTP2_PUSH_MAX_ASSETS` | `16` | Cap on assets in one `Link` header. |
| `HTTP2_PUSH_DIST_DIR` | `dist` | Override the build output directory. |
| `HTTP2_PUSH_MANIFEST` | – | Explicit manifest path (wins over discovery of `.vite/manifest.json` then `manifest.json`). |

`render.yaml` sets `HTTP2_PUSH_ENABLED`, `HTTP2_PUSH_EARLY_HINTS`,
`HTTP2_PUSH_NATIVE` and `HTTP2_PUSH_VERIFY_FILES`.

### Guard rails

- No header on JSON/API responses, static asset requests, or unsafe methods —
preload hints there would only cost bytes.
- Link parts never carry unquoted `;`: MIME parameters are stripped before the
header is assembled (`type=text/css`, not `type="text/css; charset=utf-8"`).
- A half-written manifest (build in progress) is logged and ignored; the last
good snapshot keeps serving.
- Push and hints are strictly best-effort: every failure path falls through to
`next()` with the document served normally.

### Verifying

- Unit tests: `npm run test:http2-manifest` (36 cases: graph walking, header
format/caps, hash re-reads, `verifyFiles`, document gating, early hints,
HTTP/2 push incl. missing-asset 404, env parsing).
- `curl -I http://localhost:3001/` → `Link:` lists the current `assets/*.js` /
`*.css` fingerprints from `dist/.vite/manifest.json`.
- Rebuild (`npm run build`) without restarting: the header picks up the new
hashes on the next refresh window.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"preview": "vite preview",
"test": "vitest run",
"benchmark:routing": "vitest run test/routing-benchmark-spike.test.js --reporter=verbose",
"test:http2-manifest": "vitest run test/http2-manifest.test.js",
"security:lockfiles": "node scripts/verify-lockfile-hashes.js",
"security:lockfiles:offline": "node scripts/verify-lockfile-hashes.js --offline",
"security:install": "bash scripts/sandbox-install.sh",
Expand All @@ -33,7 +34,10 @@
"test:ws-cluster": "node server/tests/ws-cluster.js",
"zk:shard": "python circuits/scripts/shard-aegis.py circuits/target/aegis.json public/zk-assets",
"test:watch": "vitest",
"test:update-snapshots": "vitest run test/snapshots.test.jsx -u",
"test:e2e:throttling": "playwright test --project=throttling",
"test:layout": "playwright test --project=\"layout-*\"",
"test:layout:generate": "playwright test --project=\"layout-*\" --update-snapshots",
"lint": "eslint .",
"security:audit-deps": "node scripts/audit-deps.js",
"security:audit-deps:check": "node scripts/audit-deps.js --check",
Expand Down Expand Up @@ -104,6 +108,8 @@
"react-i18next": "^16.5.4",
"react-map-gl": "^8.1.1",
"react-router-dom": "^7.18.0",
"workbox-range-requests": "^7.4.1",
"ws": "^8.18.3",
"zod": "^3.25.76"
},
"apiDrift": {
Expand Down Expand Up @@ -2497,6 +2503,5 @@
}
}
}
"workbox-range-requests": "^7.4.1"
}
}
65 changes: 64 additions & 1 deletion playwright.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
import { defineConfig } from '@playwright/test'

// Multi-resolution layout matrix — one project per device profile so a PR
// check names the exact resolution it broke on. The specs themselves are
// viewport-agnostic; tests/e2e/layout.spec.ts measures whatever viewport
// the project injects. `npm run test:layout` runs every leg,
// `npm run test:layout -- --project=layout-iphone-se` runs one.
const MOBILE_UA = {
'layout-iphone-se':
'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1',
'layout-iphone-14':
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
'layout-pixel-7':
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
'layout-ipad':
'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
}

const layoutProject = (name, use) => ({
name,
testMatch: /layout\.spec\.ts/,
use: { browserName: 'chromium', ...use },
})

export default defineConfig({
expect: {
toHaveScreenshot: { maxDiffPixelRatio: 0.002, animations: 'disabled' },
Expand All @@ -22,12 +44,53 @@ export default defineConfig({
projects: [
// Throttling runs in its own project: slow-network navigations need a much
// larger timeout and would make the default suite crawl.
{ name: 'chromium', testIgnore: /throttling\.spec\.ts/, use: { browserName: 'chromium' } },
{
name: 'chromium',
testIgnore: [/throttling\.spec\.ts/, /layout\.spec\.ts/],
use: { browserName: 'chromium' },
},
{
name: 'throttling',
testMatch: /throttling\.spec\.ts/,
timeout: 180_000,
use: { browserName: 'chromium' },
},
// ── Multi-resolution visual layout matrix ─────────────────────
layoutProject('layout-iphone-se', {
viewport: { width: 375, height: 667 },
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true,
userAgent: MOBILE_UA['layout-iphone-se'],
}),
layoutProject('layout-iphone-14', {
viewport: { width: 390, height: 844 },
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
userAgent: MOBILE_UA['layout-iphone-14'],
}),
layoutProject('layout-pixel-7', {
viewport: { width: 412, height: 915 },
deviceScaleFactor: 2.625,
isMobile: true,
hasTouch: true,
userAgent: MOBILE_UA['layout-pixel-7'],
}),
layoutProject('layout-ipad', {
viewport: { width: 768, height: 1024 },
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true,
userAgent: MOBILE_UA['layout-ipad'],
}),
layoutProject('layout-laptop', {
viewport: { width: 1366, height: 768 },
deviceScaleFactor: 1,
}),
layoutProject('layout-display-4k', {
viewport: { width: 2560, height: 1440 },
deviceScaleFactor: 1,
}),
],
})
16 changes: 16 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,22 @@ services:
# Set to "true" to roll the policy out in report-only mode first.
- key: CSP_REPORT_ONLY
value: "false"
# HTTP/2 push / preload manifest (server/middleware/http2Push.ts): the
# server reads dist/.vite/manifest.json (emitted by `vite build`) and
# stamps `Link: <...>; rel=preload` on HTML responses, re-reading the
# manifest whenever a release changes the asset hashes. No-ops when the
# build output is absent (API-only deploy).
- key: HTTP2_PUSH_ENABLED
value: "true"
# Emit a 103 Early Hints `Link` ahead of the document on HTTP/1.1.
- key: HTTP2_PUSH_EARLY_HINTS
value: "true"
# Native `pushStream` when the origin terminates HTTP/2 itself.
- key: HTTP2_PUSH_NATIVE
value: "true"
# Drop assets missing on disk from the push list (guards mid-release).
- key: HTTP2_PUSH_VERIFY_FILES
value: "false"

- type: cron
name: soroban-daily-state-exporter
Expand Down
10 changes: 10 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import {
import { SorobanStateExporter, loadLatestSnapshot } from './indexer/exporter.js'
import { authMiddleware } from './middleware/auth.js'
import { createCspMiddleware, createHtmlHandler } from './middleware/csp.js'
import {
createHttp2PushMiddleware,
optionsFromEnv as http2PushOptionsFromEnv,
} from './middleware/http2Push.js'
import { createPasskeyAuthRouter } from './routes/passkey-auth.js'

const __dirname = dirname(fileURLToPath(import.meta.url))
Expand Down Expand Up @@ -103,6 +107,12 @@ app.post('/api/protected/action', authMiddleware, (req: Request, res: Response)
// Built frontend (nonce-injected HTML). Only active when `vite build` output
// exists, so an API-only deployment keeps behaving exactly as before.
const DIST_DIR = join(__dirname, '..', 'dist')

// HTTP/2 push / preload hints for the built entry chunks
// (see middleware/http2Push.ts). Reads dist/.vite/manifest.json at startup and
// re-reads it whenever a release changes the asset hashes, so the `Link`
// header always matches the files that were actually deployed.
app.use(createHttp2PushMiddleware({ ...http2PushOptionsFromEnv(process.env, { distDir: DIST_DIR }) }))
app.use(express.static(DIST_DIR, { index: false }))
app.get(/^\/(?!api\/|zk\/|health$|metrics).*/, createHtmlHandler({ htmlPath: join(DIST_DIR, 'index.html') }))

Expand Down
Loading