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
37 changes: 28 additions & 9 deletions apps/storefront/scripts/cms-tailwind-index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function endpointUrl(baseUrl, path) {
return base.href;
}

async function readBoundedJsonResponse(response, label, maxBytes = MAX_RESPONSE_BYTES) {
async function readBoundedTextResponse(response, label, maxBytes = MAX_RESPONSE_BYTES) {
const declaredLength = Number.parseInt(response.headers.get("content-length") || "", 10);
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
throw new Error(`${label} exceeded the ${maxBytes}-byte response limit`);
Expand All @@ -51,8 +51,13 @@ async function readBoundedJsonResponse(response, label, maxBytes = MAX_RESPONSE_
if (bytes.byteLength > maxBytes) {
throw new Error(`${label} exceeded the ${maxBytes}-byte response limit`);
}
return new TextDecoder().decode(bytes);
}

async function readBoundedJsonResponse(response, label, maxBytes = MAX_RESPONSE_BYTES) {
const responseText = await readBoundedTextResponse(response, label, maxBytes);
try {
return JSON.parse(new TextDecoder().decode(bytes));
return JSON.parse(responseText);
} catch {
throw new Error(`${label} returned invalid JSON`);
}
Expand Down Expand Up @@ -382,17 +387,31 @@ export async function fetchPreviousTailwindSourceIndex({
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (response.status === 404 && allowBootstrap) return emptyIndex();
if (
response.ok
&& allowBootstrap
&& (response.headers.get("content-type") || "").toLowerCase().includes("text/html")
) {
return emptyIndex();
}
if (response.status === 404) {
throw new Error("previous Tailwind source index is missing; enable CMS_TAILWIND_BOOTSTRAP for the first deployment");
}
if (!response.ok) throw new Error(`previous Tailwind source index returned HTTP ${response.status}`);
if (allowBootstrap) {
const responseText = await readBoundedTextResponse(
response,
"previous Tailwind source index",
MAX_INDEX_BYTES,
);
const contentType = (response.headers.get("content-type") || "").toLowerCase();
if (
contentType.includes("text/html")
|| /^\s*(?:<!doctype\s+html\b|<html\b)/i.test(responseText)
) {
return emptyIndex();
}
let payload;
try {
payload = JSON.parse(responseText);
} catch {
throw new Error("previous Tailwind source index returned invalid JSON");
}
return validateTailwindSourceIndex(payload);
}
return validateTailwindSourceIndex(
await readBoundedJsonResponse(response, "previous Tailwind source index", MAX_INDEX_BYTES),
);
Expand Down
13 changes: 13 additions & 0 deletions apps/storefront/scripts/cms-tailwind-index.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,19 @@ test("missing deployed index requires the explicit bootstrap flag", async () =>

});

test("bootstrap treats a mislabeled Netlify SPA fallback as a missing deployed index", async () => {
const result = await fetchPreviousTailwindSourceIndex({
indexUrl: `${siteUrl}/.well-known/funkycommerce-tailwind-index.json`,
allowBootstrap: true,
fetchImpl: async () => new Response("<!doctype html><html><body>Storefront</body></html>", {
headers: { "content-type": "application/json; charset=UTF-8" },
}),
});

assert.equal(result.generatedAt, "1970-01-01T00:00:00.000Z");
assert.deepEqual(result.sources, {});
});

test("bootstrap resumes from a validated local index without refetching source bodies", async () => {
await withOutput(async ({ outputPath, indexOutputPath }) => {
const sourceVersion = version("page-7-v1");
Expand Down
4 changes: 2 additions & 2 deletions apps/storefront/scripts/prerender-language-fallback.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ test("prerender uses the WordPress site language when Polylang is unavailable",
test("prerender retries commerce route discovery without multilingual WooCommerce metadata", () => {
assert.match(
prerenderSource,
/!backendLanguageFieldsAvailable[\s\S]*?WPGraphQL route discovery failed with status 500/,
/!backendLanguageFieldsAvailable[\s\S]*?error instanceof GraphqlHttpError[\s\S]*?error\.status >= 500/,
);
assert.match(
prerenderSource,
Expand All @@ -35,7 +35,7 @@ test("prerender retries commerce route discovery without multilingual WooCommerc
);
assert.match(
prerenderSource,
/WPGraphQL route discovery without commerce metadata failed with status 500[\s\S]*?discoverStandardWordPressRoutes/,
/compatibilityError instanceof GraphqlHttpError[\s\S]*?compatibilityError\.status < 500[\s\S]*?discoverStandardWordPressRoutes/,
);
assert.match(
prerenderSource,
Expand Down
15 changes: 8 additions & 7 deletions apps/storefront/scripts/prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -692,10 +692,11 @@ class GraphqlResponseError extends Error {
}

class GraphqlHttpError extends Error {
constructor(message, retryable) {
constructor(message, status) {
super(message);
this.name = "GraphqlHttpError";
this.retryable = retryable;
this.status = status;
this.retryable = status === 429 || status >= 500;
}
}

Expand Down Expand Up @@ -757,7 +758,7 @@ async function requestGraphql(
if (!response.ok) {
throw new GraphqlHttpError(
`${operationLabel} failed with status ${response.status}`,
response.status === 429 || response.status >= 500,
response.status,
);
}

Expand Down Expand Up @@ -1357,8 +1358,8 @@ async function discoverCmsRoutes({
)
|| (
!backendLanguageFieldsAvailable
&& error instanceof Error
&& /WPGraphQL route discovery failed with status 500/.test(error.message)
&& error instanceof GraphqlHttpError
&& error.status >= 500
)
);
if (commerceMetadataUnavailable) {
Expand All @@ -1383,8 +1384,8 @@ async function discoverCmsRoutes({
);
} catch (compatibilityError) {
if (
!(compatibilityError instanceof Error)
|| !/WPGraphQL route discovery without commerce metadata failed with status 500/.test(compatibilityError.message)
!(compatibilityError instanceof GraphqlHttpError)
|| compatibilityError.status < 500
) {
throw compatibilityError;
}
Expand Down
31 changes: 22 additions & 9 deletions apps/storefront/scripts/route-query.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,13 @@ export function buildCoreRoutesQuery({
seo = false,
} = {}) {
const selectedConnections = new Set(connections);
const variables = [
selectedConnections.has("pages") ? "$pageAfter: String" : "",
selectedConnections.has("posts") ? "$postAfter: String" : "",
selectedConnections.has("categories") ? "$categoryAfter: String" : "",
selectedConnections.has("tags") ? "$tagAfter: String" : "",
selectedConnections.has("users") ? "$userAfter: String" : "",
].filter(Boolean).join(", ");
const languageField = multilingual ? "language { code }" : "";
const pageLanguageFields = multilingual
? "language { code }\n translations { databaseId uri }"
Expand All @@ -272,15 +279,20 @@ export function buildCoreRoutesQuery({
? "funkycommercePublicRobots { noindex nofollow }"
: "";
const taxonomySeoField = seo ? "seo { ...StorefrontTaxonomyRouteSeo }" : "";
const usesImageFragment = selectedConnections.has("pages")
|| selectedConnections.has("posts")
|| (seo && (
selectedConnections.has("categories")
|| selectedConnections.has("tags")
|| selectedConnections.has("users")
));
const usesPostTypeSeoFragment = seo
&& (selectedConnections.has("pages") || selectedConnections.has("posts"));
const usesTaxonomySeoFragment = seo
&& (selectedConnections.has("categories") || selectedConnections.has("tags"));

return `
query StorefrontCoreBuildRoutes(
$pageAfter: String
$postAfter: String
$categoryAfter: String
$tagAfter: String
$userAfter: String
) {
query StorefrontCoreBuildRoutes(${variables}) {
readingSettings {
showOnFront
pageOnFront
Expand Down Expand Up @@ -352,7 +364,8 @@ export function buildCoreRoutesQuery({
pageInfo { hasNextPage endCursor }
}` : ""}
}
${ROUTE_IMAGE_FRAGMENT}
${seo ? `${POST_TYPE_SEO_FRAGMENT}\n${TAXONOMY_SEO_FRAGMENT}` : ""}
${usesImageFragment ? ROUTE_IMAGE_FRAGMENT : ""}
${usesPostTypeSeoFragment ? POST_TYPE_SEO_FRAGMENT : ""}
${usesTaxonomySeoFragment ? TAXONOMY_SEO_FRAGMENT : ""}
`;
}
18 changes: 18 additions & 0 deletions apps/storefront/scripts/route-query.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,28 @@ test("core fallback paginates standard routes without the generic connections",
test("core fallback can isolate a connection when combined resolvers fail", () => {
const query = buildCoreRoutesQuery({ connections: ["posts"] });

assert.match(query, /query StorefrontCoreBuildRoutes\(\$postAfter: String\)/);
assert.match(query, /posts\(first: 25/);
for (const connection of ["pages", "categories", "tags", "users"]) {
assert.doesNotMatch(query, new RegExp(`${connection}\\(first: 25`));
}
assert.doesNotMatch(
query,
/\$pageAfter|\$categoryAfter|\$tagAfter|\$userAfter|StorefrontTaxonomyRouteSeo/,
);
});

test("isolated core taxonomy fallback omits unrelated variables and fragments", () => {
const query = buildCoreRoutesQuery({ connections: ["categories"], seo: true });

assert.match(query, /query StorefrontCoreBuildRoutes\(\$categoryAfter: String\)/);
assert.match(query, /categories\(first: 25/);
assert.match(query, /fragment StorefrontRouteImage on MediaItem/);
assert.match(query, /fragment StorefrontTaxonomyRouteSeo on TaxonomySEO/);
assert.doesNotMatch(
query,
/\$pageAfter|\$postAfter|\$tagAfter|\$userAfter|StorefrontPostTypeRouteSeo/,
);
});

test("generic route discovery can isolate one bounded connection", () => {
Expand Down
Loading