mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
8a872529fe
* docs: make /worlds the canonical home for World docs The world pages (Local/Postgres/Vercel) and Building a World were duplicated inside the v4 and v5 docs trees while /worlds/[id] rendered the v4 copy — hiding v5-only content like multi-region and leaving two diverging sources of truth. - Move world docs to an unversioned docs/content/worlds/ collection (based on the v5 copies, with inline 4.x callouts for factory naming and 5.x-only env vars), rendered at /worlds/* - Add /worlds/building-a-world; flatten the docs Deploying section to a single intro page and drop its Rocket icon - Point every link, frontmatter ref, and worlds-manifest docs field at /worlds/*; add redirects for the removed v5 and building-a-world URLs - Keep world docs on agent-facing surfaces: search, llms.txt, sitemap.md/.xml, and .md exports now serve the worlds collection - Extend the docs link linter to validate worlds pages (with heading anchors) and their outgoing links Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Pranay Prakash <pranay.gp@gmail.com> * docs: version the world docs like the docs trees (v4/v5 switcher) Instead of a single unversioned copy, world docs now follow the same versioning strategy as the docs pages: content/worlds/v4 is served at /worlds/* (current) and content/worlds/v5 at /v5/worlds/*, restoring the original per-version content. Each world detail page (and Building a World) renders the docs version switcher — the worlds listing page has no natural home for it, so it lives on the world pages themselves. - Render-time href rewriting on v5 pages now covers /worlds/... links (shared rewriteHrefForVersion helper, also used by the v5 docs and cookbook routes), and the markdown-export rewrite does the same - v5 world pages are noindexed with a canonical to /worlds/<id>; community worlds stay unversioned (/v5/worlds/<id> redirects) - /v5/docs/deploying/world/* redirects now land on /v5/worlds/*; /v5/worlds and /v5/worlds/compare redirect to the unversioned pages - Link linter models the versioned worlds URL spaces (v5 pages resolve /worlds hrefs against the v5 collection); sitemap.md and the .md export routes cover /v5/worlds/* Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Pranay Prakash <pranay.gp@gmail.com> * docs: fix v4 multi-region anchor and tighten version-prefix matching Address PR review: - The v4 Deploying page linked /worlds/vercel#multi-region, but the Multi-region section only exists on the v5 world page; use the explicit cross-version /v5/worlds/vercel#multi-region link (this was the Docs Links CI failure) - rewriteHrefForVersion now uses the boundary-checked hasPathPrefix (shared leaf module lib/geistdocs/path-prefix.ts, also used by source.ts) instead of bare startsWith - buildVersionUrl's shared-route fast path is segment-based rather than substring includes() Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Pranay Prakash <pranay.gp@gmail.com> --------- Signed-off-by: Pranay Prakash <pranay.gp@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
335 lines
9.5 KiB
JavaScript
335 lines
9.5 KiB
JavaScript
import { spawn } from 'node:child_process';
|
|
import { getTrustedSourcesHeaders } from '../../scripts/trusted-sources-headers.mjs';
|
|
|
|
/**
|
|
* Docs smoke checks.
|
|
*
|
|
* This script is intentionally small and dependency-free so it can run in CI.
|
|
* It validates critical public endpoints (OG images and sitemap) and can be
|
|
* extended with more lightweight checks over time.
|
|
*
|
|
* When DEPLOYMENT_URL or OG_BASE_URL is set, it targets a remote deployment.
|
|
* Otherwise it starts the local docs server and tests against localhost.
|
|
*/
|
|
|
|
const PORT = process.env.OG_TEST_PORT || '3100';
|
|
const HOST = '127.0.0.1';
|
|
const rawBaseUrl = process.env.DEPLOYMENT_URL || process.env.OG_BASE_URL || '';
|
|
const BASE_URL = rawBaseUrl
|
|
? rawBaseUrl.startsWith('http')
|
|
? rawBaseUrl
|
|
: `https://${rawBaseUrl}`
|
|
: `http://${HOST}:${PORT}`;
|
|
const USE_REMOTE = Boolean(rawBaseUrl);
|
|
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
|
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
const assertNoProtection = async (path) => {
|
|
const res = await fetch(`${BASE_URL}${path}`, {
|
|
redirect: 'manual',
|
|
headers: await getTrustedSourcesHeaders(),
|
|
});
|
|
const location = res.headers.get('location') || '';
|
|
if (
|
|
res.status === 307 &&
|
|
(location.includes('vercel.com/login') ||
|
|
location.includes('/_vercel/login'))
|
|
) {
|
|
throw new Error(
|
|
`${path} redirected to Vercel login; check deployment protection/bypass`
|
|
);
|
|
}
|
|
};
|
|
|
|
const waitForServer = async (url, timeoutMs = 30_000) => {
|
|
const startedAt = Date.now();
|
|
let lastStatus = null;
|
|
let lastBody = null;
|
|
// x-vercel-id identifies the Vercel edge node that served the response.
|
|
// While the proxy-side trusted-sources changes are rolling out gradually,
|
|
// a failing request may be hitting an edge node that hasn't received the
|
|
// fix yet — surfacing the id in the timeout error makes that visible.
|
|
let lastVercelId = null;
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
try {
|
|
const res = await fetch(url, {
|
|
headers: await getTrustedSourcesHeaders(),
|
|
});
|
|
if (res.ok) return;
|
|
lastStatus = res.status;
|
|
lastVercelId = res.headers.get('x-vercel-id');
|
|
// Capture the first ~500 chars of the body to help diagnose
|
|
// protection/auth failures (e.g. SSO login redirects).
|
|
try {
|
|
lastBody = (await res.text()).slice(0, 500);
|
|
} catch {
|
|
lastBody = '<unable to read body>';
|
|
}
|
|
} catch (err) {
|
|
lastStatus = `network error: ${(err && err.message) || err}`;
|
|
}
|
|
await wait(500);
|
|
}
|
|
throw new Error(
|
|
`Timed out waiting for server at ${url} (last status: ${lastStatus}; x-vercel-id: ${lastVercelId}; body: ${lastBody})`
|
|
);
|
|
};
|
|
|
|
const assertPngResponse = async (path) => {
|
|
const res = await fetch(`${BASE_URL}${path}`, {
|
|
headers: await getTrustedSourcesHeaders(),
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`${path} returned ${res.status}`);
|
|
}
|
|
const contentType = res.headers.get('content-type') || '';
|
|
if (!contentType.includes('image/png')) {
|
|
throw new Error(`${path} content-type was ${contentType}`);
|
|
}
|
|
const buf = new Uint8Array(await res.arrayBuffer());
|
|
for (let i = 0; i < PNG_SIGNATURE.length; i += 1) {
|
|
if (buf[i] !== PNG_SIGNATURE[i]) {
|
|
throw new Error(`${path} did not start with PNG signature bytes`);
|
|
}
|
|
}
|
|
};
|
|
|
|
const assertHtmlMeta = async (path, expectedOgImagePath) => {
|
|
const res = await fetch(`${BASE_URL}${path}`, {
|
|
headers: await getTrustedSourcesHeaders(),
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`${path} returned ${res.status}`);
|
|
}
|
|
const contentType = res.headers.get('content-type') || '';
|
|
if (!contentType.includes('text/html')) {
|
|
throw new Error(`${path} content-type was ${contentType}`);
|
|
}
|
|
const html = await res.text();
|
|
const ogImage = html.match(
|
|
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["'][^>]*>/i
|
|
)?.[1];
|
|
if (!ogImage) {
|
|
throw new Error(`${path} missing og:image meta tag`);
|
|
}
|
|
if (expectedOgImagePath) {
|
|
const normalized = ogImage.startsWith('http')
|
|
? new URL(ogImage).pathname
|
|
: ogImage;
|
|
if (normalized !== expectedOgImagePath) {
|
|
throw new Error(
|
|
`${path} og:image was ${ogImage}, expected ${expectedOgImagePath}`
|
|
);
|
|
}
|
|
}
|
|
const twitterImage = html.match(
|
|
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["'][^>]*>/i
|
|
)?.[1];
|
|
if (!twitterImage) {
|
|
throw new Error(`${path} missing twitter:image meta tag`);
|
|
}
|
|
if (expectedOgImagePath) {
|
|
const normalized = twitterImage.startsWith('http')
|
|
? new URL(twitterImage).pathname
|
|
: twitterImage;
|
|
if (normalized !== expectedOgImagePath) {
|
|
throw new Error(
|
|
`${path} twitter:image was ${twitterImage}, expected ${expectedOgImagePath}`
|
|
);
|
|
}
|
|
}
|
|
const ogTitle = html.match(
|
|
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["'][^>]*>/i
|
|
)?.[1];
|
|
if (!ogTitle) {
|
|
throw new Error(`${path} missing og:title meta tag`);
|
|
}
|
|
const ogDescription = html.match(
|
|
/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["'][^>]*>/i
|
|
)?.[1];
|
|
if (!ogDescription) {
|
|
throw new Error(`${path} missing og:description meta tag`);
|
|
}
|
|
};
|
|
|
|
const checks = [
|
|
{
|
|
name: 'Deployment protection',
|
|
run: () => assertNoProtection('/og'),
|
|
},
|
|
{
|
|
name: 'OG default image',
|
|
run: () => assertPngResponse('/og'),
|
|
},
|
|
{
|
|
name: 'HTML meta - docs root',
|
|
run: () => assertHtmlMeta('/docs', '/og/getting-started/image.png'),
|
|
},
|
|
{
|
|
name: 'HTML meta - docs idempotency',
|
|
run: () =>
|
|
assertHtmlMeta(
|
|
'/docs/foundations/idempotency',
|
|
'/og/foundations/idempotency/image.png'
|
|
),
|
|
},
|
|
{
|
|
name: 'HTML meta - cookbook sequential & parallel',
|
|
run: () =>
|
|
assertHtmlMeta(
|
|
'/cookbook/common-patterns/sequential-and-parallel',
|
|
'/og/cookbook/common-patterns/sequential-and-parallel/image.png'
|
|
),
|
|
},
|
|
{
|
|
name: 'HTML meta - docs get-writable',
|
|
run: () =>
|
|
assertHtmlMeta(
|
|
'/docs/api-reference/workflow/get-writable',
|
|
'/og/api-reference/workflow/get-writable/image.png'
|
|
),
|
|
},
|
|
{
|
|
name: 'HTML meta - worlds index',
|
|
run: () => assertHtmlMeta('/worlds', '/og/worlds'),
|
|
},
|
|
{
|
|
name: 'HTML meta - world local',
|
|
run: () => assertHtmlMeta('/worlds/local', '/og/worlds/local'),
|
|
},
|
|
{
|
|
name: 'HTML meta - world postgres',
|
|
run: () => assertHtmlMeta('/worlds/postgres', '/og/worlds/postgres'),
|
|
},
|
|
{
|
|
name: 'HTML meta - world vercel',
|
|
run: () => assertHtmlMeta('/worlds/vercel', '/og/worlds/vercel'),
|
|
},
|
|
{
|
|
name: 'HTML meta - worlds building-a-world',
|
|
run: () => assertHtmlMeta('/worlds/building-a-world', '/og/worlds'),
|
|
},
|
|
{
|
|
name: 'HTML meta - world vercel (v5)',
|
|
run: () => assertHtmlMeta('/v5/worlds/vercel', '/og/worlds/vercel'),
|
|
},
|
|
{
|
|
name: 'OG docs page image',
|
|
run: () => assertPngResponse('/og/foundations/idempotency/image.png'),
|
|
},
|
|
{
|
|
name: 'OG docs root image',
|
|
run: () => assertPngResponse('/og/getting-started/image.png'),
|
|
},
|
|
{
|
|
name: 'OG cookbook common-patterns image',
|
|
run: () =>
|
|
assertPngResponse(
|
|
'/og/cookbook/common-patterns/sequential-and-parallel/image.png'
|
|
),
|
|
},
|
|
{
|
|
name: 'OG docs reference image',
|
|
run: () =>
|
|
assertPngResponse('/og/api-reference/workflow/get-writable/image.png'),
|
|
},
|
|
{
|
|
name: 'OG worlds index image',
|
|
run: () => assertPngResponse('/og/worlds'),
|
|
},
|
|
{
|
|
name: 'OG world image (local)',
|
|
run: () => assertPngResponse('/og/worlds/local'),
|
|
},
|
|
{
|
|
name: 'OG world image (postgres)',
|
|
run: () => assertPngResponse('/og/worlds/postgres'),
|
|
},
|
|
{
|
|
name: 'OG world image (vercel)',
|
|
run: () => assertPngResponse('/og/worlds/vercel'),
|
|
},
|
|
{
|
|
name: 'Sitemap',
|
|
run: () => assertXmlResponse('/sitemap.xml'),
|
|
},
|
|
];
|
|
|
|
const assertXmlResponse = async (path) => {
|
|
const res = await fetch(`${BASE_URL}${path}`, {
|
|
headers: await getTrustedSourcesHeaders(),
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`${path} returned ${res.status}`);
|
|
}
|
|
const contentType = res.headers.get('content-type') || '';
|
|
if (!contentType.includes('xml') && !contentType.includes('text/plain')) {
|
|
throw new Error(`${path} content-type was ${contentType}`);
|
|
}
|
|
const text = await res.text();
|
|
if (!text.includes('<?xml')) {
|
|
throw new Error(`${path} did not contain xml declaration`);
|
|
}
|
|
};
|
|
|
|
const run = async () => {
|
|
let child = null;
|
|
let stopServer = async () => {};
|
|
let cleanup = async () => {};
|
|
|
|
if (!USE_REMOTE) {
|
|
child = spawn('pnpm', ['-C', 'docs', 'start'], {
|
|
env: {
|
|
...process.env,
|
|
PORT,
|
|
HOSTNAME: HOST,
|
|
},
|
|
stdio: 'inherit',
|
|
});
|
|
|
|
let shuttingDown = false;
|
|
stopServer = async () => {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
child.kill('SIGTERM');
|
|
await Promise.race([
|
|
new Promise((resolve) => child.once('exit', resolve)),
|
|
wait(5_000),
|
|
]);
|
|
if (!child.killed) {
|
|
child.kill('SIGKILL');
|
|
}
|
|
};
|
|
|
|
cleanup = async () => {
|
|
try {
|
|
await stopServer();
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
};
|
|
|
|
process.on('SIGINT', cleanup);
|
|
process.on('SIGTERM', cleanup);
|
|
process.on('exit', cleanup);
|
|
}
|
|
|
|
try {
|
|
await waitForServer(`${BASE_URL}/og`);
|
|
for (const check of checks) {
|
|
console.log(`Running docs smoke check: ${check.name}`);
|
|
await check.run();
|
|
}
|
|
await stopServer();
|
|
} catch (error) {
|
|
await stopServer();
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
run().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|