diff --git a/.changeset/live-mode-rsc.md b/.changeset/live-mode-rsc.md new file mode 100644 index 000000000..4525b2734 --- /dev/null +++ b/.changeset/live-mode-rsc.md @@ -0,0 +1,31 @@ +--- +"@valbuild/core": minor +"@valbuild/shared": minor +"@valbuild/server": minor +"@valbuild/next": minor +--- + +Add live mode: render content that has been saved in Val but is not yet deployed. + +Until now an app rendered exactly what was compiled into the deploy. When an editor hit **Save**, the change was committed — but nobody saw it until CI had rebuilt and redeployed. On a large site that is minutes; if the deploy pipeline is broken, it is never. + +Live mode is an opt-in config flag that closes that gap for _everyone_, with no login and no cookie: + +```ts +const { s, c, val, config } = initVal({ + project: "myteam/myproject", + gitBranch: process.env.VERCEL_GIT_COMMIT_REF, + gitCommit: process.env.VERCEL_GIT_COMMIT_SHA, + live: { ttl: 60, staleWhileRevalidate: 300 }, +}); +``` + +`ttl` is required (0 is allowed, meaning always refetch), since live mode has to ask Val what changed on every render unless we cache. `VAL_LIVE_TTL`, `VAL_LIVE_STALE_WHILE_REVALIDATE` and `VAL_LIVE_DISABLED=true` override it per environment. Live mode requires remote mode; in local development it warns and does nothing. + +This release covers the server-rendered surfaces: `fetchVal`, `fetchValRoute` and `fetchValRouteUrl` resolve live content, so the HTML is already correct on a hard load — including for a route that only exists in a committed patch, and for images added by one. Client Components (`useVal`) still render the build-time content; support is coming. + +One caveat to be aware of: Next decides how often to re-render a prerendered page from the fetches performed during that render, and Val caches the live patch set in-process - so set `export const revalidate = ` in your root layout, or statically generated pages will keep serving the content they were built with. See the Live mode section of the `@valbuild/next` README. + +Val is never in the critical path for correctness. A slow, unreachable or unexpected response falls back to the last good patch set, and failing that to the deployed content — it never throws and never 500s a page. Live content is public content, so `data-val-path` editing markers stay bound to draft mode and are never emitted for it. + +Also enables the immutable `Cache-Control` on `/api/val/files` for the `patch_id` branch, which was previously commented out: those responses are content-addressed, and live mode makes that route considerably hotter. diff --git a/LIVE_MODE_PLAN.md b/LIVE_MODE_PLAN.md new file mode 100644 index 000000000..be21a993e --- /dev/null +++ b/LIVE_MODE_PLAN.md @@ -0,0 +1,534 @@ +# Live Mode — committed patches rendered for anonymous end users + +## Context + +Today Val renders **exactly what was compiled into the deploy**. `*.val.ts` sources are plain +JS objects in the bundle; both `fetchVal` (RSC) and `useVal` (client) collapse to +`stegaEncode(selector, { disabled: true })` for anyone without draft mode +(`packages/next/src/rsc/initValRsc.ts`, `packages/next/src/client/initValClient.ts`). + +Patches are only ever applied for a logged-in editor, and even then only the _uncommitted_ ones: +`ValOps.analyzePatches` skips every patch with `appliedAt` set +(`packages/server/src/ValOps.ts:261`). So after an editor hits **Save**, the change is committed to +git and recorded in the cloud — but nobody sees it until CI rebuilds and redeploys. On a large site +that is minutes; if the deploy pipeline is broken it is never. Editors see the same stale content +as end users. + +**Live mode** closes that gap: an opt-in config flag that makes the app render committed patches +that are not yet in the running deploy, for _everyone_, with no login and no cookie. It is +deliberately shaped like the `suspend` opt-in from PR #431 — a single config/prop switch that +changes which source `stegaEncode` reads, with all the plumbing hidden behind it. + +Two things make this non-trivial and drive the design: + +- The app must ask the cloud "what changed since my commit?" on every request unless we cache. So a + **cache TTL is mandatory** when live mode is on (0 is allowed, meaning always refetch). +- **The commit sha does not identify a deploy.** The same commit can be deployed many times, and + the evaluated sources can differ between those deploys (a dependency bump changes `baseSha` + without changing the commit). Anything cached must therefore be keyed on `baseSha` as well as the + commit, and the cloud must never assume commit ⇒ deploy identity. + +Decisions already taken: RSC + client hooks + route resolution are all in scope; live sources reach +client components via a fetch after hydration; a new dedicated cloud endpoint; stale cache preferred +over fallback on failure; proxy (http) mode only. `ValProvider`'s `suspend` prop also grows from a +boolean into `"always" | "never" | "draft" | "live"` (2.5.1), since live mode introduces a second +kind of data worth waiting for. + +--- + +## Data flow + +``` +content.val.build Next.js app (ValServer, in-process) Browser +───────────────── ────────────────────────────────── ─────── +GET /v1/{project}/live/patches ← ValOpsHttp.fetchLivePatches() + ?branch&commit&base_sha └ LiveCache: ttl + stale-while-revalidate + + stale-if-error (keyed on + project|branch|commit|baseSha|coreVersion) + → { headCommitSha, patches[] } + ValOps.analyzePatches(…, {includeApplied:true}) + ValOps.getSources(analysis) + └ Record (changed modules only) + │ + ┌───────────────────┴────────────────────┐ + │ │ + fetchValStega (RSC) GET /api/val/live/sources → ValProvider + stegaEncode(sel, {getModule}) (unauthenticated, Cache-Control) └ valStore.update() + └ useValStega +``` + +Nothing new is invented for patch application: `analyzePatches` + `getSources` are the exact +functions the editor path already uses, and `stegaEncode`'s `getModule(path)` callback +(`packages/react/src/stega/stegaEncode.ts:406`) is the single override seam both surfaces already +funnel through. + +--- + +## Part 1 — content.val.build (separate repo) + +This is the hand-off prompt for the cloud repo. Ship it before the client work; the app side can be +developed against a stub. + +### New endpoint + +``` +GET /v1/{org}/{project}/live/patches + ?branch= required — the deploy's git branch + &commit= required — the deploy's git commit (VAL_GIT_COMMIT) + &base_sha= required — the deploy's evaluated baseSha (see below) + &core_version= required +Authorization: Bearer (same auth as /applicable/patches) +``` + +Response `200`: + +```jsonc +{ + "headCommitSha": "…", // newest commit on `branch` known to the cloud + "baseCommitSha": "…", // echo of the requested `commit` + "patches": [ + { + "patchId": "…", + "path": "/content/authors.val.ts", + "patch": [ + /* patch ops */ + ], + "baseSha": "…", + "createdAt": "…", + "authorId": "…", + "appliedAt": { "commitSha": "…" }, // always non-null on this route + }, + ], +} +``` + +Semantics — deliberately _narrower_ than `/applicable/patches`: + +1. **Committed only.** Every returned patch has `appliedAt.commitSha` set. Uncommitted drafts must + never appear here; this response is served to anonymous end users. +2. **Not-yet-deployed only.** Return patches whose `appliedAt.commitSha` is a descendant of the + requested `commit` on `branch` — i.e. landed _after_ the deploy's commit. If `commit` is unknown + or not an ancestor of head (force-push, rollback to a detached sha), return + `{ headCommitSha, baseCommitSha, patches: [] }` with a `x-val-live-degraded: unknown-base` + header rather than an error. Falling back to deployed content is always safe; guessing is not. +3. **Ordered** oldest → newest, same ordering guarantee as `/applicable/patches` — the app applies + them in sequence. +4. **No `patch_id` filter, no chunking.** Avoid the "returns everything regardless of filter" trap + documented at `packages/server/src/ValOpsHttp.ts:611-633`. + +### Caching contract + +- `ETag` on the response, derived from `(branch, commit, headCommitSha, set of patch ids)`. Honour + `If-None-Match` with `304`. +- `Cache-Control: public, max-age=, stale-while-revalidate=` — the app sends its own + TTL, but the cloud must not depend on the app honouring it. +- **Do not key any cache on deploy identity.** Several deploys can share one commit sha, and one + commit sha can correspond to different evaluated sources. `commit` + `base_sha` together are the + cache key; `base_sha` is opaque to the cloud but must be part of the key and echoed back so the + app can detect a mismatched response. +- This endpoint is read-only and hot. Prefer a CDN/edge cache in front of it. + +### Retention requirement (blocking) + +`GET /v1/{project}/patches/{patchId}/files` **must keep serving binary files for patches that have +already been committed**, for as long as any deploy might still be live-rendering them. + +Why: an image added by a committed-but-undeployed patch does not exist in the deployed bundle. The +app injects `patch_id` into the image source (`ValOps.getSources`, the `op.op === "file"` branch) so +the URL becomes `/api/val/files/public/val/x.jpg?patch_id=…`, which the app resolves via that cloud +route. If patch files are garbage-collected on commit, live mode renders broken images. + +If retention is not acceptable, the alternative is a commit-scoped file read +(`PUT /v1/{project}/files` already supports `{ location: "repo", commitSha }`) — but that requires +changing `convertFileSource`'s URL rules in `@valbuild/core`, so retention is strongly preferred. + +### Non-goals for the cloud + +No new auth scheme (reuse the API key), no per-request source materialisation (the cloud cannot +evaluate the deploy's TS), no websocket/push channel. + +--- + +## Part 2 — this repo + +### 2.1 Config surface + +`live` is the on-switch; presence of the object enables live mode. `ttl` is **required** (0 allowed). + +```ts +live?: { + /** Seconds to cache the live patch set. 0 = always refetch. Required. */ + ttl: number; + /** Seconds past `ttl` a stale entry may be served while refreshing in the background. */ + staleWhileRevalidate?: number; +}; +``` + +Add to every place `ValConfig` is duplicated — grep `gitCommit` to find them all: + +- `packages/core/src/initVal.ts` — the canonical `ValConfig` type +- `packages/shared/src/internal/SharedValConfig.ts` — zod, shipped to the studio via `/stat` +- `packages/shared/src/internal/ApiRoutes.ts:25-34` — the inline `ValConfig` zod +- `packages/init/src/templates.ts:60-70` — the template's local copy +- `packages/cli/src/utils/evalValConfigFile.ts:29-30` — CLI config validation + +Runtime validation (for JS consumers where the type is not enforced): if `live` is present, `ttl` +must be a finite non-negative number, else throw a config error from `initHandlerOptions` in +`packages/server/src/ValRouter.ts` with the same style as the existing `VAL_GIT_COMMIT` error. + +Env overrides, resolved in `initHandlerOptions` alongside `valContentUrl`: +`VAL_LIVE_TTL`, `VAL_LIVE_STALE_WHILE_REVALIDATE`, `VAL_LIVE_DISABLED=true` (kill switch for +preview deploys). Live mode is **proxy/http mode only** — in fs mode, log a one-time warning and +no-op. + +### 2.2 Fetch + cache — `packages/server` + +**New file `packages/server/src/LiveCache.ts`.** A small TTL + stale-while-revalidate + +stale-if-error cache. Single entry (the live patch set), keyed on +`${project}|${branch}|${commit}|${baseSha}|${coreVersion}`: + +- fresh (`age < ttl`) → return +- stale (`ttl <= age < ttl + swr`) → return immediately, kick off a background refresh, dedupe + concurrent refreshes +- expired → await the refresh; on failure return the stale entry if there is one, else `null` +- `ttl === 0` → always await a fresh fetch (still dedupe within a single tick) + +The `baseSha` component of the key is the answer to "several deploys from one commit": a redeploy +whose evaluated sources differ produces a different `baseSha` and therefore never reuses the +previous deploy's entry. + +**`packages/server/src/ValOpsHttp.ts`** — add, following the `getCommitMessage` template at +`:1283-1332` (never throw, `safeParse` + `fromError`, `console.error` then return +`{ error: GenericErrorMessage }`): + +- `const LivePatchesResponse = z.object({...})` next to `GetApplicablePatches` at `:67` +- `async fetchLivePatches(): Promise` — `GET +${contentUrl}/v1/${project}/live/patches?branch&commit&base_sha&core_version`, with + `this.authHeaders`, and `next: { revalidate: ttl }` on the `fetch` options (a no-op outside + Next, and inside Next it dedupes across instances on top of our in-process cache). `ttl === 0` + → `cache: "no-store"`. +- Wire the `LiveCache` around it so callers get the cached value. + +**`packages/server/src/ValOps.ts`** + +- `analyzePatches(sortedPatches, commits?, currentCommitSha?, opts?: { includeApplied?: boolean })` + — when `includeApplied` is set, drop the `if (patch.appliedAt) continue;` guard at `:261`. Do not + change the default; the draft path must keep skipping applied patches. +- `async getLiveSources(): Promise<{ sources: Sources; headCommitSha: string | null }>` on the base + class, returning `{}` for `ValOpsFS`. In `ValOpsHttp`: `fetchLivePatches()` → + `analyzePatches(patches, undefined, commit, { includeApplied: true })` → `getSources(analysis)`. + `getSources` already returns **only the modules that had patches**, which is what we want on the + wire. No validation, no renders, no TS work — this path must stay cheap. + +### 2.3 App endpoint — `packages/shared` + `packages/server` + +New route in `packages/shared/src/internal/ApiRoutes.ts` (model on `/commit-summary` at `:907`): + +```ts +"/live/sources": { + GET: { + req: {}, // no cookies — anonymous by design + res: z.union([ + z.object({ status: z.literal(400), json: GenericError }), + z.object({ + status: z.literal(200), + headers: z.record(z.string()).optional(), + json: z.object({ + headCommitSha: z.string().nullable(), + sources: z.record(ModuleFilePath, z.any()), // changed modules only + }), + }), + ]), + }, +}, +``` + +Implement in `packages/server/src/ValServer.ts` next to `/files` (`:2585`) and reuse its +justification for skipping auth: **this endpoint only ever returns content that is already +committed and therefore public.** Set +`Cache-Control: public, max-age=${ttl}, stale-while-revalidate=${swr}` (or `no-store` when +`ttl === 0`) — `initValServer`'s response converter already forwards `headers` +(`packages/next/src/server/initValServer.ts`). Return `{ headCommitSha: null, sources: {} }` when +live mode is off or the mode is fs, so the client never needs to branch. + +While here: enable the commented-out immutable cache header on `/files` for the `patch_id` branch +(`ValServer.ts:2610`) — those responses are content-addressed and are about to get a lot more +traffic. + +### 2.4 RSC — `packages/next/src/rsc/initValRsc.ts` + +In `initFetchValStega`, the `if (enabled)` block stays untouched. Add an `else` branch: when live +mode is configured, `await valServer["/live/sources"]["GET"]({})` (in-process call, same style as +the existing `valServer["/sources/~"]["PUT"]` call) and pass the result through: + +```ts +return stegaEncode(selector, { + disabled: !enabled, // stega stays OFF for end users + getModule: (path) => liveSources[path], // but the source is overridden +}); +``` + +Two constraints: + +- **Never read cookies or headers on this path.** `getHost`/`getCookies` are only reached under + `enabled`. Live mode must not add a dynamic API, or every route loses static generation — the + hazard PR #431 hit and documented in `packages/next/src/initVal.ts:20-27`. +- `disabled` must stay tied to draft mode, not to live mode, so no `data-val-path` stega markers + leak into public HTML. + +`fetchValRoute` / `fetchValRouteUrl` need no change — they call `fetchVal` internally, so a route +that only exists in a committed patch resolves for free. New routes are not in +`generateStaticParams`; Next's default `dynamicParams: true` renders them on demand. Document this. + +### 2.5 Client hooks — `packages/next` + +**`ValNextProvider.tsx`** — a new effect, mirroring the existing `val-event` listener at `:287`: +when `props.config.live` is set and the Val Enable cookie is _absent_ (editors keep the draft path), +`fetch("/api/val/live/sources")` after hydration and `valStore.update(path, source)` for each entry. +Re-fetch on an interval of `ttl` seconds when `ttl > 0`. Reuse `hasValEnableCookie` for the check. +Track it in a `liveActive` state and pass it through `ValOverlayProvider` alongside `suspend`. + +**`ValOverlayContext.tsx`** — add `live: boolean` to the context (see 2.5.1 for the rest of the +context changes). + +**`client/initValClient.ts`** — `useValStega` currently gates `getModule` on `draftMode`: + +```ts +getModule: (moduleId) => { + if (moduleMap && valOverlayContext.draftMode) return moduleMap[moduleId]; +}; +``` + +Change the condition to `draftMode || live`. + +Expected behaviour without a suspend mode: build-time content renders first, then each module swaps +as it lands. This is the accepted trade-off of the client-fetch approach — document it, and point at +`suspend="live"` (2.5.1) for apps that want an atomic swap instead. + +### 2.5.1 Suspend modes — `suspend?: boolean | "always" | "never" | "draft" | "live"` + +PR #431 shipped `suspend` as a boolean meaning "wait for draft data before rendering". Live mode +adds a second thing worth waiting for, so the prop grows into an enum: + +| Value | Suspends for editors (Val Enable cookie present) | Suspends for everyone else | +| ----------------------------- | ------------------------------------------------ | --------------------------------- | +| `"never"` / omitted / `false` | no | no | +| `"draft"` / `true` | yes, until draft sources load | no | +| `"live"` | no | yes, until the live fetch settles | +| `"always"` | yes | yes | + +**`true` keeps meaning `"draft"`** — exactly what PR #431 shipped. Turning on live mode must never +silently start showing a Suspense fallback to anonymous visitors; that requires typing `"live"` or +`"always"`. Normalise once (`false | undefined → "never"`, `true → "draft"`) at the top of +`ValNextProvider` and pass only the enum downwards. The init codemod +(`packages/init/src/codemods/transformNextAppRouterValProvider.ts:69-79`) keeps emitting the bare +attribute, so generated apps are unaffected. + +`"live"` names the _data source_ being waited for, not the audience or the environment — "prod" +would be wrong, since draft mode also runs in production. Symmetric with the `live` config flag: +`suspend="live"` reads as "suspend while live sources load". + +**Activation** — `ValNextProvider.tsx:105-117`, extending the existing post-hydration effect. Keep +the `startTransition` and keep the rule that the gate never deactivates: + +```ts +const mode = normalizeSuspend(props.suspend); // "never" | "draft" | "live" | "always" +React.useEffect(() => { + const isEditor = shouldEnableVal(); // cookie + not /val + not ?message_onready + const draft = (mode === "always" || mode === "draft") && isEditor; + const live = + (mode === "always" || mode === "live") && !isEditor && liveEnabled; + if (draft || live) { + startTransition(() => setSuspendActive({ draft, live })); + } +}, [mode]); +``` + +Same constraints as today: false during SSR and the hydration render (the store is only ever +populated in the browser), browser-only checks, `"live"` is a no-op when `config.live` is unset — +warn once in dev if someone asks for it without enabling live mode. + +**Release valves.** The draft valve stays `draftMode !== false`. The live gate needs its own, and it +cannot reuse `hasAllLoaded`: a live response only carries the modules that actually changed, so a +module with no live patch would never be marked loaded and every component touching it would suspend +until `LOAD_TIMEOUT_MS` fires — then re-suspend on the next render, because the resolved promise is +evicted from the cache. That is the exact failure PR #431 documented for draft mode, and it would +hit _every page_ here. + +So add to `ValExternalStore` (`ValOverlayContext.tsx`), mirroring `waitForLoad`/`loadListeners`: + +- `settleLive()` — called by `ValNextProvider` once the live fetch resolves **or fails**, after the + `update()` calls for the changed modules +- `isLiveSettled()` / `waitForLive()` — a single cached promise resolved by `settleLive()`, with the + same timeout backstop and `console.error` so a hung fetch can never pin the boundary + +The gate in `useValStega` then becomes: + +```ts +const { draft, live } = valOverlayContext.suspend; +if ( + draft && + valOverlayContext.draftMode !== false && + store && + !store.hasAllLoaded(moduleIds) +) { + React.use(store.waitForLoad(moduleIds)); +} else if (live && store && !store.isLiveSettled()) { + React.use(store.waitForLive()); +} +``` + +`React.use` is legal inside conditionals (it is not a hook), same as today. + +**What `"live"` actually buys — be honest in the docs.** SSR and hydration always render the +build-time source, so: + +- _Hard load_ of a page: the HTML is already correct if the page is a Server Component, because the + RSC path (2.4) resolves live sources server-side. For a `"use client"` page the HTML is build-time + content either way; `"live"` only makes the subsequent swap atomic instead of per-module. +- _Hard load of a route that only exists in a committed patch_: works via the RSC path; a + `"use client"` page still 404s before hydration, exactly as PR #431 noted for drafts. +- _Client-side navigation_ to such a route: this is the real win. Without a suspend mode + `useValRoute` returns null and the page calls `notFound()`; with `"live"` it waits. Needs a + `loading.tsx` to catch it. + +### 2.6 Editor consistency (same code path, worth doing here) + +Today an editor in draft mode also does **not** see committed-but-undeployed content, for the same +`if (patch.appliedAt) continue;` reason. With live mode on, the draft path should start from live +sources and layer uncommitted patches on top, so the editor sees what end users see plus their own +drafts. Concretely: in `ValServer.ts`'s `/sources/~` handler (`:1365`, both the `:1698` and `:1762` +`analyzePatches` call sites), when live mode is on, seed from `getLiveSources()` before applying the +uncommitted analysis. Guard it behind live mode so nothing changes for projects that do not opt in. + +### 2.7 Supporting changes + +- `packages/init` — teach `transformNextAppRouterValProvider` nothing new (no JSX change needed), + but the generated `val.config.ts` template should carry a commented-out `live: { ttl: 60 }` block + with a one-line explanation. +- `examples/next/val.config.ts` — enable `live: { ttl: 60, staleWhileRevalidate: 300 }` so the + example app exercises it. Note `project` is currently commented out there; re-enabling live mode + locally requires proxy mode. +- `packages/next/README.md` — a Live mode section: what it does, the TTL contract, the + no-flash caveat for client components, the `suspend` truth table from 2.5.1, and that it + requires `VAL_GIT_COMMIT`/`VAL_API_KEY`. +- A changeset (`.changeset/*.md`) — `@valbuild/core`, `@valbuild/shared`, `@valbuild/server`, + `@valbuild/next` minor. + +--- + +## Files to touch + +| Area | File | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Config type | `packages/core/src/initVal.ts` | +| Config zod (×3) | `packages/shared/src/internal/SharedValConfig.ts`, `packages/shared/src/internal/ApiRoutes.ts`, `packages/cli/src/utils/evalValConfigFile.ts` | +| Route contract | `packages/shared/src/internal/ApiRoutes.ts` (`/live/sources`) | +| Cache | `packages/server/src/LiveCache.ts` _(new)_ | +| Cloud fetch | `packages/server/src/ValOpsHttp.ts` | +| Patch analysis / apply | `packages/server/src/ValOps.ts` | +| App endpoint | `packages/server/src/ValServer.ts` | +| Config resolution | `packages/server/src/ValRouter.ts` | +| RSC | `packages/next/src/rsc/initValRsc.ts` | +| Client | `packages/next/src/ValNextProvider.tsx`, `ValOverlayContext.tsx`, `client/initValClient.ts` | +| Template / example / docs | `packages/init/src/templates.ts`, `examples/next/val.config.ts`, `packages/next/README.md` | + +--- + +## Risks and how they are handled + +| Risk | Handling | +| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Same commit deployed twice with different evaluated sources | `baseSha` is part of the cache key and is sent to the cloud; commit sha alone is never treated as deploy identity | +| Deploy commit is not an ancestor of head (rollback, force-push) | Cloud returns an empty patch set + `x-val-live-degraded`; app renders deployed content | +| Cloud outage | stale-while-revalidate → stale-if-error → build-time content. Never throws, never 500s a page | +| Images from committed-but-undeployed patches | `patch_id` URLs via the already-unauthenticated `/files`; blocking retention requirement on the cloud | +| Prerendered pages never revalidating | Next derives a page's revalidate from the fetches performed while rendering it, and `LiveCache` suppresses the fetch on a hit - so a prerendered page can be built with `revalidate: false` and never pick up live content. Apps must set `export const revalidate = ` (documented in the README, done in the example). Open follow-up: let Next's fetch cache own the ttl so the hint is always registered | +| A dynamic API sneaking into the live path | Live path must not touch `cookies()`/`headers()`/`draftMode()`; assert in review and in the example app's build output | +| Stega markers leaking to public HTML | `disabled` stays bound to draft mode only; covered by a unit test | +| Patch fails to apply against older sources | `getSources` already records per-module errors and skips; live mode logs and falls back to the unpatched module rather than failing the render | +| `suspend="live"` pinning a Suspense boundary | Live responses only carry changed modules, so the gate uses `waitForLive()`/`settleLive()` — settled on failure too — never `hasAllLoaded`. Timeout backstop retained | +| Existing apps silently suspending for visitors | `suspend={true}` keeps meaning `"draft"`; public suspension requires typing `"live"` or `"always"` | + +--- + +## Verification + +Unit / type level (from repo root): + +```bash +pnpm test # incl. new tests below +pnpm run -r typecheck +pnpm run lint && pnpm -w run format +``` + +New tests to write: + +- `packages/server/src/LiveCache.test.ts` — fresh / stale-serve-and-refresh / expired-await / + stale-if-error / `ttl: 0` bypass / concurrent-refresh dedupe. Inject a clock; do not use real + timers. +- `packages/server/src/ValOps.test.ts` (or alongside `computeChangedPatchParentRefs.test.ts`) — + `analyzePatches` with `includeApplied: true` includes `appliedAt` patches and the default still + excludes them. +- `packages/next/src/client/useValStega.live.test.ts` — modelled on + `useValStega.suspense.test.ts`: with `live` on and no draft mode, `getModule` is consulted and + stega markers are absent. +- `packages/next/src/client/useValStega.suspendModes.test.ts` — the 2.5.1 truth table. For each of + `"never" | "draft" | "live" | "always"` × editor/visitor, assert suspends-or-not; plus + `suspend={true}` behaves identically to `"draft"` (the back-compat guarantee), `"live"` releases + on `settleLive()` **after a failed fetch**, and a module with no live patch does not suspend + forever. +- `packages/next/src/ValExternalStore.test.ts` — extend with `waitForLive` / `settleLive` / + `isLiveSettled`: same-promise-until-settled, resolves on settle, resolves on timeout. +- `packages/shared` — round-trip the new `/live/sources` route through `createValClient`. + +End-to-end against the example app: + +1. Point `examples/next` at a real project (`project`, `VAL_API_KEY`, `VAL_SECRET`, + `VAL_GIT_COMMIT`, `VAL_GIT_BRANCH`) with `live: { ttl: 0 }`. +2. `cd examples/next && pnpm run build && pnpm start` (build first, so the running deploy is + pinned to a commit). +3. In a second checkout, edit content in the Val studio and **Save** (commits without redeploying). +4. Reload the running app **logged out, in a fresh incognito window** — the committed change must + appear. Set `ttl: 60` and confirm it takes up to 60s, and that `staleWhileRevalidate` serves + instantly while refreshing. +5. Add a new blog route via a committed patch and hard-load its URL — it must render, not 404. +6. Add an image in a committed patch and confirm it loads (this is the cloud retention requirement + in practice). +7. Kill network access to `content.val.build` (block it in `/etc/hosts`) and confirm the app keeps + serving — stale first, then build-time content — with a warning in the log and no 500. +8. Confirm no `data-val-path` stega attributes in the anonymous HTML (`curl … | grep val-path`). +9. With ``, client-side navigate (not hard-load) to a + blog route that only exists in a committed patch — `loading.tsx` shows, then the page renders. + Repeat with `suspend={true}`: it must _not_ suspend for a logged-out visitor. + +CLI regression (required whenever `packages/server` changes, per repo rules): + +```bash +cd packages/cli && pnpm exec tsx src/cli.ts validate --root ../../examples/next +``` + +Full CI parity before declaring done: `pnpm run build` (then `pnpm preconstruct dev`) and +`cd examples/next && pnpm run build`. + +--- + +## Sequencing + +1. **This document.** Agree the shape before any code lands. +2. Hand Part 1 to the content.val.build repo. Nothing here ships until `/live/patches` exists. +3. Config surface + validation (2.1) — small, self-contained, unblocks everything else. +4. `LiveCache` + `fetchLivePatches` + `getLiveSources` (2.2) with unit tests, against a stubbed + endpoint. +5. `/live/sources` app endpoint (2.3). +6. RSC path (2.4) — the highest-value surface, shippable on its own. +7. Client hooks (2.5), then the `suspend` enum (2.5.1) — the enum needs the live fetch to exist + before `"live"` has anything to gate on, so it lands second even though the normalisation and + the `"always"`/`"never"`/`"draft"` values are independent of live mode. +8. Editor consistency (2.6), docs, example, changeset. + +## Out of scope + +Push/websocket invalidation (the cloud already has a websocket channel for the studio; wiring it to +public rendering is a follow-up that would let `ttl` be effectively infinite). Live mode for the +pages router. Per-module or per-route TTLs. Any change to how patches are created or committed. diff --git a/examples/next/val.config.ts b/examples/next/val.config.ts index cfde56f86..6d44d5b32 100644 --- a/examples/next/val.config.ts +++ b/examples/next/val.config.ts @@ -4,6 +4,13 @@ const { s, c, val, config, nextAppRouter, externalPageRouter } = initVal({ project: "valbuild/val-examples-next", root: "/examples/next", defaultTheme: "dark", + // Render content saved in Val before it has been deployed. Only takes effect + // in remote mode (VAL_API_KEY + VAL_SECRET + VAL_GIT_COMMIT); running the + // example locally logs a warning and ignores it. + live: { + ttl: 60, + staleWhileRevalidate: 300, + }, ai: { chat: { experimental: { diff --git a/packages/cli/src/utils/evalValConfigFile.ts b/packages/cli/src/utils/evalValConfigFile.ts index 78343d1f2..008c9e3d7 100644 --- a/packages/cli/src/utils/evalValConfigFile.ts +++ b/packages/cli/src/utils/evalValConfigFile.ts @@ -29,6 +29,12 @@ const ValConfigSchema = z.object({ gitCommit: z.string().optional(), gitBranch: z.string().optional(), defaultTheme: z.union([z.literal("light"), z.literal("dark")]).optional(), + live: z + .object({ + ttl: z.number().finite().nonnegative(), + staleWhileRevalidate: z.number().finite().nonnegative().optional(), + }) + .optional(), ai: z .object({ commitMessages: z diff --git a/packages/core/src/initVal.ts b/packages/core/src/initVal.ts index ff738279b..6d601c708 100644 --- a/packages/core/src/initVal.ts +++ b/packages/core/src/initVal.ts @@ -29,6 +29,26 @@ export type ValConfig = { gitCommit?: string; gitBranch?: string; defaultTheme?: "dark" | "light"; + /** + * Live mode: render patches that are committed, but not yet deployed. + * + * When set, the app asks Val for the patches that landed after the currently + * deployed commit and applies them before rendering - for everyone, without + * a login. This closes the gap between hitting Save in the studio and CI + * having rebuilt and redeployed the app. + * + * Requires proxy mode (VAL_API_KEY + VAL_SECRET + VAL_GIT_COMMIT). In fs + * (local dev) mode it is a no-op. + * + * Since the live patch set is fetched from Val, `ttl` is required: it is the + * number of seconds a fetched patch set is reused before refetching. + */ + live?: { + /** Seconds to cache the live patch set. 0 = always refetch. Required. */ + ttl: number; + /** Seconds past `ttl` a stale entry may be served while it is refreshed in the background. */ + staleWhileRevalidate?: number; + }; ai?: { commitMessages?: { disabled?: boolean; diff --git a/packages/init/src/templates.ts b/packages/init/src/templates.ts index 5a9175d38..891604715 100644 --- a/packages/init/src/templates.ts +++ b/packages/init/src/templates.ts @@ -67,14 +67,33 @@ type ValConfig = { gitCommit?: string; gitBranch?: string; defaultTheme?: "dark" | "light"; + live?: { + ttl: number; + staleWhileRevalidate?: number; + }; }; +// Live mode is off by default, since it requires a project and remote mode. +// Left here as a pointer: without it, saved content is only visible once CI has +// rebuilt and redeployed the app. +const LIVE_MODE_COMMENT = ` // Render content that has been saved in Val, but not yet deployed. + // Requires remote mode. 'ttl' is the seconds to cache for; 0 = always refetch. + // live: { ttl: 60 },`; + +function valConfigLiteral(options: ValConfig) { + const literal = JSON.stringify(options, null, 2); + // Drop the closing "\n}" and re-add the newline with a trailing comma, so the + // commented-out live block below is a line the user can just uncomment. + const entries = literal === "{}" ? "{\n" : `${literal.slice(0, -2)},\n`; + return `${entries}${LIVE_MODE_COMMENT}\n}`; +} + export const VAL_CONFIG = ( isTypeScript: boolean, options: ValConfig, ) => `import { initVal } from "@valbuild/next"; -const { s, c, val, config, nextAppRouter, externalPageRouter } = initVal(${JSON.stringify(options, null, 2)}); +const { s, c, val, config, nextAppRouter, externalPageRouter } = initVal(${valConfigLiteral(options)}); ${isTypeScript ? 'export type { t } from "@valbuild/next";' : ""}; export { s, c, val, config, nextAppRouter, externalPageRouter }; diff --git a/packages/next/README.md b/packages/next/README.md index d9d5bcb24..d1fff03a3 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -42,6 +42,8 @@ - [Installation](#installation) - [Getting started](#getting-started) +- [Remote mode](#remote-mode) +- [Live mode](#live-mode) - [Schema types](#schema-types): - [String](#string) - [Number](#number) @@ -232,6 +234,69 @@ export type { t } from "@valbuild/next"; export { s, c, val, config }; ``` +# Live mode + +By default your app renders exactly what was compiled into the deploy. When an editor hits **Save**, the change is committed — but nobody sees it until CI rebuilds and redeploys. On a large site that is minutes; if the deploy pipeline is broken, it is never. + +Live mode closes that gap. It makes your app render patches that are **committed but not yet deployed**, for everyone, with no login and no cookie: + +```ts +const { s, c, val, config } = initVal({ + project: "myteam/myproject", + gitBranch: process.env.VERCEL_GIT_COMMIT_REF, + gitCommit: process.env.VERCEL_GIT_COMMIT_SHA, + live: { + ttl: 60, // seconds to cache the live patch set. 0 = always refetch + staleWhileRevalidate: 300, // optional: seconds a stale set may be served while refreshing + }, +}); +``` + +## Requirements + +Live mode requires [remote mode](#remote-mode): `VAL_API_KEY`, `VAL_SECRET`, plus `gitCommit` and `gitBranch` (the deploy has to be able to say which commit it is running). In local development (fs mode) live mode logs a warning and does nothing. + +## The TTL contract + +Live mode has to ask Val "what changed since my commit?" — so `ttl` is **required**. There is no safe default: + +- `ttl: 0` — always refetch. Correct content immediately, one request to Val per render. Note that this opts every route that calls `fetchVal` out of static generation. +- `ttl: 60` — a committed change appears within 60 seconds. +- `staleWhileRevalidate: 300` — for the 300 seconds after the ttl expires, the cached set is served immediately while it is refreshed in the background, so no visitor waits for the refresh. + +Val is never in the critical path for correctness. If it is slow, unreachable, or returns something unexpected, the app serves the last good patch set, and failing that the deployed content. It never throws and never 500s a page. + +## Revalidation: prerendered pages need `export const revalidate` + +⚠️ **This is required for live mode to work on statically generated pages.** + +Next decides how often to re-render a prerendered page from what happened _during_ that page's render. Val caches the live patch set in-process for `ttl` seconds, so most renders answer from that cache without issuing a request — and a page that renders without any request is prerendered once and then never revalidated. It would keep serving the content that was current at build time, which is exactly what live mode exists to avoid. + +So declare the interval yourself, in your root layout (or per page/segment), matching your `ttl`: + +```ts +// app/layout.tsx +export const revalidate = 60; // matches live: { ttl: 60 } +``` + +You do not need this for pages that are already dynamic (they render per request anyway), nor with `live: { ttl: 0 }`, which makes every route dynamic — every render refetches, so nothing is prerendered. + +## Environment variables + +These override `val.config`, which is useful when the same config is deployed to several environments: + +- **`VAL_LIVE_TTL`** / **`VAL_LIVE_STALE_WHILE_REVALIDATE`**: override the values above. +- **`VAL_LIVE_DISABLED=true`**: kill switch. Turns live mode off entirely, e.g. for preview deploys. + +## What currently renders live + +- **Server Components** (`fetchVal`, `fetchValRoute`, `fetchValRouteUrl`): fully supported. The HTML is already correct on a hard load. +- **New routes from a committed patch**: a route that only exists in a committed patch resolves and renders. It is not in `generateStaticParams`, so Next renders it on demand via the default `dynamicParams: true`. +- **Images added in a committed patch**: served through `/api/val/files`, since they do not exist in the deployed bundle. +- **Client Components** (`useVal`): not yet — they render the build-time content. Support is coming. + +Live content is public content, so no editing markers are ever emitted for it: `data-val-path` attributes remain tied to draft mode. + # Formatting published content If you are using `prettier` or another code formatting tool, it is recommended to setup formatting of code after changes have been applied. diff --git a/packages/next/src/rsc/initValRsc.live.test.ts b/packages/next/src/rsc/initValRsc.live.test.ts new file mode 100644 index 000000000..16f3a5245 --- /dev/null +++ b/packages/next/src/rsc/initValRsc.live.test.ts @@ -0,0 +1,155 @@ +/** + * @jest-environment node + */ +import { initVal, modules } from "@valbuild/core"; + +// initValRsc imports next/headers for its types, but the import is emitted, so +// it has to resolve at runtime. Nothing in the live path calls these. +jest.mock( + "next/headers", + () => ({ + cookies: () => { + throw new Error("cookies() must not be called on the live path"); + }, + headers: () => { + throw new Error("headers() must not be called on the live path"); + }, + draftMode: async () => ({ isEnabled: false }), + }), + { virtual: true }, +); + +import { initValRsc } from "./initValRsc"; + +const { s, c, config: baseConfig } = initVal({ project: "org/project" }); + +function fetchValFor(live?: { ttl: number; staleWhileRevalidate?: number }) { + const config = { ...baseConfig, live }; + const valModule = c.define("/content/title.val.ts", s.string(), "Deployed"); + const valModules = modules(config, [ + { def: () => Promise.resolve({ default: valModule }) }, + ]); + const { fetchValStega } = initValRsc(config, valModules, { + draftMode: (async () => ({ isEnabled: false })) as never, + headers: (() => { + throw new Error("headers() must not be called on the live path"); + }) as never, + cookies: (() => { + throw new Error("cookies() must not be called on the live path"); + }) as never, + }); + return { fetchValStega, valModule }; +} + +function liveResponse(value: string) { + return { + ok: true, + status: 200, + statusText: "", + json: async () => ({ + headCommitSha: "commit2", + baseCommitSha: "commit1", + patches: [ + { + patchId: "patch1", + path: "/content/title.val.ts", + patch: [{ op: "replace", path: [], value }], + baseSha: "base1", + createdAt: "2024-01-01T00:00:00.000Z", + authorId: "author1", + appliedAt: { commitSha: "commit2" }, + }, + ], + }), + headers: { get: () => null }, + } as unknown as Response; +} + +/** Stega encodes paths as invisible unicode, so plain ascii means no markers. */ +function hasStegaMarkers(value: string) { + // eslint-disable-next-line no-control-regex + return /[^\x00-\x7F]/.test(value); +} + +describe("fetchValStega with live mode", () => { + const env = { ...process.env }; + let fetchMock: jest.SpyInstance; + let error: jest.SpyInstance; + + beforeEach(() => { + process.env.VAL_API_KEY = "test-api-key"; + process.env.VAL_SECRET = "test-secret"; + process.env.VAL_GIT_COMMIT = "commit1"; + process.env.VAL_GIT_BRANCH = "main"; + delete process.env.VAL_LIVE_TTL; + delete process.env.VAL_LIVE_DISABLED; + fetchMock = jest.spyOn(global, "fetch"); + error = jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = { ...env }; + fetchMock.mockRestore(); + error.mockRestore(); + }); + + test("renders committed-but-undeployed content for an anonymous visitor", async () => { + fetchMock.mockResolvedValue(liveResponse("Committed")); + const { fetchValStega, valModule } = fetchValFor({ ttl: 60 }); + + expect(await fetchValStega(valModule)).toBe("Committed"); + }); + + test("does not leak stega markers into public html", async () => { + fetchMock.mockResolvedValue(liveResponse("Committed")); + const { fetchValStega, valModule } = fetchValFor({ ttl: 60 }); + + // `disabled` is bound to draft mode, not to live mode: live content is + // public, so it must carry no data-val-path markers. + expect(hasStegaMarkers(await fetchValStega(valModule))).toBe(false); + }); + + test("renders the deployed content when live mode is off", async () => { + fetchMock.mockResolvedValue(liveResponse("Committed")); + const { fetchValStega, valModule } = fetchValFor(); + + expect(await fetchValStega(valModule)).toBe("Deployed"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("VAL_LIVE_DISABLED falls back to the deployed content", async () => { + process.env.VAL_LIVE_DISABLED = "true"; + fetchMock.mockResolvedValue(liveResponse("Committed")); + const { fetchValStega, valModule } = fetchValFor({ ttl: 60 }); + + expect(await fetchValStega(valModule)).toBe("Deployed"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("falls back to the deployed content when Val is unreachable", async () => { + fetchMock.mockRejectedValue(new Error("ECONNREFUSED")); + const { fetchValStega, valModule } = fetchValFor({ ttl: 60 }); + + const res = await fetchValStega(valModule); + expect(res).toBe("Deployed"); + // A failure must not re-encode with stega enabled either. + expect(hasStegaMarkers(res)).toBe(false); + }); + + test("a module with no live patch still renders the deployed content", async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + statusText: "", + json: async () => ({ + headCommitSha: "commit2", + baseCommitSha: "commit1", + patches: [], + }), + headers: { get: () => null }, + } as unknown as Response); + const { fetchValStega, valModule } = fetchValFor({ ttl: 60 }); + + expect(await fetchValStega(valModule)).toBe("Deployed"); + }); +}); diff --git a/packages/next/src/rsc/initValRsc.ts b/packages/next/src/rsc/initValRsc.ts index 8472ed957..d6b5f5b56 100644 --- a/packages/next/src/rsc/initValRsc.ts +++ b/packages/next/src/rsc/initValRsc.ts @@ -17,7 +17,11 @@ import { } from "@valbuild/core"; import { cookies, draftMode, headers } from "next/headers"; import { VAL_SESSION_COOKIE } from "@valbuild/shared/internal"; -import { createValServer, ValServer } from "@valbuild/server"; +import { + createValServer, + isLiveModeConfigured, + ValServer, +} from "@valbuild/server"; import { VERSION } from "../version"; import { getValRouteUrlFromVal, initValRouteFromVal } from "../routeFromVal"; @@ -117,6 +121,25 @@ const initFetchValStega = } } } + } else if (isLiveModeConfigured(config)) { + // Live mode: render patches that are committed to Val but are not yet + // part of this deploy, for everyone, with no login. + // + // NOTE: this path must not add a dynamic API. cookies() and headers() + // opt the route out of static generation for every visitor, so + // getHost/getCookies above stay behind `enabled` and live mode reads + // neither: it needs nothing per-request, only the config and the + // in-process server. + const liveSources = await getLiveSources(valServerPromise); + if (liveSources) { + return stegaEncode(selector, { + // `disabled` stays bound to draft mode, never to live mode: live + // content is public, so no data-val-path markers may leak into the + // HTML anonymous visitors get. + disabled: !enabled, + getModule: (path) => liveSources[path as ModuleFilePath], + }); + } } return stegaEncode(selector, { disabled: !enabled, @@ -128,6 +151,30 @@ const initFetchValStega = }); }; +/** + * The live sources for this request, or null if there are none to apply. + * + * Never throws: live mode is an enhancement on top of the deployed content, so + * every failure has to degrade to rendering what was deployed. In particular it + * must not reach the caller's catch, which re-encodes with stega enabled. + */ +async function getLiveSources( + valServerPromise: Promise, +): Promise | null> { + try { + const valServer = await valServerPromise; + const res = await valServer["/live/sources"]["GET"]({}); + if (res.status !== 200) { + console.error("Val: could not get live sources: ", res.json.message); + return null; + } + return res.json.sources; + } catch (err) { + console.error("Val: could not get live sources: ", err); + return null; + } +} + function getHost(headers: { get(name: string): string | null } | undefined) { // TODO: does NextJs have a way to determine this? const host = headers?.get("host"); @@ -311,6 +358,14 @@ export function initValRsc( }, }, ); + // valServerPromise is created here at module-eval time, but only awaited from + // inside a fetchVal call. Without this no-op catch, a config error (a bad + // live ttl, proxy mode without a project) rejects with no handler attached, + // which becomes an unhandledRejection and kills the server before any request + // is served. Every awaiting call site reports the error itself. + valServerPromise.catch(() => { + // handled at the call sites + }); return { fetchValStega: initFetchValStega( config, diff --git a/packages/server/src/LiveCache.test.ts b/packages/server/src/LiveCache.test.ts new file mode 100644 index 000000000..d5d65415d --- /dev/null +++ b/packages/server/src/LiveCache.test.ts @@ -0,0 +1,289 @@ +import { LiveCache } from "./LiveCache"; + +/** A clock we control, so the tests never need real timers. */ +function fakeClock(start = 1_000_000) { + let current = start; + return { + now: () => current, + advanceSeconds: (seconds: number) => { + current += seconds * 1000; + }, + }; +} + +/** Resolve the microtask queue, so background refreshes get to run. */ +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +describe("LiveCache", () => { + const KEY = "project|main|commit1|base1|0.1.0"; + + test("fetches on a miss and caches the result", async () => { + const clock = fakeClock(); + const cache = new LiveCache({ + ttl: 60, + staleWhileRevalidate: 0, + now: clock.now, + }); + const fetcher = jest.fn(async () => "v1"); + + expect(await cache.get(KEY, fetcher)).toBe("v1"); + expect(await cache.get(KEY, fetcher)).toBe("v1"); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + test("serves the cached value while it is fresh", async () => { + const clock = fakeClock(); + const cache = new LiveCache({ + ttl: 60, + staleWhileRevalidate: 0, + now: clock.now, + }); + const fetcher = jest.fn(async () => "v1"); + + await cache.get(KEY, fetcher); + clock.advanceSeconds(59); + expect(await cache.get(KEY, fetcher)).toBe("v1"); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + test("refetches once the ttl has passed", async () => { + const clock = fakeClock(); + const cache = new LiveCache({ + ttl: 60, + staleWhileRevalidate: 0, + now: clock.now, + }); + let value = "v1"; + const fetcher = jest.fn(async () => value); + + await cache.get(KEY, fetcher); + clock.advanceSeconds(61); + value = "v2"; + expect(await cache.get(KEY, fetcher)).toBe("v2"); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + test("stale-while-revalidate serves the stale value and refreshes in the background", async () => { + const clock = fakeClock(); + const cache = new LiveCache({ + ttl: 60, + staleWhileRevalidate: 300, + now: clock.now, + }); + let value = "v1"; + const fetcher = jest.fn(async () => value); + + await cache.get(KEY, fetcher); + clock.advanceSeconds(61); + value = "v2"; + + // Inside the swr window: the stale value comes back immediately... + expect(await cache.get(KEY, fetcher)).toBe("v1"); + expect(fetcher).toHaveBeenCalledTimes(2); + + // ...and the background refresh has replaced it by the next call. + await flush(); + expect(await cache.get(KEY, fetcher)).toBe("v2"); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + test("awaits the refresh once past the swr window", async () => { + const clock = fakeClock(); + const cache = new LiveCache({ + ttl: 60, + staleWhileRevalidate: 300, + now: clock.now, + }); + let value = "v1"; + const fetcher = jest.fn(async () => value); + + await cache.get(KEY, fetcher); + clock.advanceSeconds(361); + value = "v2"; + expect(await cache.get(KEY, fetcher)).toBe("v2"); + }); + + test("stale-if-error: a failed refresh falls back to the stale value", async () => { + const clock = fakeClock(); + const cache = new LiveCache({ + ttl: 60, + staleWhileRevalidate: 0, + now: clock.now, + }); + let value: string | null = "v1"; + const fetcher = jest.fn(async () => value); + + await cache.get(KEY, fetcher); + clock.advanceSeconds(61); + value = null; // the fetch failed + + expect(await cache.get(KEY, fetcher)).toBe("v1"); + // ...and keeps falling back, rather than caching the failure + clock.advanceSeconds(10_000); + expect(await cache.get(KEY, fetcher)).toBe("v1"); + }); + + test("returns null when the first fetch fails and there is nothing stale", async () => { + const cache = new LiveCache({ ttl: 60, staleWhileRevalidate: 0 }); + expect(await cache.get(KEY, async () => null)).toBeNull(); + }); + + test("a thrown fetcher is treated as a failure, not a crash", async () => { + const error = jest.spyOn(console, "error").mockImplementation(() => {}); + try { + const clock = fakeClock(); + const cache = new LiveCache({ + ttl: 60, + staleWhileRevalidate: 0, + now: clock.now, + }); + await cache.get(KEY, async () => "v1"); + clock.advanceSeconds(61); + + expect( + await cache.get(KEY, async () => { + throw new Error("boom"); + }), + ).toBe("v1"); + expect(error).toHaveBeenCalled(); + } finally { + error.mockRestore(); + } + }); + + test("ttl 0 always refetches", async () => { + const cache = new LiveCache({ ttl: 0, staleWhileRevalidate: 0 }); + let value = "v1"; + const fetcher = jest.fn(async () => value); + + expect(await cache.get(KEY, fetcher)).toBe("v1"); + value = "v2"; + expect(await cache.get(KEY, fetcher)).toBe("v2"); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + test("ttl 0 ignores staleWhileRevalidate rather than serving stale", async () => { + const cache = new LiveCache({ ttl: 0, staleWhileRevalidate: 300 }); + let value = "v1"; + const fetcher = jest.fn(async () => value); + + await cache.get(KEY, fetcher); + value = "v2"; + expect(await cache.get(KEY, fetcher)).toBe("v2"); + }); + + test("ttl 0 still falls back to a stale value on failure", async () => { + const cache = new LiveCache({ ttl: 0, staleWhileRevalidate: 0 }); + expect(await cache.get(KEY, async () => "v1")).toBe("v1"); + expect(await cache.get(KEY, async () => null)).toBe("v1"); + }); + + test("concurrent expired reads share a single fetch", async () => { + const cache = new LiveCache({ ttl: 0, staleWhileRevalidate: 0 }); + let resolveFetch: (value: string) => void = () => {}; + const fetcher = jest.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const all = Promise.all([ + cache.get(KEY, fetcher), + cache.get(KEY, fetcher), + cache.get(KEY, fetcher), + ]); + resolveFetch("v1"); + + expect(await all).toEqual(["v1", "v1", "v1"]); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + test("concurrent stale reads only kick off one background refresh", async () => { + const clock = fakeClock(); + const cache = new LiveCache({ + ttl: 60, + staleWhileRevalidate: 300, + now: clock.now, + }); + const fetcher = jest.fn(async () => "v1"); + + await cache.get(KEY, fetcher); + clock.advanceSeconds(61); + await Promise.all([ + cache.get(KEY, fetcher), + cache.get(KEY, fetcher), + cache.get(KEY, fetcher), + ]); + await flush(); + + expect(fetcher).toHaveBeenCalledTimes(2); // the initial one + one refresh + }); + + test("a different key never reuses the previous entry, including on failure", async () => { + const clock = fakeClock(); + const cache = new LiveCache({ + ttl: 60, + staleWhileRevalidate: 300, + now: clock.now, + }); + // Same commit, different baseSha: a redeploy whose evaluated sources differ. + const otherKey = "project|main|commit1|base2|0.1.0"; + + expect(await cache.get(KEY, async () => "v1")).toBe("v1"); + expect(await cache.get(otherKey, async () => "v2")).toBe("v2"); + // Serving another deploy's patches would be worse than serving none. + expect( + await cache.get("project|main|commit2|base3|0.1.0", async () => null), + ).toBeNull(); + }); + + test("a slow refresh for an old key never overwrites a newer entry", async () => { + const cache = new LiveCache({ ttl: 60, staleWhileRevalidate: 0 }); + let resolveSlow: (value: string) => void = () => {}; + const slow = () => + new Promise((resolve) => { + resolveSlow = resolve; + }); + + // keyA is in flight when keyB starts, and keyB lands first. + const a = cache.get("keyA", slow); + const b = await cache.get("keyB", async () => "fromB"); + expect(b).toBe("fromB"); + resolveSlow("fromA"); + // The caller that asked for keyA still gets keyA's value... + expect(await a).toBe("fromA"); + + // ...but keyB's entry survived, and a failing keyB refresh falls back to it + // rather than to keyA's patch set. + expect(await cache.get("keyB", async () => null)).toBe("fromB"); + }); + + test("clear() also discards a refresh that is already in flight", async () => { + const cache = new LiveCache({ ttl: 60, staleWhileRevalidate: 0 }); + let resolveFetch: (value: string) => void = () => {}; + const pending = cache.get( + KEY, + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + cache.clear(); + resolveFetch("v1"); + expect(await pending).toBe("v1"); + + // The cleared entry must not have been resurrected by the in-flight write. + expect(await cache.get(KEY, async () => null)).toBeNull(); + }); + + test("clear() drops the entry", async () => { + const cache = new LiveCache({ ttl: 60, staleWhileRevalidate: 0 }); + const fetcher = jest.fn(async () => "v1"); + + await cache.get(KEY, fetcher); + cache.clear(); + await cache.get(KEY, fetcher); + expect(fetcher).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/server/src/LiveCache.ts b/packages/server/src/LiveCache.ts new file mode 100644 index 000000000..2e250d49a --- /dev/null +++ b/packages/server/src/LiveCache.ts @@ -0,0 +1,150 @@ +/** + * A single-entry TTL cache with stale-while-revalidate and stale-if-error, used + * to hold the live patch set fetched from Val. + * + * Live mode renders on every request, so without this every request would hit + * Val. The states are: + * + * - fresh (`age < ttl`): return the entry + * - stale (`ttl <= age < ttl + staleWhileRevalidate`): return the entry + * immediately and refresh in the background + * - expired: await a refresh; if that fails, fall back to the stale entry + * (stale-if-error) and only return null when there is nothing at all + * + * `ttl === 0` means always refetch - concurrent callers within the same fetch + * still share one request rather than stampeding. + * + * The key deliberately includes `baseSha` and not just the commit sha: the same + * commit can be deployed several times with different evaluated sources (a + * dependency bump changes baseSha without changing the commit), so a commit sha + * alone does not identify a deploy and must never be treated as if it does. A + * key change drops the previous entry, including for the stale-if-error path - + * serving another deploy's patches is worse than serving no patches. + */ +export type LiveCacheOptions = { + /** Seconds an entry is fresh. 0 = always refetch. */ + ttl: number; + /** Seconds past `ttl` a stale entry may be served while it is refreshed. */ + staleWhileRevalidate: number; + /** Milliseconds since the epoch. Injectable so tests do not need real timers. */ + now?: () => number; +}; + +type Entry = { + key: string; + value: T; + /** Milliseconds since the epoch at which `value` was fetched. */ + fetchedAt: number; +}; + +export class LiveCache { + private readonly ttlMs: number; + private readonly staleWhileRevalidateMs: number; + private readonly now: () => number; + private entry: Entry | null = null; + /** In-flight refresh, so concurrent callers share one request. */ + private inFlight: { key: string; promise: Promise } | null = null; + /** + * Bumped whenever what we are caching changes: a `clear()`, or a refresh for + * a different key. A refresh that started before the bump still returns its + * value to the caller that asked for it, but must not write it to `entry` - + * it would either resurrect a cleared entry or overwrite a newer deploy's + * patches with an older one's. + */ + private epoch = 0; + + constructor(options: LiveCacheOptions) { + this.ttlMs = options.ttl * 1000; + this.staleWhileRevalidateMs = options.staleWhileRevalidate * 1000; + this.now = options.now ?? Date.now; + } + + /** + * Get the cached value for `key`, fetching if needed. + * + * `fetcher` must never throw for an expected failure (a network error, a bad + * response): return null instead, and this falls back to a stale entry. A + * thrown error is treated the same way, but is logged as unexpected. + * + * `alwaysFetch` hands the ttl to something else - see + * `ValOpsHttp.fetchLivePatches`, where the framework's own fetch cache owns + * it. The dedupe and the stale-if-error fallback still apply, so this is + * "do not answer from the fresh window", not "do not cache". + */ + async get( + key: string, + fetcher: () => Promise, + opts?: { alwaysFetch?: boolean }, + ): Promise { + const entry = this.entry?.key === key ? this.entry : null; + const age = entry === null ? Infinity : this.now() - entry.fetchedAt; + + // ttl 0 means always refetch, so it skips the serve-without-awaiting + // branches entirely - including staleWhileRevalidate, which would otherwise + // reintroduce exactly the staleness ttl 0 asks us not to have. The cached + // entry is still kept for stale-if-error below. + if (this.ttlMs > 0 && !opts?.alwaysFetch) { + if (entry !== null && age < this.ttlMs) { + return entry.value; + } + if (entry !== null && age < this.ttlMs + this.staleWhileRevalidateMs) { + // Stale: serve now, refresh behind the request. The refresh must not be + // awaited and must not reject - a rejected floating promise would be an + // unhandledRejection and take the process down in Node. + this.refresh(key, fetcher).catch(() => { + // handled in refresh() + }); + return entry.value; + } + } + // Expired or absent: we have to wait for the fetch. + const value = await this.refresh(key, fetcher); + if (value !== null) { + return value; + } + // stale-if-error: an old patch set renders better content than none, and + // "none" here means falling all the way back to the deployed content. + return entry?.value ?? null; + } + + private refresh(key: string, fetcher: () => Promise) { + if (this.inFlight && this.inFlight.key === key) { + return this.inFlight.promise; + } + if (this.inFlight) { + // A refresh for another key is in flight: it is now stale by definition. + this.epoch++; + } + const epoch = this.epoch; + const promise = (async () => { + try { + return await fetcher(); + } catch (err) { + // Expected failures are supposed to come back as null, so getting here + // means something unforeseen: log it, but never fail the render. + console.error( + "Val: unexpected error while fetching live patches", + err instanceof Error ? err.message : err, + ); + return null; + } + })().then((value) => { + if (this.inFlight?.promise === promise) { + this.inFlight = null; + } + if (value !== null && epoch === this.epoch) { + this.entry = { key, value, fetchedAt: this.now() }; + } + return value; + }); + this.inFlight = { key, promise }; + return promise; + } + + /** Drop the cached entry. Exposed for tests and for a future invalidation hook. */ + clear() { + this.entry = null; + this.inFlight = null; + this.epoch++; + } +} diff --git a/packages/server/src/ValOps.ts b/packages/server/src/ValOps.ts index 2cf3407f6..3e54b2d26 100644 --- a/packages/server/src/ValOps.ts +++ b/packages/server/src/ValOps.ts @@ -71,6 +71,17 @@ const tsOps = new TSOps((document) => { ); }); +/** + * The `live` block of ValConfig after env overrides have been applied and the + * defaults filled in. `undefined` means live mode is off. + */ +export type ResolvedLiveConfig = { + /** Seconds a fetched live patch set is fresh. 0 = always refetch. */ + ttl: number; + /** Seconds past `ttl` a stale entry may be served while it is refreshed in the background. */ + staleWhileRevalidate: number; +}; + export type ValOpsOptions = { formatter?: (code: string, filePath: string) => string | Promise; statPollingInterval?: number; @@ -243,6 +254,16 @@ export abstract class ValOps { sortedPatches: OrderedPatches["patches"], commits?: ValCommit[], currentCommitSha?: CommitSha, + opts?: { + /** + * Include patches that have already been committed. + * + * Only live mode wants these: it renders patches that are committed but + * not yet deployed. The draft path must keep skipping them, since there + * they are already part of the sources it started from. + */ + includeApplied?: boolean; + }, ): PatchAnalysis { const patchesByModule: { [path: ModuleFilePath]: { @@ -258,7 +279,7 @@ export abstract class ValOps { } > = {}; for (const patch of sortedPatches) { - if (patch.appliedAt) { + if (patch.appliedAt && !opts?.includeApplied) { continue; } let hasSourceFileOps = false; @@ -426,6 +447,25 @@ export abstract class ValOps { return { sources: patchedSources, errors }; } + // #region getLiveSources + /** + * The sources of the modules that changed in patches which are committed to + * Val but are not yet part of the running deploy - "live mode". + * + * Only the changed modules are returned, which is the whole point: it is what + * goes over the wire to the browser, and it keeps this path cheap. There is + * no validation and no rendering here; it runs on public page loads. + * + * Live mode requires talking to Val, so this is a no-op outside http mode - + * `ValOpsFS` has no committed-but-undeployed patches to speak of. + */ + async getLiveSources(): Promise<{ + sources: Sources; + headCommitSha: string | null; + }> { + return { sources: {}, headCommitSha: null }; + } + /** * Every module's source, with the pending patches applied. * diff --git a/packages/server/src/ValOpsHttp.ts b/packages/server/src/ValOpsHttp.ts index 8cd26740b..c98bdeb05 100644 --- a/packages/server/src/ValOpsHttp.ts +++ b/packages/server/src/ValOpsHttp.ts @@ -26,7 +26,10 @@ import { OrderedPatchesMetadata, OrderedPatches, SourcesSha, + ResolvedLiveConfig, + Sources, } from "./ValOps"; +import { LiveCache } from "./LiveCache"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { @@ -105,6 +108,29 @@ const GetApplicablePatches = z.object({ ) .optional(), }); +/** + * The live patch set: patches that are committed to Val but landed after the + * commit this deploy was built from. + * + * Narrower than GetApplicablePatches on purpose - this response is served to + * anonymous end users, so every patch here is already committed and therefore + * public. There is no patch_id filter and no chunking. + */ +const LivePatchesResponse = z.object({ + headCommitSha: z.string().nullable(), + baseCommitSha: z.string().nullable(), + patches: z.array( + z.object({ + patchId: z.string(), + path: z.string(), + patch: Patch, + baseSha: z.string(), + createdAt: z.string(), + authorId: z.string().nullable(), + appliedAt: z.object({ commitSha: z.string() }), + }), + ), +}); const FilesResponse = z.object({ files: z.array( z.union([ @@ -171,11 +197,78 @@ const NonceResponse = z.object({ url: z.string(), }); +/** The live patch set, as returned by GET /v1/{project}/live/patches. */ +export type LivePatches = { + patches: OrderedPatches["patches"]; + /** The newest commit on this branch known to Val. */ + headCommitSha: string | null; +}; + +/** + * Whether the runtime's `fetch` honours `next: { revalidate }` - i.e. whether + * Next has patched `globalThis.fetch`. + * + * This decides who owns the live mode ttl, and it is not really a caching + * question. Next derives how often to re-render a page from the fetches + * performed while rendering it: no fetch, no revalidation. So if we answer from + * our own in-process cache, a prerendered page is built with `revalidate: false` + * and keeps serving its build-time snapshot forever - which is exactly the gap + * live mode exists to close. + * + * When Next's fetch is there we therefore call it on every render and let it own + * the ttl (a hit is served from its Data Cache without touching the network, + * and still registers the interval). Our LiveCache stays on as the dedupe and + * the stale-if-error fallback. Outside Next nothing registers anything, so the + * in-process ttl is all there is and we keep using it. + * + * Feature-detected rather than sniffed from an env var, so it tracks the + * capability we actually depend on. If the marker ever disappears we fall back + * to the in-process ttl, and apps can pin the interval themselves with + * `export const revalidate` - which is documented either way. + */ +/** + * How long to wait for Val before giving up and rendering the deployed content. + * + * Live mode is on the render path of public pages, so an unreachable Val must + * degrade rather than stall: without a deadline a hung connection holds the + * render open until the platform kills the whole request. Generous enough not to + * fire on a slow-but-working response, since giving up means a visitor sees the + * deployed content for this render. + */ +const LIVE_FETCH_TIMEOUT_MS = 10_000; + +/** + * How often to repeat a live mode problem that keeps happening. + * + * These are logged per call, and there is one call per fetchVal per render - so + * an unreachable Val would otherwise print one line per prerendered page during + * a build and one per request at runtime, burying everything else. + */ +const LIVE_ISSUE_LOG_INTERVAL_MS = 60_000; + +function hasFrameworkFetchCache(): boolean { + const fetchFn: unknown = globalThis.fetch; + return ( + typeof fetchFn === "function" && + "__nextPatched" in fetchFn && + Reflect.get(fetchFn, "__nextPatched") === true + ); +} + export class ValOpsHttp extends ValOps { private readonly authHeaders: | { Authorization: string } | { "x-val-pat": string }; private readonly root: string; + private readonly live?: ResolvedLiveConfig; + private readonly liveCache: LiveCache | null; + /** The sources derived from the current live patch set - see getLiveSources. */ + private liveSourcesMemo: { + signature: string; + result: { sources: Sources; headCommitSha: string | null }; + } | null = null; + /** The last live mode problem logged - see logLiveIssue. */ + private lastLiveIssue: { message: string; at: number } | null = null; constructor( private readonly contentUrl: string, private readonly project: string, @@ -195,6 +288,11 @@ export class ValOpsHttp extends ValOps { * the root would be /apps/my-app */ root?: string; + /** + * Live mode settings, already resolved by `resolveLiveConfig`. + * Undefined means live mode is off. + */ + live?: ResolvedLiveConfig; }, ) { super(valModules, options); @@ -203,6 +301,8 @@ export class ValOpsHttp extends ValOps { ? { "x-val-pat": auth.pat } : { Authorization: `Bearer ${auth.apiKey}` }; this.root = options?.root ?? ""; + this.live = options?.live; + this.liveCache = this.live ? new LiveCache(this.live) : null; } async onInit(): Promise { // TODO: unused for now. Implement or remove @@ -788,6 +888,198 @@ export class ValOpsHttp extends ValOps { } } + // #region live patches + /** + * Report a live mode problem, at most once per + * LIVE_ISSUE_LOG_INTERVAL_MS while the same one persists. + * + * Live mode never fails a render, so these messages are all the operator has + * to go on - which is exactly why they must not be drowned out by their own + * repetition. A change of message always reports immediately. + */ + private logLiveIssue(message: string, level: "error" | "warn" = "error") { + const at = Date.now(); + const previous = this.lastLiveIssue; + if ( + previous?.message === message && + at - previous.at < LIVE_ISSUE_LOG_INTERVAL_MS + ) { + return; + } + this.lastLiveIssue = { message, at }; + if (level === "warn") { + console.warn(message); + } else { + console.error(message); + } + } + + /** + * The patches that are committed to Val, but landed after the commit this + * deploy was built from. Cached according to the live mode ttl. + * + * Returns null whenever live mode cannot produce an answer - it is off, or + * the request failed and there is nothing stale to fall back to. Callers then + * render the deployed content, which is always a safe answer. + */ + async fetchLivePatches(): Promise { + if (!this.liveCache || !this.live) { + return null; + } + const baseSha = await this.getBaseSha(); + // baseSha, not just the commit sha: the same commit can be deployed more + // than once with different evaluated sources, so the commit alone does not + // identify a deploy. + const key = [ + this.project, + this.branch, + this.commitSha, + baseSha, + Internal.VERSION.core, + ].join("|"); + return this.liveCache.get( + key, + () => this.fetchLivePatchesUncached(baseSha), + { alwaysFetch: this.live.ttl > 0 && hasFrameworkFetchCache() }, + ); + } + + private async fetchLivePatchesUncached( + baseSha: BaseSha, + ): Promise { + const searchParams = new URLSearchParams([ + ["branch", this.branch], + ["commit", this.commitSha], + ["base_sha", baseSha], + ["core_version", Internal.VERSION.core ?? ""], + ]); + // Inside Next this is both the cache and the page's revalidation interval - + // see hasFrameworkFetchCache. Outside Next it is ignored and our own + // LiveCache owns the ttl instead. + const cacheOptions: RequestInit & { next?: { revalidate: number } } = + this.live && this.live.ttl > 0 + ? { next: { revalidate: this.live.ttl } } + : { cache: "no-store" }; + try { + const res = await fetch( + `${this.contentUrl}/v1/${this.project}/live/patches?${searchParams.toString()}`, + { + headers: this.authHeaders, + // A deadline, so a hung Val cannot hold the render open. NOTE: a + // signal opts the request out of Next's per-render fetch memoisation + // but not out of its Data Cache, which is the part live mode needs. + signal: AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), + ...cacheOptions, + }, + ); + if (!res.ok) { + this.logLiveIssue( + "Val: could not get live patches. HTTP error: " + + res.status + + " " + + res.statusText, + ); + return null; + } + const parsed = LivePatchesResponse.safeParse(await res.json()); + if (!parsed.success) { + this.logLiveIssue( + "Val: could not parse the live patches response. Error: " + + fromError(parsed.error), + ); + return null; + } + if ( + parsed.data.baseCommitSha !== null && + parsed.data.baseCommitSha !== this.commitSha + ) { + // The response is for a different deploy than the one asking, so its + // patches may not apply to our sources at all. + this.logLiveIssue( + `Val: ignoring live patches for a different commit. Expected: ${this.commitSha}, got: ${parsed.data.baseCommitSha}`, + ); + return null; + } + const degraded = res.headers.get("x-val-live-degraded"); + if (degraded) { + // Expected on a rollback or a force-push: Val cannot tell what landed + // after a commit it does not recognise, so it returns nothing. + this.logLiveIssue( + `Val: live mode is degraded (${degraded}), rendering the deployed content.`, + "warn", + ); + } + return { + headCommitSha: parsed.data.headCommitSha, + patches: parsed.data.patches.map((patch) => ({ + patchId: patch.patchId as PatchId, + path: patch.path as ModuleFilePath, + patch: patch.patch, + baseSha: patch.baseSha as BaseSha, + createdAt: patch.createdAt, + authorId: patch.authorId as AuthorId | null, + appliedAt: { commitSha: patch.appliedAt.commitSha as CommitSha }, + })), + }; + } catch (err) { + this.logLiveIssue( + "Val: could not get live patches (connection error): " + + (err instanceof Error ? err.message : JSON.stringify(err)), + ); + return null; + } + } + + override async getLiveSources(): Promise<{ + sources: Sources; + headCommitSha: string | null; + }> { + const live = await this.fetchLivePatches(); + if (!live) { + return { sources: {}, headCommitSha: null }; + } + if (live.patches.length === 0) { + return { sources: {}, headCommitSha: live.headCommitSha }; + } + // Every fetchVal in a render calls this, but the answer only changes when + // the patch set does - and applying the patches is not free (getSources + // deep-clones each module and replays every op). So memoise the derived + // sources on the patch set they came from, or a page with ten fetchVal + // calls redoes the same work ten times on every public request. + const signature = [ + // stringified, so a null head commit cannot collide with an empty one + JSON.stringify(live.headCommitSha), + ...live.patches.map((patch) => patch.patchId), + ].join("|"); + if (this.liveSourcesMemo?.signature === signature) { + return this.liveSourcesMemo.result; + } + // NOTE: includeApplied is what makes analyzePatches keep the committed + // patches in `patchesByModule`. getSources reads `patches` rather than + // `patchesByModule`, so it does not depend on this today - but passing it + // keeps the analysis a truthful description of what is being applied. + const analysis = this.analyzePatches(live.patches, undefined, undefined, { + includeApplied: true, + }); + // getSources returns only the modules that had patches, which is what we + // want here: it is what goes over the wire. Patches that fail to apply are + // recorded per module and skipped, so a stale patch degrades to the + // deployed content for that module instead of failing the render. + const { sources, errors } = await this.getSources({ + ...analysis, + ...live, + }); + for (const [path, moduleErrors] of Object.entries(errors)) { + console.error( + `Val: could not apply live patches to ${path}, rendering the deployed content for it instead:`, + moduleErrors.map((e) => e.error.message).join(", "), + ); + } + const result = { sources, headCommitSha: live.headCommitSha }; + this.liveSourcesMemo = { signature, result }; + return result; + } + protected async saveSourceFilePatch( path: ModuleFilePath, patch: PatchT, diff --git a/packages/server/src/ValRouter.ts b/packages/server/src/ValRouter.ts index 6c9fcd1dc..239f7dc28 100644 --- a/packages/server/src/ValRouter.ts +++ b/packages/server/src/ValRouter.ts @@ -8,6 +8,7 @@ import { } from "@valbuild/shared/internal"; import { createUIRequestHandler } from "@valbuild/ui/server"; import { ValServer, ValServerCallbacks, ValServerConfig } from "./ValServer"; +import { ResolvedLiveConfig } from "./ValOps"; import { fromError } from "zod-validation-error"; import { z, ZodError } from "zod"; @@ -126,6 +127,122 @@ type ValServerOverrides = Partial<{ disableCache?: boolean; }>; +const LIVE_ENV_VARS = { + ttl: "VAL_LIVE_TTL", + staleWhileRevalidate: "VAL_LIVE_STALE_WHILE_REVALIDATE", + disabled: "VAL_LIVE_DISABLED", +} as const; + +function invalidLiveSeconds(source: string, value: unknown): Error { + return new Error( + `Invalid Val live mode config: ${source} must be a finite, non-negative number of seconds, but was: ${JSON.stringify( + value, + )}`, + ); +} + +/** val.config is not always type checked (it may be plain JS), so validate strictly. */ +function liveSecondsFromConfig( + value: number | undefined, + source: string, +): number | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + throw invalidLiveSeconds(source, value); + } + return value; +} + +/** + * Env vars are always strings, so these are coerced - but just as strictly. + * + * An empty (or whitespace-only) value counts as unset: several hosts + * materialise a declared-but-empty variable as "", and refusing to boot over + * one would be a poor trade for a feature the app may not even use. + */ +function liveSecondsFromEnv(envVar: string): number | undefined { + const value = process.env[envVar]; + if (value === undefined || value.trim() === "") { + return undefined; + } + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + throw invalidLiveSeconds(`the ${envVar} env var`, value); + } + return parsed; +} + +/** + * Whether live mode is asked for at all. + * + * The cheap check: it answers "should we bother asking the server for live + * sources?" without needing to know the mode. `resolveLiveConfig` is the one + * that decides whether live mode actually ends up on - it can still say no, + * in which case /live/sources just returns an empty set. + */ +export function isLiveModeConfigured(config: ValConfig | undefined): boolean { + if (process.env[LIVE_ENV_VARS.disabled] === "true") { + return false; + } + // Same as liveSecondsFromEnv: an empty value is not an opt-in. + return !!config?.live || (process.env[LIVE_ENV_VARS.ttl] ?? "").trim() !== ""; +} + +let hasWarnedAboutLiveModeInFsMode = false; + +/** + * Resolve the `live` config, applying the env var overrides. + * + * Live mode requires talking to Val on every request unless we cache, so `ttl` + * has no safe default and is required whenever `live` is present. This is also + * validated at runtime (and not only by the `ValConfig` type) because + * val.config may be a plain JS file where the type is not enforced. + */ +export function resolveLiveConfig( + // `initVal()` takes an optional config and hands it straight back, so this is + // undefined at runtime whenever the app calls `initVal()` with no arguments - + // which the ValConfig type does not tell you. + config: ValConfig | undefined, + isProxyMode: boolean, +): ResolvedLiveConfig | undefined { + if (!isLiveModeConfigured(config)) { + return undefined; + } + const envTtl = liveSecondsFromEnv(LIVE_ENV_VARS.ttl); + const envSwr = liveSecondsFromEnv(LIVE_ENV_VARS.staleWhileRevalidate); + const ttl = + envTtl ?? + liveSecondsFromConfig(config?.live?.ttl, "'live.ttl' in val.config"); + if (ttl === undefined) { + // Only reachable when 'live' is present as an object without a ttl, which + // the ValConfig type forbids but a plain JS config file does not. + throw new Error( + "Invalid Val live mode config: 'live.ttl' is required when 'live' is set in val.config. Use 'live: { ttl: 0 }' to always refetch.", + ); + } + const staleWhileRevalidate = + envSwr ?? + liveSecondsFromConfig( + config?.live?.staleWhileRevalidate, + "'live.staleWhileRevalidate' in val.config", + ) ?? + 0; + if (!isProxyMode) { + // Once per process: an app creates a Val server per entrypoint (the RSC one + // and the API route one), and a build creates more still. + if (!hasWarnedAboutLiveModeInFsMode) { + hasWarnedAboutLiveModeInFsMode = true; + console.warn( + "Val: live mode is configured, but Val is running in local (fs) mode so it has no effect. Live mode renders patches that were committed to Val, which requires proxy mode (VAL_API_KEY and VAL_SECRET).", + ); + } + return undefined; + } + return { ttl, staleWhileRevalidate }; +} + export async function createValServer( valModules: ValModules, route: string, @@ -165,6 +282,7 @@ async function initHandlerOptions( opts.valBuildUrl || process.env.VAL_BUILD_URL || "https://admin.val.build"; const valContentUrl = opts.valContentUrl || process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST; + const live = resolveLiveConfig(config, !!isProxyMode); if (isProxyMode) { if (!maybeApiKey || !maybeValSecret) { throw new Error( @@ -207,6 +325,7 @@ async function initHandlerOptions( valContentUrl, valBuildUrl, config, + live, }; } else { const cwd = process.cwd(); diff --git a/packages/server/src/ValServer.ts b/packages/server/src/ValServer.ts index 27221e2b9..fe4981bb8 100644 --- a/packages/server/src/ValServer.ts +++ b/packages/server/src/ValServer.ts @@ -35,6 +35,7 @@ import { CommitSha, formatPatchSourceError, OrderedPatches, + ResolvedLiveConfig, SchemaSha, SourcesSha, } from "./ValOps"; @@ -62,6 +63,12 @@ export type ValServerOptions = { apiKey?: string; project?: string; config: ValConfig; + /** + * Live mode settings, resolved from `config.live` and the VAL_LIVE_* env vars + * by `resolveLiveConfig`. Undefined means live mode is off, which is also the + * case whenever the mode is "fs". + */ + live?: ResolvedLiveConfig; }; export type ValServerConfig = ValServerOptions & @@ -131,6 +138,7 @@ export const ValServer = ( formatter: options.formatter, root: options.root, config: options.config, + live: options.live, }, ); } else { @@ -2581,6 +2589,37 @@ export const ValServer = ( }, }, + //#region live + "/live/sources": { + GET: async () => { + // NOTE: no auth here, for the same reason as /files below, only more + // clear-cut: everything this returns is already committed to the + // repository and therefore public. It is uncommitted drafts that need + // protecting, and those never reach this route - the live patch set + // only ever contains committed patches. + if (!options.live) { + // Live mode is off, or the mode is fs. An empty set rather than an + // error, so the caller does not have to know which. + return { + status: 200, + headers: { "Cache-Control": "no-store" }, + json: { headCommitSha: null, sources: {} }, + }; + } + const { sources, headCommitSha } = await serverOps.getLiveSources(); + return { + status: 200, + headers: { + "Cache-Control": + options.live.ttl === 0 + ? "no-store" + : `public, max-age=${options.live.ttl}, stale-while-revalidate=${options.live.staleWhileRevalidate}`, + }, + json: { headCommitSha, sources }, + }; + }, + }, + //#region files "/files": { GET: async (req) => { @@ -2606,8 +2645,12 @@ export const ValServer = ( remote, ); mimeType = Internal.filenameToMimeType(filePath); - // TODO: reenable this: - // cacheControl = "public, max-age=20000, immutable"; + // A patch is immutable: a given (filePath, patch_id) pair always + // resolves to the same bytes, since editing a file creates a new + // patch with a new id. Live mode makes this route a lot hotter - + // every image added by a committed-but-undeployed patch is served + // from here rather than from the deploy. + cacheControl = "public, max-age=20000, immutable"; } else { if (serverOps instanceof ValOpsHttp && remote) { console.error( diff --git a/packages/server/src/analyzePatches.test.ts b/packages/server/src/analyzePatches.test.ts new file mode 100644 index 000000000..e44851fe6 --- /dev/null +++ b/packages/server/src/analyzePatches.test.ts @@ -0,0 +1,111 @@ +import { ModuleFilePath, PatchId, initVal, modules } from "@valbuild/core"; +import { ValOpsHttp } from "./ValOpsHttp"; +import { AuthorId, BaseSha, CommitSha, OrderedPatches } from "./ValOps"; + +const { s, c, config } = initVal(); + +function testOps() { + return new ValOpsHttp( + "https://content.example.com", + "org/project", + "commit1", + "main", + { apiKey: "test-api-key" }, + modules(config, [ + { + def: () => + Promise.resolve({ + default: c.define( + "/content/authors.val.ts", + s.object({ name: s.string() }), + { name: "Deployed" }, + ), + }), + }, + ]), + { config }, + ); +} + +function patch( + patchId: string, + appliedAt: { commitSha: CommitSha } | null, +): OrderedPatches["patches"][number] { + return { + patchId: patchId as PatchId, + path: "/content/authors.val.ts" as ModuleFilePath, + patch: [{ op: "replace", path: ["name"], value: patchId }], + baseSha: "base1" as BaseSha, + createdAt: "2024-01-01T00:00:00.000Z", + authorId: "author1" as AuthorId, + appliedAt, + }; +} + +describe("analyzePatches", () => { + const uncommitted = patch("uncommitted", null); + const committed = patch("committed", { commitSha: "commit2" as CommitSha }); + + test("skips committed patches by default", () => { + const analysis = testOps().analyzePatches([committed, uncommitted]); + expect( + analysis.patchesByModule["/content/authors.val.ts" as ModuleFilePath], + ).toEqual([{ patchId: "uncommitted" }]); + }); + + test("includes committed patches with includeApplied", () => { + const analysis = testOps().analyzePatches( + [committed, uncommitted], + undefined, + undefined, + { includeApplied: true }, + ); + // Order is preserved: the patches are applied in sequence. + expect( + analysis.patchesByModule["/content/authors.val.ts" as ModuleFilePath], + ).toEqual([{ patchId: "committed" }, { patchId: "uncommitted" }]); + }); + + test("includeApplied: false is the same as the default", () => { + const analysis = testOps().analyzePatches( + [committed, uncommitted], + undefined, + undefined, + { includeApplied: false }, + ); + expect( + analysis.patchesByModule["/content/authors.val.ts" as ModuleFilePath], + ).toEqual([{ patchId: "uncommitted" }]); + }); + + // NOTE: this is not what makes live-mode images resolve - getSources builds + // its own patch_id ops from patch.patch. It matters for the callers that do + // read fileLastUpdatedByPatchId: prepare() and /sources/~. + test("committed file ops are tracked too", () => { + const withFileOp: OrderedPatches["patches"][number] = { + ...patch("filePatch", { commitSha: "commit2" as CommitSha }), + patch: [ + { + op: "file", + path: ["image"], + filePath: "/public/val/image.jpg", + value: "data:image/jpeg;base64,...", + remote: false, + }, + ], + }; + const analysis = testOps().analyzePatches( + [withFileOp], + undefined, + undefined, + { + includeApplied: true, + }, + ); + expect(analysis.fileLastUpdatedByPatchId["/public/val/image.jpg"]).toEqual({ + patchId: "filePatch", + remote: false, + isDelete: false, + }); + }); +}); diff --git a/packages/server/src/getLiveSources.test.ts b/packages/server/src/getLiveSources.test.ts new file mode 100644 index 000000000..92d43577f --- /dev/null +++ b/packages/server/src/getLiveSources.test.ts @@ -0,0 +1,440 @@ +import { ModuleFilePath, initVal, modules } from "@valbuild/core"; +import { ValOpsHttp } from "./ValOpsHttp"; +import { ResolvedLiveConfig } from "./ValOps"; + +const { s, c, config } = initVal(); +const AUTHORS = "/content/authors.val.ts" as ModuleFilePath; +const PAGES = "/content/pages.val.ts" as ModuleFilePath; + +function testOps(live?: ResolvedLiveConfig) { + return new ValOpsHttp( + "https://content.example.com", + "org/project", + "commit1", + "main", + { apiKey: "test-api-key" }, + modules(config, [ + { + def: () => + Promise.resolve({ + default: c.define(AUTHORS, s.object({ name: s.string() }), { + name: "Deployed", + }), + }), + }, + { + def: () => + Promise.resolve({ + default: c.define(PAGES, s.object({ title: s.string() }), { + title: "Deployed page", + }), + }), + }, + ]), + { config, live }, + ); +} + +/** One committed-but-undeployed patch on /content/authors.val.ts */ +function livePatch(value: string, patchId = "patch1") { + return { + patchId, + path: AUTHORS, + patch: [{ op: "replace", path: ["name"], value }], + baseSha: "base1", + createdAt: "2024-01-01T00:00:00.000Z", + authorId: "author1", + appliedAt: { commitSha: "commit2" }, + }; +} + +function jsonResponse( + json: unknown, + init?: { ok?: boolean; status?: number; headers?: Record }, +) { + const headers = init?.headers ?? {}; + return { + ok: init?.ok ?? true, + status: init?.status ?? 200, + statusText: "", + json: async () => json, + headers: { get: (name: string) => headers[name] ?? null }, + } as unknown as Response; +} + +function liveResponse( + patches: ReturnType[], + overrides?: { headCommitSha?: string | null; baseCommitSha?: string | null }, +) { + return jsonResponse({ + headCommitSha: overrides?.headCommitSha ?? "commit2", + baseCommitSha: + overrides && "baseCommitSha" in overrides + ? overrides.baseCommitSha + : "commit1", + patches, + }); +} + +/** + * Mark the mocked fetch the way Next marks the fetch it has patched. That is how + * ValOpsHttp knows the framework owns the live mode ttl - and that answering + * from the in-process cache instead would cost the page its revalidation + * interval. jest's mockRestore puts the original fetch back, marker and all. + */ +function asNextPatchedFetch() { + Reflect.set(globalThis.fetch, "__nextPatched", true); +} + +describe("getLiveSources", () => { + let fetchMock: jest.SpyInstance; + let error: jest.SpyInstance; + let warn: jest.SpyInstance; + + beforeEach(() => { + fetchMock = jest.spyOn(global, "fetch"); + error = jest.spyOn(console, "error").mockImplementation(() => {}); + warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + fetchMock.mockRestore(); + error.mockRestore(); + warn.mockRestore(); + }); + + test("applies committed patches and returns only the changed modules", async () => { + fetchMock.mockResolvedValue(liveResponse([livePatch("Committed")])); + + const res = await testOps({ + ttl: 60, + staleWhileRevalidate: 0, + }).getLiveSources(); + + expect(res.sources).toEqual({ [AUTHORS]: { name: "Committed" } }); + // Unchanged modules stay out of the response - it goes over the wire. + expect(res.sources[PAGES]).toBeUndefined(); + expect(res.headCommitSha).toBe("commit2"); + }); + + test("applies patches in order", async () => { + fetchMock.mockResolvedValue( + liveResponse([livePatch("First", "p1"), livePatch("Second", "p2")]), + ); + + const res = await testOps({ + ttl: 60, + staleWhileRevalidate: 0, + }).getLiveSources(); + + expect(res.sources).toEqual({ [AUTHORS]: { name: "Second" } }); + }); + + test("requests the branch, commit, base_sha and core_version", async () => { + fetchMock.mockResolvedValue(liveResponse([])); + const ops = testOps({ ttl: 60, staleWhileRevalidate: 0 }); + + await ops.getLiveSources(); + + const url = new URL(fetchMock.mock.calls[0][0]); + expect(url.pathname).toBe("/v1/org/project/live/patches"); + expect(url.searchParams.get("branch")).toBe("main"); + expect(url.searchParams.get("commit")).toBe("commit1"); + expect(url.searchParams.get("base_sha")).toBe(await ops.getBaseSha()); + expect(url.searchParams.get("core_version")).toBeTruthy(); + expect(fetchMock.mock.calls[0][1].headers).toEqual({ + Authorization: "Bearer test-api-key", + }); + }); + + test("is a no-op when live mode is off", async () => { + fetchMock.mockResolvedValue(liveResponse([livePatch("Committed")])); + + const res = await testOps().getLiveSources(); + + expect(res).toEqual({ sources: {}, headCommitSha: null }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("caches within the ttl", async () => { + fetchMock.mockResolvedValue(liveResponse([livePatch("Committed")])); + const ops = testOps({ ttl: 60, staleWhileRevalidate: 0 }); + + await ops.getLiveSources(); + await ops.getLiveSources(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("derives the sources once per patch set, not once per call", async () => { + fetchMock.mockResolvedValue(liveResponse([livePatch("Committed")])); + const ops = testOps({ ttl: 60, staleWhileRevalidate: 0 }); + const getSources = jest.spyOn(ops, "getSources"); + + // Every fetchVal in a render calls this, so applying the patches once per + // call would multiply the work by the number of fetchVal calls on the page. + const first = await ops.getLiveSources(); + const second = await ops.getLiveSources(); + + expect(second).toEqual(first); + expect(getSources).toHaveBeenCalledTimes(1); + }); + + test("re-derives the sources when the patch set changes", async () => { + fetchMock.mockResolvedValueOnce(liveResponse([livePatch("First", "p1")])); + fetchMock.mockResolvedValue(liveResponse([livePatch("Second", "p2")])); + const ops = testOps({ ttl: 0, staleWhileRevalidate: 0 }); + + expect((await ops.getLiveSources()).sources).toEqual({ + [AUTHORS]: { name: "First" }, + }); + expect((await ops.getLiveSources()).sources).toEqual({ + [AUTHORS]: { name: "Second" }, + }); + }); + + test("ttl 0 refetches every time", async () => { + fetchMock.mockResolvedValue(liveResponse([livePatch("Committed")])); + const ops = testOps({ ttl: 0, staleWhileRevalidate: 0 }); + + await ops.getLiveSources(); + await ops.getLiveSources(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test("hands the ttl to the framework fetch cache when there is one", async () => { + // Next only learns how often to re-render a page from the fetches performed + // while rendering it. Answering from our own cache means a prerendered page + // is built with no revalidation and never picks up live content, so when + // Next's fetch is there we go through it every time and let it do the + // caching - it serves from its Data Cache without touching the network. + fetchMock.mockResolvedValue(liveResponse([livePatch("Committed")])); + asNextPatchedFetch(); + const ops = testOps({ ttl: 60, staleWhileRevalidate: 300 }); + + await ops.getLiveSources(); + await ops.getLiveSources(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1][1]).toMatchObject({ + next: { revalidate: 60 }, + }); + }); + + test("still falls back to a stale patch set when the framework owns the ttl", async () => { + fetchMock.mockResolvedValueOnce(liveResponse([livePatch("Committed")])); + fetchMock.mockRejectedValue(new Error("ECONNREFUSED")); + asNextPatchedFetch(); + const ops = testOps({ ttl: 60, staleWhileRevalidate: 300 }); + + expect((await ops.getLiveSources()).sources).toEqual({ + [AUTHORS]: { name: "Committed" }, + }); + // Handing over the ttl must not hand over stale-if-error too. + expect((await ops.getLiveSources()).sources).toEqual({ + [AUTHORS]: { name: "Committed" }, + }); + }); + + test("gives Val a deadline, so a hung response cannot stall the render", async () => { + fetchMock.mockResolvedValue(liveResponse([])); + + await testOps({ ttl: 60, staleWhileRevalidate: 0 }).getLiveSources(); + + const { signal } = fetchMock.mock.calls[0][1]; + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal.aborted).toBe(false); + }); + + test("repeats a persistent failure at most once per interval", async () => { + // One call per fetchVal per render: without this, an unreachable Val prints + // a line per prerendered page during a build and one per request at runtime. + fetchMock.mockRejectedValue(new Error("ECONNREFUSED")); + asNextPatchedFetch(); + const ops = testOps({ ttl: 60, staleWhileRevalidate: 0 }); + + await ops.getLiveSources(); + await ops.getLiveSources(); + await ops.getLiveSources(); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(error).toHaveBeenCalledTimes(1); + }); + + test("reports a different failure immediately", async () => { + fetchMock.mockRejectedValueOnce(new Error("ECONNREFUSED")); + fetchMock.mockResolvedValue(jsonResponse({}, { ok: false, status: 503 })); + asNextPatchedFetch(); + const ops = testOps({ ttl: 60, staleWhileRevalidate: 0 }); + + await ops.getLiveSources(); + await ops.getLiveSources(); + + expect(error).toHaveBeenCalledTimes(2); + }); + + test("sends no-store when ttl is 0 and a revalidate hint otherwise", async () => { + fetchMock.mockResolvedValue(liveResponse([])); + + await testOps({ ttl: 0, staleWhileRevalidate: 0 }).getLiveSources(); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ cache: "no-store" }); + + await testOps({ ttl: 60, staleWhileRevalidate: 0 }).getLiveSources(); + expect(fetchMock.mock.calls[1][1]).toMatchObject({ + next: { revalidate: 60 }, + }); + }); + + test("falls back to the deployed content on an http error", async () => { + fetchMock.mockResolvedValue(jsonResponse({}, { ok: false, status: 500 })); + + const res = await testOps({ + ttl: 60, + staleWhileRevalidate: 0, + }).getLiveSources(); + + expect(res).toEqual({ sources: {}, headCommitSha: null }); + expect(error).toHaveBeenCalled(); + }); + + test("falls back to the deployed content on a network error", async () => { + fetchMock.mockRejectedValue(new Error("ECONNREFUSED")); + + const res = await testOps({ + ttl: 60, + staleWhileRevalidate: 0, + }).getLiveSources(); + + expect(res).toEqual({ sources: {}, headCommitSha: null }); + expect(error).toHaveBeenCalled(); + }); + + test("falls back to the deployed content on an unparseable response", async () => { + fetchMock.mockResolvedValue(jsonResponse({ unexpected: true })); + + const res = await testOps({ + ttl: 60, + staleWhileRevalidate: 0, + }).getLiveSources(); + + expect(res).toEqual({ sources: {}, headCommitSha: null }); + expect(error).toHaveBeenCalled(); + }); + + test("ignores a response for a different commit", async () => { + fetchMock.mockResolvedValue( + liveResponse([livePatch("Committed")], { + baseCommitSha: "someOtherCommit", + }), + ); + + const res = await testOps({ + ttl: 60, + staleWhileRevalidate: 0, + }).getLiveSources(); + + expect(res).toEqual({ sources: {}, headCommitSha: null }); + expect(error).toHaveBeenCalled(); + }); + + test("serves stale content when a refresh fails", async () => { + fetchMock.mockResolvedValueOnce(liveResponse([livePatch("Committed")])); + fetchMock.mockRejectedValue(new Error("ECONNREFUSED")); + const ops = testOps({ ttl: 0, staleWhileRevalidate: 0 }); + + expect((await ops.getLiveSources()).sources).toEqual({ + [AUTHORS]: { name: "Committed" }, + }); + expect((await ops.getLiveSources()).sources).toEqual({ + [AUTHORS]: { name: "Committed" }, + }); + }); + + test("an empty patch set still reports the head commit", async () => { + fetchMock.mockResolvedValue(liveResponse([])); + + const res = await testOps({ + ttl: 60, + staleWhileRevalidate: 0, + }).getLiveSources(); + + expect(res).toEqual({ sources: {}, headCommitSha: "commit2" }); + }); + + test("warns but does not fail when Val reports a degraded response", async () => { + fetchMock.mockResolvedValue( + jsonResponse( + { headCommitSha: "commit9", baseCommitSha: "commit1", patches: [] }, + { headers: { "x-val-live-degraded": "unknown-base" } }, + ), + ); + + const res = await testOps({ + ttl: 60, + staleWhileRevalidate: 0, + }).getLiveSources(); + + expect(res).toEqual({ sources: {}, headCommitSha: "commit9" }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("unknown-base")); + }); + + test("a patch that does not apply degrades to the deployed content", async () => { + fetchMock.mockResolvedValue( + liveResponse([ + { + ...livePatch("Committed"), + // "name" is a string in the deployed sources, so this path does not exist + patch: [{ op: "replace", path: ["name", "nested"], value: "x" }], + }, + ]), + ); + + const res = await testOps({ + ttl: 60, + staleWhileRevalidate: 0, + }).getLiveSources(); + + expect(res.sources[AUTHORS]).toEqual({ name: "Deployed" }); + expect(error).toHaveBeenCalled(); + }); + + test("a patch for a module that no longer exists does not fail the render", async () => { + fetchMock.mockResolvedValue( + liveResponse([ + { + ...livePatch("Committed"), + path: "/content/deleted.val.ts" as ModuleFilePath, + }, + ]), + ); + + const res = await testOps({ + ttl: 60, + staleWhileRevalidate: 0, + }).getLiveSources(); + + expect(res.sources).toEqual({}); + expect(error).toHaveBeenCalled(); + }); + + test("only committed patches are accepted", async () => { + fetchMock.mockResolvedValue( + liveResponse([ + // appliedAt is required on this route: an uncommitted patch must never + // reach anonymous end users. + { ...livePatch("Draft"), appliedAt: null } as unknown as ReturnType< + typeof livePatch + >, + ]), + ); + + const res = await testOps({ + ttl: 60, + staleWhileRevalidate: 0, + }).getLiveSources(); + + expect(res).toEqual({ sources: {}, headCommitSha: null }); + expect(error).toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4fd74456a..3ac852717 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,5 +1,10 @@ export { createService, Service } from "./Service"; -export { createValApiRouter, createValServer, safeReadGit } from "./ValRouter"; +export { + createValApiRouter, + createValServer, + isLiveModeConfigured, + safeReadGit, +} from "./ValRouter"; export { ValModuleLoader } from "./ValModuleLoader"; export { getCompilerOptions } from "./getCompilerOptions"; export { ValSourceFileHandler } from "./ValSourceFileHandler"; diff --git a/packages/server/src/liveSourcesRoute.test.ts b/packages/server/src/liveSourcesRoute.test.ts new file mode 100644 index 000000000..e15366c97 --- /dev/null +++ b/packages/server/src/liveSourcesRoute.test.ts @@ -0,0 +1,148 @@ +import { initVal, modules } from "@valbuild/core"; +import { createValApiRouter, createValServer } from "./ValRouter"; + +const ROUTE = "/api/val"; + +function onLiveSourcesRoute(opts: { + live?: { ttl: number; staleWhileRevalidate?: number }; + proxy?: boolean; +}) { + const { c, s, config: baseConfig } = initVal(); + const config = { ...baseConfig, live: opts.live }; + const valModules = modules(config, [ + { + def: () => + Promise.resolve({ + default: c.define( + "/content/authors.val.ts", + s.object({ name: s.string() }), + { name: "Deployed" }, + ), + }), + }, + ]); + return createValApiRouter( + ROUTE, + createValServer( + valModules, + ROUTE, + opts.proxy + ? { + mode: "proxy", + apiKey: "test-api-key", + valSecret: "test-secret", + project: "org/project", + gitCommit: "commit1", + gitBranch: "main", + versions: { core: "1.0.0", next: "1.0.0" }, + ...config, + } + : { disableCache: true, ...config }, + config, + { + async isEnabled() { + return false; + }, + async onDisable() {}, + async onEnable() {}, + }, + ), + (res) => res, + ); +} + +/** No cookies and no auth header: exactly what an anonymous end user sends. */ +function anonymousRequest(): Request { + return { + method: "GET", + url: new URL(`http://localhost:3000${ROUTE}/live/sources`), + headers: new Headers(), + json: async () => ({}), + } as unknown as Request; +} + +describe("/live/sources", () => { + let fetchMock: jest.SpyInstance; + let warn: jest.SpyInstance; + + beforeEach(() => { + warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + fetchMock = jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + status: 200, + statusText: "", + json: async () => ({ + headCommitSha: "commit2", + baseCommitSha: "commit1", + patches: [ + { + patchId: "patch1", + path: "/content/authors.val.ts", + patch: [{ op: "replace", path: ["name"], value: "Committed" }], + baseSha: "base1", + createdAt: "2024-01-01T00:00:00.000Z", + authorId: "author1", + appliedAt: { commitSha: "commit2" }, + }, + ], + }), + headers: { get: () => null }, + } as unknown as Response); + }); + + afterEach(() => { + fetchMock.mockRestore(); + warn.mockRestore(); + }); + + test("serves live sources to an anonymous request", async () => { + const res = await onLiveSourcesRoute({ + live: { ttl: 60, staleWhileRevalidate: 300 }, + proxy: true, + })(anonymousRequest()); + + expect(res.status).toBe(200); + expect("json" in res && res.json).toEqual({ + headCommitSha: "commit2", + sources: { "/content/authors.val.ts": { name: "Committed" } }, + }); + expect("headers" in res && res.headers).toEqual({ + "Cache-Control": "public, max-age=60, stale-while-revalidate=300", + }); + }); + + test("ttl 0 is served as no-store", async () => { + const res = await onLiveSourcesRoute({ + live: { ttl: 0 }, + proxy: true, + })(anonymousRequest()); + + expect(res.status).toBe(200); + expect("headers" in res && res.headers).toEqual({ + "Cache-Control": "no-store", + }); + }); + + test("returns an empty set when live mode is off, not an error", async () => { + const res = await onLiveSourcesRoute({ proxy: true })(anonymousRequest()); + + expect(res.status).toBe(200); + expect("json" in res && res.json).toEqual({ + headCommitSha: null, + sources: {}, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("returns an empty set in fs mode, where live mode does not apply", async () => { + const res = await onLiveSourcesRoute({ live: { ttl: 60 } })( + anonymousRequest(), + ); + + expect(res.status).toBe(200); + expect("json" in res && res.json).toEqual({ + headCommitSha: null, + sources: {}, + }); + }); +}); diff --git a/packages/server/src/resolveLiveConfig.test.ts b/packages/server/src/resolveLiveConfig.test.ts new file mode 100644 index 000000000..fc947d9d4 --- /dev/null +++ b/packages/server/src/resolveLiveConfig.test.ts @@ -0,0 +1,143 @@ +import { ValConfig } from "@valbuild/core"; +import { resolveLiveConfig } from "./ValRouter"; + +describe("resolveLiveConfig", () => { + const envKeys = [ + "VAL_LIVE_TTL", + "VAL_LIVE_STALE_WHILE_REVALIDATE", + "VAL_LIVE_DISABLED", + ] as const; + const savedEnv: Record = {}; + let warn: jest.SpyInstance; + + beforeEach(() => { + for (const key of envKeys) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + for (const key of envKeys) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + warn.mockRestore(); + }); + + test("live mode is off when 'live' is not configured", () => { + expect(resolveLiveConfig({}, true)).toBeUndefined(); + }); + + test("tolerates an undefined config", () => { + // initVal() hands back whatever it was given, so apps that call it without + // arguments end up with an undefined config at runtime. + expect(resolveLiveConfig(undefined, true)).toBeUndefined(); + }); + + test("ttl is carried through and staleWhileRevalidate defaults to 0", () => { + expect(resolveLiveConfig({ live: { ttl: 60 } }, true)).toEqual({ + ttl: 60, + staleWhileRevalidate: 0, + }); + }); + + test("ttl: 0 is allowed and means always refetch", () => { + expect(resolveLiveConfig({ live: { ttl: 0 } }, true)).toEqual({ + ttl: 0, + staleWhileRevalidate: 0, + }); + }); + + test("staleWhileRevalidate is carried through", () => { + expect( + resolveLiveConfig({ live: { ttl: 60, staleWhileRevalidate: 300 } }, true), + ).toEqual({ ttl: 60, staleWhileRevalidate: 300 }); + }); + + test("env vars override val.config", () => { + process.env.VAL_LIVE_TTL = "10"; + process.env.VAL_LIVE_STALE_WHILE_REVALIDATE = "20"; + expect( + resolveLiveConfig({ live: { ttl: 60, staleWhileRevalidate: 300 } }, true), + ).toEqual({ ttl: 10, staleWhileRevalidate: 20 }); + }); + + test("VAL_LIVE_TTL enables live mode without a val.config block", () => { + process.env.VAL_LIVE_TTL = "30"; + expect(resolveLiveConfig({}, true)).toEqual({ + ttl: 30, + staleWhileRevalidate: 0, + }); + }); + + test("VAL_LIVE_DISABLED=true is a kill switch", () => { + process.env.VAL_LIVE_TTL = "30"; + process.env.VAL_LIVE_DISABLED = "true"; + expect(resolveLiveConfig({ live: { ttl: 60 } }, true)).toBeUndefined(); + }); + + test("live mode is a no-op in fs mode, warning once per process", () => { + expect(resolveLiveConfig({ live: { ttl: 60 } }, false)).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + // An app creates a Val server per entrypoint (RSC + API route), so warning + // per call would print the same paragraph several times on every start. + expect(resolveLiveConfig({ live: { ttl: 60 } }, false)).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + }); + + test("throws when 'live' is set without a ttl", () => { + // The ValConfig type requires ttl, but val.config may be plain JS + const config = { live: {} } as unknown as ValConfig; + expect(() => resolveLiveConfig(config, true)).toThrow( + /live\.ttl.*required/, + ); + }); + + test.each([ + ["a negative ttl", { live: { ttl: -1 } }], + ["a non-finite ttl", { live: { ttl: Infinity } }], + ["a non-numeric ttl", { live: { ttl: "60" } }], + ["a negative staleWhileRevalidate", { live: { ttl: 1, swr: -1 } }], + ])("throws on %s", (_name, live) => { + const config = { + live: { + ttl: (live.live as { ttl: unknown }).ttl, + staleWhileRevalidate: (live.live as { swr?: unknown }).swr, + }, + } as unknown as ValConfig; + expect(() => resolveLiveConfig(config, true)).toThrow( + /Invalid Val live mode config/, + ); + }); + + test("throws on an invalid VAL_LIVE_TTL env var", () => { + process.env.VAL_LIVE_TTL = "not-a-number"; + expect(() => resolveLiveConfig({}, true)).toThrow(/VAL_LIVE_TTL/); + }); + + test.each(["", " "])( + "an empty VAL_LIVE_TTL (%p) counts as unset, not as an error", + (value) => { + // Hosts materialise a declared-but-empty env var as "". Refusing to boot + // over one would be a poor trade for an opt-in feature. + process.env.VAL_LIVE_TTL = value; + expect(resolveLiveConfig({}, true)).toBeUndefined(); + expect(resolveLiveConfig({ live: { ttl: 60 } }, true)).toEqual({ + ttl: 60, + staleWhileRevalidate: 0, + }); + }, + ); + + test("an empty VAL_LIVE_STALE_WHILE_REVALIDATE counts as unset", () => { + process.env.VAL_LIVE_STALE_WHILE_REVALIDATE = ""; + expect( + resolveLiveConfig({ live: { ttl: 60, staleWhileRevalidate: 300 } }, true), + ).toEqual({ ttl: 60, staleWhileRevalidate: 300 }); + }); +}); diff --git a/packages/shared/src/internal/ApiRoutes.ts b/packages/shared/src/internal/ApiRoutes.ts index 7d75f5591..eed996884 100644 --- a/packages/shared/src/internal/ApiRoutes.ts +++ b/packages/shared/src/internal/ApiRoutes.ts @@ -32,6 +32,12 @@ const ValConfig = z.object({ .optional(), gitCommit: z.string().optional(), gitBranch: z.string().optional(), + live: z + .object({ + ttl: z.number(), + staleWhileRevalidate: z.number().optional(), + }) + .optional(), }); const ValidationFixZ: z.ZodSchema = z.union([ @@ -904,6 +910,37 @@ export const Api = { ]), }, }, + /** + * The sources of modules changed by patches that are committed to Val, but + * are not yet part of the running deploy - "live mode". + * + * Deliberately unauthenticated: everything it returns is already committed + * and therefore public. It is served to anonymous end users by design, which + * is also why it carries no draft content of any kind. + * + * Only the changed modules are returned. When live mode is off (or the mode + * is fs) it returns an empty set rather than an error, so callers never have + * to branch on whether live mode is enabled. + */ + "/live/sources": { + GET: { + req: {}, // no cookies - anonymous by design + res: z.union([ + z.object({ + status: z.literal(400), + json: GenericError, + }), + z.object({ + status: z.literal(200), + headers: z.record(z.string(), z.string()).optional(), + json: z.object({ + headCommitSha: z.string().nullable(), + sources: z.record(ModuleFilePath, z.any()), + }), + }), + ]), + }, + }, "/commit-summary": { GET: { req: { diff --git a/packages/shared/src/internal/SharedValConfig.ts b/packages/shared/src/internal/SharedValConfig.ts index 41d0f09b4..f97fbbb84 100644 --- a/packages/shared/src/internal/SharedValConfig.ts +++ b/packages/shared/src/internal/SharedValConfig.ts @@ -15,6 +15,12 @@ export const SharedValConfig: z.ZodSchema< gitCommit: z.string().optional(), gitBranch: z.string().optional(), defaultTheme: z.enum(["dark", "light"]).optional(), + live: z + .object({ + ttl: z.number(), + staleWhileRevalidate: z.number().optional(), + }) + .optional(), ai: z .object({ commitMessages: z