snappy-prerender 2.6.0-rc.3 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +191 -0
- package/README.md +149 -107
- package/docs/limitations.md +58 -0
- package/docs/react-snap-migration.md +45 -0
- package/docs/report.md +102 -0
- package/package.json +9 -5
- package/src/cli-init.js +205 -0
- package/src/cli.js +159 -168
- package/src/core/artifacts.js +112 -0
- package/src/core/audit/index.js +114 -0
- package/src/core/audit/links.js +63 -0
- package/src/core/audit/metadata.js +88 -0
- package/src/core/audit/sitemap.js +39 -0
- package/src/core/blocking.js +53 -1
- package/src/core/browser.js +109 -39
- package/src/core/build-origin.js +19 -0
- package/src/core/config.js +256 -6
- package/src/core/crawl.js +300 -0
- package/src/core/csp.js +127 -0
- package/src/core/css-url.js +34 -0
- package/src/core/destination.js +28 -3
- package/src/core/frameworks.js +94 -0
- package/src/core/hydration-diff.js +93 -0
- package/src/core/init.js +187 -31
- package/src/core/inline-css.js +34 -13
- package/src/core/metadata-origin.js +11 -2
- package/src/core/normalize.js +59 -2
- package/src/core/output.js +32 -9
- package/src/core/page-content.js +13 -0
- package/src/core/page-meta.js +108 -0
- package/src/core/preload-links.js +120 -0
- package/src/core/preload-manifest.js +61 -7
- package/src/core/prune.js +65 -0
- package/src/core/render.js +147 -11
- package/src/core/report.js +108 -0
- package/src/core/resources.js +6 -1
- package/src/core/route-config.js +17 -0
- package/src/core/route-sources.js +209 -0
- package/src/core/routes.js +9 -0
- package/src/core/run.js +186 -358
- package/src/core/server.js +30 -4
- package/src/core/sitemap.js +128 -20
- package/src/core/url-policy.js +32 -0
- package/src/core/verify.js +103 -49
- package/src/core/write.js +363 -0
- package/src/head/index.js +2 -2
- package/src/head/manager.js +54 -13
- package/src/head/tags.js +71 -32
package/src/core/server.js
CHANGED
|
@@ -8,18 +8,32 @@ import sirv from 'sirv';
|
|
|
8
8
|
* an ephemeral loopback port so parallel runs never collide.
|
|
9
9
|
*/
|
|
10
10
|
export async function startStaticServer({ dir, base }) {
|
|
11
|
-
const handler = sirv(dir, { dev: true, etag: true
|
|
11
|
+
const handler = sirv(dir, { dev: true, etag: true });
|
|
12
|
+
const indexHandler = sirv(dir, { dev: true, etag: true });
|
|
12
13
|
const server = http.createServer((request, response) => {
|
|
13
14
|
if (base !== '/') {
|
|
14
15
|
const stripped = stripBase(request.url, base);
|
|
15
16
|
if (stripped === null) {
|
|
16
|
-
response
|
|
17
|
-
response.end('Not found');
|
|
17
|
+
notFound(response);
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
20
|
request.url = stripped;
|
|
21
21
|
}
|
|
22
|
-
handler(request, response)
|
|
22
|
+
handler(request, response, () => {
|
|
23
|
+
// Navigation requests can be SPA routes that only exist in the router. Assets,
|
|
24
|
+
// fetches and websockets ask for something else and must see their real 404.
|
|
25
|
+
if (!acceptsHtml(request)) {
|
|
26
|
+
notFound(response);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const original = request.url;
|
|
30
|
+
request.url = '/index.html';
|
|
31
|
+
try {
|
|
32
|
+
indexHandler(request, response, () => notFound(response));
|
|
33
|
+
} finally {
|
|
34
|
+
request.url = original;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
23
37
|
});
|
|
24
38
|
|
|
25
39
|
await new Promise((resolve, reject) => {
|
|
@@ -37,6 +51,18 @@ export async function startStaticServer({ dir, base }) {
|
|
|
37
51
|
};
|
|
38
52
|
}
|
|
39
53
|
|
|
54
|
+
// Reports whether a request is a document navigation rather than an asset or fetch.
|
|
55
|
+
function acceptsHtml(request) {
|
|
56
|
+
const accept = request.headers.accept ?? '';
|
|
57
|
+
return accept.includes('text/html') || accept.includes('application/xhtml+xml');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Answers a request no handler could serve.
|
|
61
|
+
function notFound(response) {
|
|
62
|
+
response.statusCode = 404;
|
|
63
|
+
response.end('Not found');
|
|
64
|
+
}
|
|
65
|
+
|
|
40
66
|
/**
|
|
41
67
|
* Removes the configured base prefix from a request URL, keeping any query string
|
|
42
68
|
* intact. Returns null when the request falls outside the base path.
|
package/src/core/sitemap.js
CHANGED
|
@@ -1,37 +1,64 @@
|
|
|
1
1
|
import { normaliseRoute, toPublicPath } from './routes.js';
|
|
2
|
+
import { pagePath } from './url-policy.js';
|
|
2
3
|
|
|
3
4
|
const XML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
5
|
+
export const SITEMAP_MAX_URLS = 50000;
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
|
-
* Builds the sitemap for the routes a run wrote. Locs
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Builds the sitemap for the routes a run wrote. Locs use the same page path policy as
|
|
9
|
+
* canonical links, so a directory route keeps its slash, a flat route stays
|
|
10
|
+
* extensionless, and a noindex page is never listed. More than maxUrls entries are
|
|
11
|
+
* split into numbered sitemaps behind a sitemap index.
|
|
10
12
|
*/
|
|
11
|
-
export function buildSitemap({ files, config,
|
|
13
|
+
export function buildSitemap({ files, config, maxUrls = SITEMAP_MAX_URLS }) {
|
|
12
14
|
const siteUrl = config.metadata?.siteUrl?.replace(/\/$/, '');
|
|
13
15
|
if (!siteUrl) return null;
|
|
14
16
|
const notFound = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
|
|
15
17
|
|
|
16
|
-
const
|
|
18
|
+
const entries = [];
|
|
17
19
|
for (const file of files) {
|
|
18
|
-
if (!file.file.endsWith('.html')) continue;
|
|
20
|
+
if (!file.file.endsWith('.html') || file.noindex) continue;
|
|
19
21
|
if (file.route && notFound && normaliseRoute(file.route) === notFound) continue;
|
|
20
|
-
|
|
22
|
+
const path = file.route
|
|
23
|
+
? pagePath(file.route, {
|
|
24
|
+
flatOutput: config.flatOutput,
|
|
25
|
+
trailingSlash: config.metadata?.trailingSlash,
|
|
26
|
+
})
|
|
27
|
+
: publicPath(file.file);
|
|
28
|
+
entries.push({
|
|
29
|
+
loc: `${siteUrl}${toPublicPath(path, config.base)}`,
|
|
30
|
+
alternates: normaliseAlternates(file.alternates ?? [], siteUrl, config.base),
|
|
31
|
+
images: config.imageSitemap ? normaliseImages(file.images ?? [], siteUrl, config.base) : [],
|
|
32
|
+
});
|
|
21
33
|
}
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
if (entries.length === 0) return null;
|
|
35
|
+
|
|
36
|
+
entries.sort((a, b) => a.loc.localeCompare(b.loc));
|
|
37
|
+
const chunks = [];
|
|
38
|
+
for (let index = 0; index < entries.length; index += maxUrls) {
|
|
39
|
+
chunks.push(entries.slice(index, index + maxUrls));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (chunks.length === 1) {
|
|
43
|
+
return {
|
|
44
|
+
files: [{ name: 'sitemap.xml', contents: urlSet(chunks[0]) }],
|
|
45
|
+
routes: entries.length,
|
|
46
|
+
locs: entries.map((entry) => entry.loc),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const chunksWithNames = chunks.map((chunk, index) => {
|
|
51
|
+
const name = `sitemap-${index + 1}.xml`;
|
|
52
|
+
const loc = `${siteUrl}${toPublicPath(`/${name}`, config.base)}`;
|
|
53
|
+
return { name, contents: urlSet(chunk), loc };
|
|
54
|
+
});
|
|
32
55
|
return {
|
|
33
|
-
|
|
34
|
-
|
|
56
|
+
files: [
|
|
57
|
+
{ name: 'sitemap.xml', contents: sitemapIndex(chunksWithNames) },
|
|
58
|
+
...chunksWithNames.map(({ name, contents }) => ({ name, contents })),
|
|
59
|
+
],
|
|
60
|
+
routes: entries.length,
|
|
61
|
+
locs: entries.map((entry) => entry.loc),
|
|
35
62
|
};
|
|
36
63
|
}
|
|
37
64
|
|
|
@@ -56,6 +83,87 @@ export function withSitemapDirective(robots, sitemapUrl) {
|
|
|
56
83
|
return `${robots}${separator}Sitemap: ${sitemapUrl}\n`;
|
|
57
84
|
}
|
|
58
85
|
|
|
86
|
+
// Builds one urlset document.
|
|
87
|
+
function urlSet(entries) {
|
|
88
|
+
const hasAlternates = entries.some((entry) => entry.alternates.length > 0);
|
|
89
|
+
const hasImages = entries.some((entry) => entry.images.length > 0);
|
|
90
|
+
const namespaces = [
|
|
91
|
+
'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"',
|
|
92
|
+
hasAlternates ? 'xmlns:xhtml="http://www.w3.org/1999/xhtml"' : null,
|
|
93
|
+
hasImages ? 'xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"' : null,
|
|
94
|
+
]
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.join(' ');
|
|
97
|
+
const urls = entries
|
|
98
|
+
.map((entry) => {
|
|
99
|
+
const lines = [` <loc>${escapeXml(entry.loc)}</loc>`];
|
|
100
|
+
for (const alternate of entry.alternates) {
|
|
101
|
+
lines.push(
|
|
102
|
+
` <xhtml:link rel="alternate" hreflang="${escapeXml(alternate.hreflang)}" href="${escapeXml(alternate.href)}"/>`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
for (const image of entry.images) {
|
|
106
|
+
lines.push(
|
|
107
|
+
` <image:image>\n <image:loc>${escapeXml(image)}</image:loc>\n </image:image>`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return ` <url>\n${lines.join('\n')}\n </url>`;
|
|
111
|
+
})
|
|
112
|
+
.join('\n');
|
|
113
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset ${namespaces}>\n${urls}\n</urlset>\n`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Resolves hreflang links and removes duplicates.
|
|
117
|
+
function normaliseAlternates(alternates, siteUrl, base) {
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
const result = [];
|
|
120
|
+
for (const alternate of alternates) {
|
|
121
|
+
const href = absoluteSitemapUrl(alternate.href, siteUrl, base);
|
|
122
|
+
if (!href) continue;
|
|
123
|
+
const key = `${alternate.hreflang}|${href}`;
|
|
124
|
+
if (seen.has(key)) continue;
|
|
125
|
+
seen.add(key);
|
|
126
|
+
result.push({ hreflang: alternate.hreflang, href });
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Resolves image URLs to absolute same-origin URLs.
|
|
132
|
+
function normaliseImages(images, siteUrl, base) {
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
const result = [];
|
|
135
|
+
for (const image of images) {
|
|
136
|
+
const absolute = absoluteSitemapUrl(image, siteUrl, base);
|
|
137
|
+
if (!absolute || new URL(absolute).origin !== new URL(siteUrl).origin) continue;
|
|
138
|
+
if (seen.has(absolute)) continue;
|
|
139
|
+
seen.add(absolute);
|
|
140
|
+
result.push(absolute);
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Resolves a site-relative path against the public base, leaving absolute URLs alone.
|
|
146
|
+
function absoluteSitemapUrl(value, siteUrl, base) {
|
|
147
|
+
if (!value) return null;
|
|
148
|
+
const raw = String(value);
|
|
149
|
+
if (/^https?:\/\//i.test(raw)) return raw;
|
|
150
|
+
if (raw.startsWith('//')) return `https:${raw}`;
|
|
151
|
+
const basePrefix = base === '/' ? '' : base.slice(0, -1);
|
|
152
|
+
if (basePrefix && (raw === basePrefix || raw.startsWith(`${basePrefix}/`))) {
|
|
153
|
+
return `${siteUrl}${raw}`;
|
|
154
|
+
}
|
|
155
|
+
const path = raw.startsWith('/') ? raw : `/${raw}`;
|
|
156
|
+
return `${siteUrl}${basePrefix}${path}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Builds the sitemap index that points at the numbered sitemaps.
|
|
160
|
+
function sitemapIndex(entries) {
|
|
161
|
+
const sitemaps = entries
|
|
162
|
+
.map((entry) => ` <sitemap>\n <loc>${escapeXml(entry.loc)}</loc>\n </sitemap>`)
|
|
163
|
+
.join('\n');
|
|
164
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${sitemaps}\n</sitemapindex>\n`;
|
|
165
|
+
}
|
|
166
|
+
|
|
59
167
|
// Escapes the characters that would break a sitemap element.
|
|
60
168
|
function escapeXml(value) {
|
|
61
169
|
return value.replace(/[&<>"']/g, (char) => XML_ESCAPES[char]);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { normaliseRoute } from './routes.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds the public path for a route using the same policy for canonical, social and
|
|
5
|
+
* sitemap URLs. Directory output keeps the trailing slash it is served with; flat
|
|
6
|
+
* output stays extensionless so `/about` remains the URL even though the file is
|
|
7
|
+
* `about.html`. An explicit trailingSlash convention always wins.
|
|
8
|
+
*/
|
|
9
|
+
export function pagePath(route, { flatOutput = false, trailingSlash = 'preserve' } = {}) {
|
|
10
|
+
const normalised = normaliseRoute(route);
|
|
11
|
+
if (normalised === '/') return '/';
|
|
12
|
+
if (trailingSlash === 'always') return `${normalised}/`;
|
|
13
|
+
if (trailingSlash === 'never') return normalised;
|
|
14
|
+
return flatOutput ? normalised : `${normalised}/`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Applies a trailing slash convention to an absolute URL, leaving the root and asset
|
|
19
|
+
* URLs alone. Used for an explicit canonical that is already a full URL.
|
|
20
|
+
*/
|
|
21
|
+
export function withTrailingSlash(url, convention) {
|
|
22
|
+
if (convention !== 'always' && convention !== 'never') return url;
|
|
23
|
+
const [path, query] = url.split('?');
|
|
24
|
+
const isRoot = /^https?:\/\/[^/]+\/?$/i.test(path);
|
|
25
|
+
if (convention === 'always') {
|
|
26
|
+
const next = path.endsWith('/') ? path : `${path}/`;
|
|
27
|
+
return query ? `${next}?${query}` : next;
|
|
28
|
+
}
|
|
29
|
+
if (isRoot) return query ? `${path.replace(/\/$/, '/')}?${query}` : path.replace(/\/?$/, '/');
|
|
30
|
+
const next = path.replace(/\/+$/, '');
|
|
31
|
+
return query ? `${next}?${query}` : next;
|
|
32
|
+
}
|
package/src/core/verify.js
CHANGED
|
@@ -1,21 +1,17 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { blockThirdPartyRequests } from './blocking.js';
|
|
2
|
-
import {
|
|
4
|
+
import { DEFAULTS } from './config.js';
|
|
5
|
+
import { frameworkFromMarkers, isHydrationError } from './frameworks.js';
|
|
6
|
+
import { compareContainers } from './hydration-diff.js';
|
|
7
|
+
import { routeToFile } from './output.js';
|
|
8
|
+
import { isPageEmpty } from './page-content.js';
|
|
9
|
+
import { resolveRouteConfig } from './route-config.js';
|
|
10
|
+
import { normaliseRoute, toServerUrl } from './routes.js';
|
|
3
11
|
import { mapLimit } from './scheduler.js';
|
|
4
12
|
import { settlePage, trackNetwork } from './settle.js';
|
|
5
13
|
|
|
6
|
-
|
|
7
|
-
/hydrat/i,
|
|
8
|
-
/did not match/i,
|
|
9
|
-
/server-rendered HTML/i,
|
|
10
|
-
// React 16 and 17 phrase their mismatch warnings differently and never use the
|
|
11
|
-
// minified codes, which only exist from React 18 on.
|
|
12
|
-
/did not expect server HTML/i,
|
|
13
|
-
/expected server HTML to contain/i,
|
|
14
|
-
/react\.dev\/errors\/(418|419|421|422|423|424|425)/i,
|
|
15
|
-
/Minified React error #(418|419|421|422|423|424|425)\b/i,
|
|
16
|
-
];
|
|
17
|
-
|
|
18
|
-
// Containers used by the common React setups, so the probe can find the prerendered
|
|
14
|
+
// Containers used by the common setups, so the probe can find the prerendered
|
|
19
15
|
// subtree without configuration.
|
|
20
16
|
const CONTAINER_SELECTOR = '#root, #app, #__next';
|
|
21
17
|
|
|
@@ -45,19 +41,9 @@ function bootProbe() {
|
|
|
45
41
|
});
|
|
46
42
|
}
|
|
47
43
|
|
|
48
|
-
/**
|
|
49
|
-
* Classifies a console or page error as a React hydration failure. Exported so the
|
|
50
|
-
* pattern set is testable without a browser. Patterns stay narrow on purpose: a
|
|
51
|
-
* false positive fails a user's build, so unrelated 4xx React codes and generic
|
|
52
|
-
* "does not match" messages are deliberately not treated as hydration errors.
|
|
53
|
-
*/
|
|
54
|
-
export function isHydrationError(message) {
|
|
55
|
-
return HYDRATION_PATTERNS.some((pattern) => pattern.test(message));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
44
|
/**
|
|
59
45
|
* Classifies how a prerendered page booted. `hydrated` means the app adopted the
|
|
60
|
-
* markup, `re-rendered` means it discarded it, `undetected` means no
|
|
46
|
+
* markup, `re-rendered` means it discarded it, `undetected` means no framework root was
|
|
61
47
|
* found on the container, and `unknown` means the prerendered child was never seen.
|
|
62
48
|
*/
|
|
63
49
|
export function classifyBoot({ markupPreserved, frameworkMounted }) {
|
|
@@ -71,16 +57,22 @@ export function classifyBoot({ markupPreserved, frameworkMounted }) {
|
|
|
71
57
|
* hydration errors plus how each route booted. This is the pass that catches markup
|
|
72
58
|
* that only breaks once the app re-attaches, and apps that never attach to it at all.
|
|
73
59
|
*/
|
|
74
|
-
export async function verifyRoutes({ browser, origin, routes, config }) {
|
|
60
|
+
export async function verifyRoutes({ browser, origin, routes, config, outputDir = null }) {
|
|
75
61
|
const { results, errors } = await mapLimit(routes, config.concurrency, (route) =>
|
|
76
|
-
verifyRoute({ browser, origin, route, config }),
|
|
62
|
+
verifyRoute({ browser, origin, route, config: resolveRouteConfig(config, route), outputDir }),
|
|
77
63
|
);
|
|
78
64
|
const verified = results.filter(Boolean);
|
|
79
65
|
const modes = { hydrated: 0, 're-rendered': 0, undetected: 0, unknown: 0 };
|
|
80
|
-
|
|
66
|
+
const frameworks = {};
|
|
67
|
+
for (const entry of verified) {
|
|
68
|
+
modes[entry.mode] += 1;
|
|
69
|
+
const name = entry.framework ?? 'unknown';
|
|
70
|
+
frameworks[name] = (frameworks[name] ?? 0) + 1;
|
|
71
|
+
}
|
|
81
72
|
return {
|
|
82
73
|
routes: verified,
|
|
83
74
|
modes,
|
|
75
|
+
frameworks,
|
|
84
76
|
ok: verified.every((entry) => entry.ok) && errors.length === 0,
|
|
85
77
|
errors: errors.map(({ item, error }) => ({ route: item, message: error.message })),
|
|
86
78
|
};
|
|
@@ -90,26 +82,23 @@ export async function verifyRoutes({ browser, origin, routes, config }) {
|
|
|
90
82
|
* Verifies one route against the same request rules as the render pass, so what is
|
|
91
83
|
* verified matches what was rendered.
|
|
92
84
|
*/
|
|
93
|
-
async function verifyRoute({ browser, origin, route, config }) {
|
|
85
|
+
async function verifyRoute({ browser, origin, route, config, outputDir }) {
|
|
94
86
|
const context = await browser.newContext({
|
|
95
87
|
viewport: config.viewport,
|
|
96
88
|
storageState: config.storageState ?? undefined,
|
|
97
|
-
userAgent:
|
|
89
|
+
userAgent:
|
|
90
|
+
config.userAgent === DEFAULTS.userAgent ? undefined : (config.userAgent ?? undefined),
|
|
98
91
|
ignoreHTTPSErrors: config.ignoreHTTPSErrors,
|
|
92
|
+
reducedMotion: config.freezeAnimations ? 'reduce' : 'no-preference',
|
|
93
|
+
serviceWorkers: 'block',
|
|
99
94
|
});
|
|
100
95
|
const page = await context.newPage();
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
const pageErrors = [];
|
|
104
|
-
// Hydration messages arrive through either channel, so both are classified the same way.
|
|
105
|
-
const collect = (message, target) => {
|
|
106
|
-
if (isHydrationError(message)) hydrationErrors.push(message);
|
|
107
|
-
else target.push(message);
|
|
108
|
-
};
|
|
96
|
+
const consoleMessages = [];
|
|
97
|
+
const pageMessages = [];
|
|
109
98
|
page.on('console', (message) => {
|
|
110
|
-
if (message.type() === 'error')
|
|
99
|
+
if (message.type() === 'error') consoleMessages.push(message.text());
|
|
111
100
|
});
|
|
112
|
-
page.on('pageerror', (error) =>
|
|
101
|
+
page.on('pageerror', (error) => pageMessages.push(error.message));
|
|
113
102
|
|
|
114
103
|
const tracker = trackNetwork(page);
|
|
115
104
|
try {
|
|
@@ -117,32 +106,97 @@ async function verifyRoute({ browser, origin, route, config }) {
|
|
|
117
106
|
await blockThirdPartyRequests(page, origin, config.allowedHosts);
|
|
118
107
|
}
|
|
119
108
|
await page.addInitScript(bootProbe);
|
|
120
|
-
await page.goto(toServerUrl(route, config.base, origin), {
|
|
109
|
+
const response = await page.goto(toServerUrl(route, config.base, origin), {
|
|
121
110
|
waitUntil: 'domcontentloaded',
|
|
122
111
|
timeout: config.timeout,
|
|
123
112
|
});
|
|
113
|
+
const status = response?.status() ?? null;
|
|
124
114
|
await settlePage(page, config, tracker);
|
|
115
|
+
const empty = await isPageEmpty(page);
|
|
116
|
+
const statusCode = await page.evaluate(() => {
|
|
117
|
+
const marker = document.querySelector('meta[name="prerender-status-code"]');
|
|
118
|
+
if (!marker) return null;
|
|
119
|
+
const value = Number.parseInt(marker.getAttribute('content') ?? '', 10);
|
|
120
|
+
return Number.isFinite(value) ? value : null;
|
|
121
|
+
});
|
|
122
|
+
const notFoundRoute = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
|
|
123
|
+
const isNotFoundRoute = notFoundRoute !== null && normaliseRoute(route) === notFoundRoute;
|
|
124
|
+
const notFound = statusCode === 404 || isNotFoundRoute;
|
|
125
125
|
|
|
126
126
|
const boot = await page.evaluate((selector) => {
|
|
127
127
|
const probe = window.__snappyBoot ?? {};
|
|
128
128
|
const container =
|
|
129
129
|
document.querySelector(selector) ?? document.body?.firstElementChild ?? null;
|
|
130
|
-
const
|
|
131
|
-
?
|
|
132
|
-
(
|
|
133
|
-
|
|
134
|
-
|
|
130
|
+
const markers = container
|
|
131
|
+
? {
|
|
132
|
+
react: Object.keys(container).some(
|
|
133
|
+
(key) => key.startsWith('__reactContainer') || key === '_reactRootContainer',
|
|
134
|
+
),
|
|
135
|
+
vue: Boolean(
|
|
136
|
+
container.__vue_app__ ||
|
|
137
|
+
container.__vueParentComponent ||
|
|
138
|
+
container._vnode ||
|
|
139
|
+
container.__vue__,
|
|
140
|
+
),
|
|
141
|
+
preact: Boolean(
|
|
142
|
+
container.__k ||
|
|
143
|
+
container._children ||
|
|
144
|
+
container.firstElementChild?.__k ||
|
|
145
|
+
container.firstElementChild?._children,
|
|
146
|
+
),
|
|
147
|
+
svelte: Boolean(
|
|
148
|
+
container.__svelte_meta || container.querySelector?.('[data-svelte-h]'),
|
|
149
|
+
),
|
|
150
|
+
}
|
|
151
|
+
: {};
|
|
135
152
|
const child = probe.prerenderedChild ?? null;
|
|
136
|
-
return {
|
|
153
|
+
return { markers, markupPreserved: child ? child.isConnected : null };
|
|
137
154
|
}, CONTAINER_SELECTOR);
|
|
155
|
+
const detected = frameworkFromMarkers(boot.markers, config.framework);
|
|
156
|
+
// Classify only after the framework is known, so an explicit `framework` choice and
|
|
157
|
+
// the detected framework select the matching message patterns.
|
|
158
|
+
const hydrationErrors = [];
|
|
159
|
+
const consoleErrors = [];
|
|
160
|
+
const pageErrors = [];
|
|
161
|
+
for (const message of consoleMessages) {
|
|
162
|
+
if (isHydrationError(message, detected.name ?? 'auto')) hydrationErrors.push(message);
|
|
163
|
+
else consoleErrors.push(message);
|
|
164
|
+
}
|
|
165
|
+
for (const message of pageMessages) {
|
|
166
|
+
if (isHydrationError(message, detected.name ?? 'auto')) hydrationErrors.push(message);
|
|
167
|
+
else pageErrors.push(message);
|
|
168
|
+
}
|
|
169
|
+
const mode = classifyBoot({
|
|
170
|
+
markupPreserved: boot.markupPreserved,
|
|
171
|
+
frameworkMounted: detected.mounted,
|
|
172
|
+
});
|
|
173
|
+
let firstMismatch = null;
|
|
174
|
+
if (outputDir && (hydrationErrors.length > 0 || mode === 're-rendered')) {
|
|
175
|
+
const liveHtml = await page.evaluate((selector) => {
|
|
176
|
+
const container = document.querySelector(selector) ?? document.body;
|
|
177
|
+
return container?.outerHTML ?? null;
|
|
178
|
+
}, CONTAINER_SELECTOR);
|
|
179
|
+
const file = path.join(outputDir, routeToFile(route, config));
|
|
180
|
+
const expectedHtml = await fs.readFile(file, 'utf8').catch(() => null);
|
|
181
|
+
if (liveHtml && expectedHtml) firstMismatch = compareContainers(expectedHtml, liveHtml);
|
|
182
|
+
}
|
|
138
183
|
|
|
139
184
|
return {
|
|
140
185
|
route,
|
|
186
|
+
status,
|
|
187
|
+
statusCode,
|
|
188
|
+
notFound,
|
|
189
|
+
framework: detected.name,
|
|
190
|
+
empty,
|
|
141
191
|
hydrationErrors,
|
|
142
192
|
consoleErrors,
|
|
143
193
|
pageErrors,
|
|
144
|
-
ok:
|
|
145
|
-
|
|
194
|
+
ok:
|
|
195
|
+
hydrationErrors.length === 0 &&
|
|
196
|
+
(status === null || status < 400 || (isNotFoundRoute && status === 404)) &&
|
|
197
|
+
!empty,
|
|
198
|
+
mode,
|
|
199
|
+
firstMismatch,
|
|
146
200
|
markupPreserved: boot.markupPreserved,
|
|
147
201
|
};
|
|
148
202
|
} finally {
|