Skip to content
31 changes: 31 additions & 0 deletions .changeset/live-mode-rsc.md
Original file line number Diff line number Diff line change
@@ -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 = <your ttl>` 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.
534 changes: 534 additions & 0 deletions LIVE_MODE_PLAN.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions examples/next/val.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/utils/evalValConfigFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/initVal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 20 additions & 1 deletion packages/init/src/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
65 changes: 65 additions & 0 deletions packages/next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
155 changes: 155 additions & 0 deletions packages/next/src/rsc/initValRsc.live.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading