diff --git a/packages/cli/cli/changes/unreleased/add-revalidate-endpoint.yml b/packages/cli/cli/changes/unreleased/add-revalidate-endpoint.yml new file mode 100644 index 00000000000..8d7df74eb1b --- /dev/null +++ b/packages/cli/cli/changes/unreleased/add-revalidate-endpoint.yml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Add `/api/revalidate` endpoint to docs preview server that revalidates all locales by default. + When no `locale` parameter is provided, the endpoint recomputes translations for all configured locales. + When a specific `locale` parameter is provided (e.g., `{"locale": "fr"}`), only that locale is revalidated. + type: feat diff --git a/packages/cli/docs-preview/src/createBunServer.ts b/packages/cli/docs-preview/src/createBunServer.ts index a2badc9488c..65d6128e7a0 100644 --- a/packages/cli/docs-preview/src/createBunServer.ts +++ b/packages/cli/docs-preview/src/createBunServer.ts @@ -40,6 +40,12 @@ export interface BunServerOptions { * E.g., "/fr/getting-started" -> "fr", "/getting-started" -> undefined */ extractLocaleFromPath?(urlPath: string | undefined): string | undefined; + /** + * Callback to revalidate locale translations. + * If locale is undefined, revalidates all locales. + * Returns an array of revalidated locale codes. + */ + onRevalidate?(locale?: string): Promise; } export interface BunServer { @@ -57,7 +63,7 @@ export function createBunServer(options: BunServerOptions): BunServer | undefine return undefined; } - const { port, debugLogger, getDocsLoadResponse, extractLocaleFromPath } = options; + const { port, debugLogger, getDocsLoadResponse, extractLocaleFromPath, onRevalidate } = options; type WsData = { connectionId: string }; const connections = new Map, { pingInterval: NodeJS.Timeout; lastPong: number }>(); @@ -112,6 +118,33 @@ export function createBunServer(options: BunServerOptions): BunServer | undefine } } + // POST /api/revalidate + if (req.method === "POST" && url.pathname === "/api/revalidate") { + if (onRevalidate == null) { + return new Response(JSON.stringify({ error: "Revalidation not supported" }), { + status: 501, + headers: { "Content-Type": "application/json", ...CORS_HEADERS } + }); + } + try { + const body = (await req.json()) as { locale?: string } | undefined; + const revalidatedLocales = await onRevalidate(body?.locale); + return new Response(JSON.stringify({ revalidated: revalidatedLocales }), { + headers: { "Content-Type": "application/json", ...CORS_HEADERS } + }); + } catch (error) { + return new Response( + JSON.stringify({ + error: error instanceof Error ? error.message : "Failed to revalidate locales" + }), + { + status: 500, + headers: { "Content-Type": "application/json", ...CORS_HEADERS } + } + ); + } + } + // GET /_local/... const localMatch = /^\/_local\/(.*)/.exec(url.pathname); if (req.method === "GET" && localMatch != null) { diff --git a/packages/cli/docs-preview/src/runAppPreviewServer.ts b/packages/cli/docs-preview/src/runAppPreviewServer.ts index 9201cc45697..47a0a3bb0fa 100644 --- a/packages/cli/docs-preview/src/runAppPreviewServer.ts +++ b/packages/cli/docs-preview/src/runAppPreviewServer.ts @@ -1056,6 +1056,43 @@ export async function runAppPreviewServer({ } }); + // eslint-disable-next-line @typescript-eslint/no-misused-promises + app.post("/api/revalidate", async (req, res) => { + try { + const requestBody = req.body as { locale?: string } | undefined; + const specificLocale = requestBody?.locale; + + if (previewResult == null) { + context.logger.warn("Cannot revalidate: no docs definition loaded"); + return res.status(503).json({ error: "No docs definition loaded" }); + } + + // Revalidate all locales by default, or just the specified locale + if (specificLocale != null) { + context.logger.info(`Revalidating locale: ${specificLocale}`); + const singleLocaleMap = await computeTranslatedDefinitions(previewResult); + const revalidatedDefinition = singleLocaleMap.get(specificLocale); + if (revalidatedDefinition != null) { + translatedDefinitions.set(specificLocale, revalidatedDefinition); + return res.json({ revalidated: [specificLocale] }); + } else { + return res.status(404).json({ error: `Locale '${specificLocale}' not found` }); + } + } else { + context.logger.info("Revalidating all locales"); + translatedDefinitions = await computeTranslatedDefinitions(previewResult); + const revalidatedLocales = Array.from(translatedDefinitions.keys()); + return res.json({ + revalidated: revalidatedLocales.length > 0 ? revalidatedLocales : ["default"] + }); + } + } catch (error) { + context.logger.error("Error revalidating locales", (error as Error).message); + context.logger.error("Stack trace:", (error as Error).stack ?? ""); + res.status(500).json({ error: "Failed to revalidate locales" }); + } + }); + app.get(/^\/_local\/(.*)/, (req, res) => { return res.sendFile(`/${req.params[0]}`); }); @@ -1068,7 +1105,29 @@ export async function runAppPreviewServer({ port: backendPort, debugLogger, getDocsLoadResponse: buildDocsLoadResponse, - extractLocaleFromPath + extractLocaleFromPath, + onRevalidate: async (locale?: string): Promise => { + if (previewResult == null) { + throw new Error("No docs definition loaded"); + } + + if (locale != null) { + context.logger.info(`Revalidating locale: ${locale}`); + const singleLocaleMap = await computeTranslatedDefinitions(previewResult); + const revalidatedDefinition = singleLocaleMap.get(locale); + if (revalidatedDefinition != null) { + translatedDefinitions.set(locale, revalidatedDefinition); + return [locale]; + } else { + throw new Error(`Locale '${locale}' not found`); + } + } else { + context.logger.info("Revalidating all locales"); + translatedDefinitions = await computeTranslatedDefinitions(previewResult); + const revalidatedLocales = Array.from(translatedDefinitions.keys()); + return revalidatedLocales.length > 0 ? revalidatedLocales : ["default"]; + } + } }); if (bunHandle != null) { sendData = bunHandle.sendData;