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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/cold-page-request.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,13 @@ Parsed manifests and bodies live under a parser-version + content-SHA namespace.
Runtime Cache persists across deployments within an environment, so unrelated deployments can reuse
immutable content artifacts. `CONTENT_PARSER_VERSION` must be bumped when parser/plugin configuration,
relevant parser dependencies, or cached derived data changes.

**Client-side navigation** fetches `_payload.json` alongside the page, and Vercel's ISR default
(`max-age=0, must-revalidate`) means the browser revalidates it on every navigation rather than
trusting a stale copy for minutes — the webhook purge above takes effect immediately there too.
For prod pages, `server/middleware/payload-etag.ts` sets a weak `etag` from `resolveProdSha()`,
so revalidation is a cheap conditional request answered `304` from the edge's ISR entry, not a
full re-download. Preview payloads (`/tree/**`, `/blob/**`, `/pr/**`) skip this — they render from
their own pinned instance rather than the prod head, and they're low-traffic and `noindex`, so a
full re-download on every nav there is an acceptable trade against resolving a second SHA per
request.
6 changes: 0 additions & 6 deletions modules/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,12 +255,6 @@ export default defineNuxtModule<ComarkDocsOptions>({
'/api/content/blob/*/manifest.json': { isr: true }, // Immutable since SHA-pinned
'/api/content/blob/*/snapshot/*': { isr: true }, // Immutable since SHA-pinned
'/api/code-explorer/**': { isr },
'/_payload.json': {
headers: { 'cache-control': `public, max-age=${isr}, s-maxage=${isr}, stale-while-revalidate=60` },
},
'/**/_payload.json': {
headers: { 'cache-control': `public, max-age=${isr}, s-maxage=${isr}, stale-while-revalidate=60` },
},
}

if (existsSync(contentPath)) {
Expand Down
31 changes: 31 additions & 0 deletions patches/@nuxt__nitro-server@4.5.2.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
diff --git a/dist/runtime/handlers/renderer.mjs b/dist/runtime/handlers/renderer.mjs
index 8e92513..9277140 100644
--- a/dist/runtime/handlers/renderer.mjs
+++ b/dist/runtime/handlers/renderer.mjs
@@ -7,7 +7,7 @@ import { APP_ROOT_CLOSE_TAG, APP_ROOT_OPEN_TAG, getRenderer, getServerApp } from
import { renderInlineStyles } from "../utils/renderer/inline-styles.mjs";
import { renderStreamedIslandTeleports, replaceIslandTeleports } from "../utils/renderer/islands.mjs";
import { renderPayloadJsonScript, renderPayloadResponse, renderPayloadScript, splitPayload } from "../utils/renderer/payload.mjs";
-import { getQuery, joinURL } from "ufo";
+import { getQuery, hasProtocol, joinURL } from "ufo";
import { appendResponseHeader, createError, getQuery as getQuery$1, getRequestHeader, getResponseStatus, getResponseStatusText, removeResponseHeader, setResponseHeader, writeEarlyHints } from "h3";
import { defineRenderHandler, getRouteRules, useNitroApp } from "nitropack/runtime";
import destr from "destr";
@@ -159,7 +159,7 @@ async function renderRoute(event, ssrError) {
if (_PAYLOAD_EXTRACTION && !_PAYLOAD_INLINE && !NO_SCRIPTS) ssrContext.head.push({ link: [NUXT_JSON_PAYLOADS ? {
rel: "preload",
as: "fetch",
- crossorigin: "anonymous",
+ ...payloadURL && hasProtocol(payloadURL, { acceptRelative: true }) ? { crossorigin: "anonymous" } : {},
href: payloadURL
} : {
rel: "modulepreload",
@@ -278,7 +278,7 @@ async function renderStreamedResponse(ctx) {
if (_PAYLOAD_EXTRACTION && !_PAYLOAD_INLINE && !NO_SCRIPTS) ssrContext.head.push({ link: [NUXT_JSON_PAYLOADS ? {
rel: "preload",
as: "fetch",
- crossorigin: "anonymous",
+ ...payloadURL && hasProtocol(payloadURL, { acceptRelative: true }) ? { crossorigin: "anonymous" } : {},
href: payloadURL
} : {
rel: "modulepreload",
42 changes: 42 additions & 0 deletions patches/nuxt@4.5.2.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
diff --git a/dist/app/composables/payload.js b/dist/app/composables/payload.js
index 481e4ac..b4d43a7 100644
--- a/dist/app/composables/payload.js
+++ b/dist/app/composables/payload.js
@@ -31,10 +31,11 @@ function preloadPayload(url, opts = {}) {
if (!shouldPreload) return;
const payloadURL = await _getPayloadURL(url, opts);
const rel = detectLinkRelType();
+ const crossorigin = _isCrossOriginPayload(payloadURL);
const link = renderJsonPayloads ? defineLink({
rel,
as: "fetch",
- crossorigin: "anonymous",
+ ...crossorigin ? { crossorigin: "anonymous" } : {},
href: payloadURL
}) : {
rel: "modulepreload",
@@ -46,7 +47,7 @@ function preloadPayload(url, opts = {}) {
const linkEl = document.createElement("link");
linkEl.rel = rel;
linkEl.setAttribute("as", "fetch");
- linkEl.crossOrigin = "anonymous";
+ if (crossorigin) linkEl.crossOrigin = "anonymous";
linkEl.href = payloadURL;
document.head.appendChild(linkEl);
return new Promise((resolve, reject) => {
@@ -84,6 +85,15 @@ async function _getPayloadURL(url, opts = {}) {
if (hash) u.searchParams.set(payloadBuildIdParam, String(hash));
return payloadURL + u.search;
}
+function _isCrossOriginPayload(payloadURL) {
+ if (!hasProtocol(payloadURL, { acceptRelative: true })) return false;
+ if (import.meta.server) return true;
+ try {
+ return new URL(payloadURL, window.location.href).origin !== window.location.origin;
+ } catch {
+ return true;
+ }
+}
async function _importPayload(payloadURL, cache) {
if (import.meta.server || !payloadExtraction) return null;
try {
2 changes: 2 additions & 0 deletions playground/content/3.concepts/1.architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ The handler verifies the webhook signature with `WEBHOOK_SECRET`, resolves the n

The webhook advances the production pointer immediately. If delivery or refresh fails, the pointer expires within one hour and the next server render resolves the latest content commit through GitHub.

Client-side navigation fetches each page's data as `_payload.json`, separately from the HTML. Vercel's ISR default (`max-age=0, must-revalidate`) means the browser revalidates it on every navigation instead of trusting a stale copy, so a push takes effect there immediately too. For production pages, a weak `etag` derived from the production content SHA turns that revalidation into a cheap conditional request instead of a full re-download; [versioned previews](/concepts/versioned-previews) skip it and always re-download, since they render from their own pinned commit rather than production's.

## Markdown for agents

Every production documentation page is mirrored as raw Markdown at `/raw/<path>.md` ([versioned previews](/concepts/versioned-previews) serve HTML only). The mirrors carry the same ISR caching as the HTML pages. This part of the site is [nuxt-agent-discovery](https://github.com/benjamincanac/nuxt-agent-discovery), reading the same `comark-content` instance that renders the HTML.
Expand Down
2 changes: 2 additions & 0 deletions playground/content/4.deployment/1.vercel.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ The layer generates ISR route rules for every top-level content section, the lan

Tune or disable this with [`comarkDocs.isr`](/getting-started/configuration#nuxtconfigts-comarkdocs-options) in `nuxt.config.ts`. Your own `routeRules` take precedence over the generated ones.

Client-side navigation is unaffected by that expiration window: it fetches each page's data separately (`_payload.json`), which the browser revalidates on every navigation rather than caching for the ISR duration — so a webhook purge is visible there immediately too, at the cost of a conditional request the edge answers without re-sending the body.

## Rolling back content

Content follows the head of the production branch unless a `contentSha` pin is active, so rolling back a *deployment* in Vercel does not roll back *content*. For a temporary rollback that leaves git history unchanged, [pin production to an older content commit](#pin-production-content).
Expand Down
Loading
Loading