@taprootio/site-authoring 0.8.4 → 0.8.6
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/README.md +85 -3
- package/examples/riverbend-wellness/settings/taproot-styles.json +2 -188
- package/package.json +1 -1
- package/src/api.js +71 -2
- package/src/cli.js +126 -5
- package/src/constants.js +8 -1
- package/src/delivery-browser.js +276 -0
- package/src/delivery-check.js +821 -0
- package/src/errors.js +12 -1
- package/src/presentation-reference.js +98 -10
- package/src/reference-help.js +118 -1
- package/src/settings-catalog.js +14 -1
- package/src/staging-check.js +9 -0
- package/src/theme-projection.js +213 -0
- package/src/theme-validation.js +64 -4
- package/src/transport.js +20 -2
- package/src/verbs/delivery-check.js +185 -0
- package/src/verbs/deploy.js +88 -3
- package/src/verbs/index.js +6 -0
- package/src/verbs/preview-revoke.js +2 -2
- package/src/verbs/staging-review.js +29 -0
- package/src/verbs/theme-push.js +3 -3
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
import { sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
|
|
2
|
+
import { withholdCredentialLocation } from "./staging-check.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Visitor-facing delivery verification (TR00824).
|
|
6
|
+
*
|
|
7
|
+
* A completed deployment job proves that Taproot wrote what it meant to
|
|
8
|
+
* write. It does not prove that a visitor receives it: the edge may still
|
|
9
|
+
* serve the previous route, an asset may 404 behind a 200 HTML error page, and
|
|
10
|
+
* the shared runtime a browser loads comes from a mutable major pointer that
|
|
11
|
+
* can lag or be cached. This module checks the public HTTP surface of a
|
|
12
|
+
* deployment target with explicit bounds and reports each dimension on its
|
|
13
|
+
* own: HTTP delivery is checked here, browser-observed behaviour is the
|
|
14
|
+
* optional probe in `delivery-browser.js`, and a dimension nobody checked is
|
|
15
|
+
* reported as unchecked, never as passed.
|
|
16
|
+
*
|
|
17
|
+
* Everything here is read-only. It never purges, republishes, or follows a
|
|
18
|
+
* link off the site's own origin, and it never sends the staging credential
|
|
19
|
+
* anywhere but the staging origin it was minted for. HTML is read by a small
|
|
20
|
+
* start-tag tokenizer that follows the browser's rules for comments, quoted
|
|
21
|
+
* attribute values and raw-text elements, not by a full parser: a reference
|
|
22
|
+
* a browser would resolve through tree construction quirks may be missed,
|
|
23
|
+
* never invented.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const DELIVERY_LIMITS = Object.freeze({
|
|
27
|
+
routes: 20,
|
|
28
|
+
images: 6,
|
|
29
|
+
links: 40,
|
|
30
|
+
capabilities: 12,
|
|
31
|
+
localReferences: 20,
|
|
32
|
+
concurrency: 4,
|
|
33
|
+
attempts: 2,
|
|
34
|
+
retryMilliseconds: 1_000,
|
|
35
|
+
requestMilliseconds: 15_000,
|
|
36
|
+
htmlBytes: 2 * 1024 * 1024,
|
|
37
|
+
manifestBytes: 256 * 1024,
|
|
38
|
+
propagationWaitMaximumSeconds: 120,
|
|
39
|
+
failures: 40,
|
|
40
|
+
reportBytes: 24_000,
|
|
41
|
+
outputBytes: 48 * 1024,
|
|
42
|
+
urlScalars: 512,
|
|
43
|
+
tags: 50_000,
|
|
44
|
+
attributesPerTag: 64,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const HTML_TYPES = /^text\/html\b/iu;
|
|
48
|
+
const JSON_TYPES = /^application\/(?:json|manifest\+json)\b/iu;
|
|
49
|
+
const SCRIPT_TYPES = /^(?:text|application)\/(?:javascript|ecmascript|x-javascript)\b/iu;
|
|
50
|
+
const IMAGE_TYPES = /^image\//iu;
|
|
51
|
+
const LOCAL_HOST = /^(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0|[^/]*\.(?:test|local|localhost|internal))(?::\d+)?$/iu;
|
|
52
|
+
// Untrusted names index these tables, so they carry no prototype: an element
|
|
53
|
+
// called <constructor> or an entity &constructor; is ordinary markup.
|
|
54
|
+
const ENTITIES = new Map([["amp", "&"], ["lt", "<"], ["gt", ">"], ["quot", "\""], ["apos", "'"], ["#39", "'"]]);
|
|
55
|
+
// Raw-text elements: their bodies are text to a browser, never markup.
|
|
56
|
+
const RAW_TEXT_END = new Map([
|
|
57
|
+
["script", /<\/script(?=[\s/>])/giu],
|
|
58
|
+
["style", /<\/style(?=[\s/>])/giu],
|
|
59
|
+
["textarea", /<\/textarea(?=[\s/>])/giu],
|
|
60
|
+
["title", /<\/title(?=[\s/>])/giu],
|
|
61
|
+
]);
|
|
62
|
+
const TAG_NAME = /^[a-z][^\s/>]*/iu;
|
|
63
|
+
// One attribute at a time, in document order, so a quoted value that happens
|
|
64
|
+
// to contain attribute-like text is consumed as that value and never read as
|
|
65
|
+
// an attribute of its own.
|
|
66
|
+
const ATTRIBUTE = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/gu;
|
|
67
|
+
const CUSTOM_ELEMENT_NAME = /^[a-z][a-z0-9._-]{0,63}$/u;
|
|
68
|
+
|
|
69
|
+
function decodeEntities(value) {
|
|
70
|
+
return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/giu, (whole, entity) => {
|
|
71
|
+
let codePoint;
|
|
72
|
+
if (entity.startsWith("#x")) codePoint = Number.parseInt(entity.slice(2), 16);
|
|
73
|
+
else if (entity.startsWith("#")) codePoint = Number(entity.slice(1));
|
|
74
|
+
else return ENTITIES.get(entity.toLowerCase()) ?? whole;
|
|
75
|
+
// An out-of-range or surrogate reference decodes to the replacement
|
|
76
|
+
// character, as a browser does, never to an exception.
|
|
77
|
+
return Number.isInteger(codePoint) && codePoint > 0 && codePoint <= 0x10ffff && !(codePoint >= 0xd800 && codePoint <= 0xdfff)
|
|
78
|
+
? String.fromCodePoint(codePoint)
|
|
79
|
+
: "\ufffd";
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A start tag's attributes, first occurrence of a name winning as in a browser. */
|
|
84
|
+
function parseAttributes(text) {
|
|
85
|
+
const attributes = new Map();
|
|
86
|
+
ATTRIBUTE.lastIndex = 0;
|
|
87
|
+
let match;
|
|
88
|
+
while ((match = ATTRIBUTE.exec(text)) !== null && attributes.size < DELIVERY_LIMITS.attributesPerTag) {
|
|
89
|
+
const name = match[1].toLowerCase();
|
|
90
|
+
if (attributes.has(name)) continue;
|
|
91
|
+
const decoded = decodeEntities(match[2] ?? match[3] ?? match[4] ?? "").trim();
|
|
92
|
+
attributes.set(name, decoded === "" ? undefined : decoded);
|
|
93
|
+
}
|
|
94
|
+
return attributes;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The live start tags of a document in order, each with its attributes and,
|
|
99
|
+
* for a raw-text element, its body. Comments are skipped, a quoted attribute
|
|
100
|
+
* value may hold any character including `>`, an unterminated tag or comment
|
|
101
|
+
* ends the live document, and script, style, textarea and title bodies are
|
|
102
|
+
* attached to their element rather than scanned. Used for reference and
|
|
103
|
+
* bootstrap discovery alike, so both read the same document.
|
|
104
|
+
*/
|
|
105
|
+
export function tokenizeMarkup(html) {
|
|
106
|
+
const tags = [];
|
|
107
|
+
const length = html.length;
|
|
108
|
+
let index = 0;
|
|
109
|
+
while (index < length && tags.length < DELIVERY_LIMITS.tags) {
|
|
110
|
+
const open = html.indexOf("<", index);
|
|
111
|
+
if (open < 0) break;
|
|
112
|
+
if (html.startsWith("<!--", open)) {
|
|
113
|
+
const close = html.indexOf("-->", open + 4);
|
|
114
|
+
if (close < 0) break;
|
|
115
|
+
index = close + 3;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const nameMatch = TAG_NAME.exec(html.slice(open + 1, open + 65));
|
|
119
|
+
if (!nameMatch) {
|
|
120
|
+
index = open + 1;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const name = nameMatch[0].toLowerCase();
|
|
124
|
+
let cursor = open + 1 + nameMatch[0].length;
|
|
125
|
+
let terminated = false;
|
|
126
|
+
while (cursor < length) {
|
|
127
|
+
const character = html[cursor];
|
|
128
|
+
if (character === ">") {
|
|
129
|
+
terminated = true;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
if (character === "=") {
|
|
133
|
+
let next = cursor + 1;
|
|
134
|
+
while (next < length && /\s/u.test(html[next])) next += 1;
|
|
135
|
+
const quote = html[next];
|
|
136
|
+
if (quote === "\"" || quote === "'") {
|
|
137
|
+
const close = html.indexOf(quote, next + 1);
|
|
138
|
+
if (close < 0) break;
|
|
139
|
+
cursor = close + 1;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
cursor = next;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
cursor += 1;
|
|
146
|
+
}
|
|
147
|
+
if (!terminated) break;
|
|
148
|
+
const tag = { name, attributes: parseAttributes(html.slice(open + 1 + nameMatch[0].length, cursor)), text: "" };
|
|
149
|
+
index = cursor + 1;
|
|
150
|
+
const rawEnd = RAW_TEXT_END.get(name);
|
|
151
|
+
if (rawEnd) {
|
|
152
|
+
rawEnd.lastIndex = index;
|
|
153
|
+
const end = rawEnd.exec(html);
|
|
154
|
+
if (!end) {
|
|
155
|
+
tag.text = html.slice(index);
|
|
156
|
+
index = length;
|
|
157
|
+
} else {
|
|
158
|
+
tag.text = html.slice(index, end.index);
|
|
159
|
+
const close = html.indexOf(">", end.index);
|
|
160
|
+
index = close < 0 ? length : close + 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
tags.push(tag);
|
|
164
|
+
}
|
|
165
|
+
return tags;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function typeOf(response) {
|
|
169
|
+
return (response.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function expectationFor(kind) {
|
|
173
|
+
if (kind === "route" || kind === "link") return { name: "text/html", test: HTML_TYPES };
|
|
174
|
+
if (kind === "manifest") return { name: "application/json", test: JSON_TYPES };
|
|
175
|
+
if (kind === "site-bundle" || kind === "runtime-entry" || kind === "module" || kind === "capability") {
|
|
176
|
+
return { name: "javascript", test: SCRIPT_TYPES };
|
|
177
|
+
}
|
|
178
|
+
return { name: "image/*", test: IMAGE_TYPES };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function compareVersions(left, right) {
|
|
182
|
+
const parse = (value) => {
|
|
183
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/u.exec(value ?? "");
|
|
184
|
+
return match ? match.slice(1).map(Number) : undefined;
|
|
185
|
+
};
|
|
186
|
+
const a = parse(left);
|
|
187
|
+
const b = parse(right);
|
|
188
|
+
if (!a || !b) return undefined;
|
|
189
|
+
for (let index = 0; index < 3; index += 1) {
|
|
190
|
+
if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1;
|
|
191
|
+
}
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The runtime bootstrap a rendered page declares, or undefined when absent. */
|
|
196
|
+
export function parseBootstrap(html) {
|
|
197
|
+
const script = tokenizeMarkup(html).find((tag) => tag.name === "script" && tag.attributes.get("id") === "taproot-runtime-bootstrap");
|
|
198
|
+
if (!script) return undefined;
|
|
199
|
+
let parsed;
|
|
200
|
+
try {
|
|
201
|
+
parsed = JSON.parse(script.text);
|
|
202
|
+
} catch {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
206
|
+
const text = (value) => (typeof value === "string" ? [...value].slice(0, DELIVERY_LIMITS.urlScalars * 4).join("") : "");
|
|
207
|
+
// Capability tags are custom-element names; anything else is not a
|
|
208
|
+
// capability this page could declare and is counted, never carried.
|
|
209
|
+
const declared = Array.isArray(parsed.runtimeCapabilities) ? parsed.runtimeCapabilities : [];
|
|
210
|
+
const runtimeCapabilities = declared.filter((value) => typeof value === "string" && CUSTOM_ELEMENT_NAME.test(value)).slice(0, 64);
|
|
211
|
+
return {
|
|
212
|
+
runtimeMajorVersion: text(parsed.runtimeMajorVersion).slice(0, 16),
|
|
213
|
+
runtimeManifestUrl: text(parsed.runtimeManifestUrl),
|
|
214
|
+
fallbackRuntimeManifestUrl: text(parsed.fallbackRuntimeManifestUrl),
|
|
215
|
+
siteBundleUrl: text(parsed.siteBundleUrl),
|
|
216
|
+
runtimeCapabilities,
|
|
217
|
+
...(declared.length > runtimeCapabilities.length ? { capabilitiesDropped: declared.length - runtimeCapabilities.length } : {}),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A reference resolved against its page, or undefined when it is not an
|
|
223
|
+
* http(s) URL, carries credentials, or uses a spelling (a backslash, a
|
|
224
|
+
* network-path `//host`) that a later reparse could send elsewhere.
|
|
225
|
+
*/
|
|
226
|
+
function resolveReference(value, base) {
|
|
227
|
+
if (typeof value !== "string" || value.includes("\\") || /[-]/u.test(value)) return undefined;
|
|
228
|
+
let url;
|
|
229
|
+
try {
|
|
230
|
+
url = new URL(value, base);
|
|
231
|
+
} catch {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password) return undefined;
|
|
235
|
+
if (url.pathname.startsWith("//")) return undefined;
|
|
236
|
+
return url;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isLocalOnly(url) {
|
|
240
|
+
return LOCAL_HOST.test(url.host);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The references a rendered page makes that a visitor's browser would fetch
|
|
245
|
+
* next, bounded, and separated into the site's own routes, its assets, and
|
|
246
|
+
* anything that points at a developer-only host. Only same-origin references
|
|
247
|
+
* are kept, as validated absolute URLs: an authored reference to any other
|
|
248
|
+
* host is content, not delivery, and the CLI never probes a host the site did
|
|
249
|
+
* not publish to.
|
|
250
|
+
*/
|
|
251
|
+
export function discoverReferences(markup, pageUrl) {
|
|
252
|
+
const page = new URL(pageUrl);
|
|
253
|
+
const favicon = [];
|
|
254
|
+
const modules = [];
|
|
255
|
+
const images = [];
|
|
256
|
+
const internalLinks = new Set();
|
|
257
|
+
const localOnly = new Set();
|
|
258
|
+
const note = (url, into) => {
|
|
259
|
+
if (!url) return;
|
|
260
|
+
if (url.origin === page.origin) into?.(url);
|
|
261
|
+
else if (isLocalOnly(url)) localOnly.add(url.href);
|
|
262
|
+
};
|
|
263
|
+
for (const tag of tokenizeMarkup(markup)) {
|
|
264
|
+
if (tag.name === "link") {
|
|
265
|
+
const rel = (tag.attributes.get("rel") ?? "").toLowerCase().split(/\s+/u);
|
|
266
|
+
const url = resolveReference(tag.attributes.get("href"), page);
|
|
267
|
+
if (rel.includes("icon") || rel.includes("apple-touch-icon")) note(url, (value) => favicon.push(value.href));
|
|
268
|
+
else if (rel.includes("modulepreload")) note(url, (value) => modules.push(value.href));
|
|
269
|
+
} else if (tag.name === "img" || tag.name === "esp-image") {
|
|
270
|
+
note(resolveReference(tag.attributes.get("src"), page), (value) => images.push(value.href));
|
|
271
|
+
} else if (tag.name === "a") {
|
|
272
|
+
const url = resolveReference(tag.attributes.get("href"), page);
|
|
273
|
+
if (!url) continue;
|
|
274
|
+
if (url.origin === page.origin) {
|
|
275
|
+
if (!url.pathname.startsWith("/_") && !url.search) {
|
|
276
|
+
url.hash = "";
|
|
277
|
+
internalLinks.add(url.href);
|
|
278
|
+
}
|
|
279
|
+
} else if (isLocalOnly(url)) localOnly.add(url.href);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
favicon: [...new Set(favicon)].slice(0, 2),
|
|
284
|
+
modules: [...new Set(modules)].slice(0, 4),
|
|
285
|
+
images: [...new Set(images)].slice(0, DELIVERY_LIMITS.images),
|
|
286
|
+
internalLinks: [...internalLinks].slice(0, DELIVERY_LIMITS.links),
|
|
287
|
+
localOnly: [...localOnly].slice(0, DELIVERY_LIMITS.localReferences),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function describeFailure(row) {
|
|
292
|
+
if (row.error) return row.error;
|
|
293
|
+
if (row.status === 0) return "unreachable";
|
|
294
|
+
if (row.status >= 300 && row.status < 400) return "redirected";
|
|
295
|
+
if (row.status !== 200) return `http_${row.status}`;
|
|
296
|
+
if (!row.contentTypeMatches) return "wrong_content_type";
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Read a body up to `maximumBytes`, streaming so a missing or understated
|
|
302
|
+
* Content-Length cannot make the CLI buffer more than the bound. Returns
|
|
303
|
+
* undefined once the bound is exceeded, after cancelling the stream.
|
|
304
|
+
*/
|
|
305
|
+
async function readBounded(response, maximumBytes) {
|
|
306
|
+
const declared = Number(response.headers.get("content-length") ?? "0");
|
|
307
|
+
if (declared > maximumBytes) {
|
|
308
|
+
await response.body?.cancel().catch(() => {});
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
if (!response.body) return "";
|
|
312
|
+
const reader = response.body.getReader();
|
|
313
|
+
const chunks = [];
|
|
314
|
+
let total = 0;
|
|
315
|
+
try {
|
|
316
|
+
for (;;) {
|
|
317
|
+
const { done, value } = await reader.read();
|
|
318
|
+
if (done) break;
|
|
319
|
+
total += value.byteLength;
|
|
320
|
+
if (total > maximumBytes) {
|
|
321
|
+
await reader.cancel().catch(() => {});
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
chunks.push(value);
|
|
325
|
+
}
|
|
326
|
+
} finally {
|
|
327
|
+
reader.releaseLock?.();
|
|
328
|
+
}
|
|
329
|
+
return new TextDecoder().decode(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** A URL-ish scalar as evidence: credentials withheld, controls removed, bounded. */
|
|
333
|
+
function bounded(value) {
|
|
334
|
+
return [...withholdCredentialLocation(typeof value === "string" ? value : "")].slice(0, DELIVERY_LIMITS.urlScalars).join("");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* One bounded, retried GET against the delivery target. Redirects are never
|
|
339
|
+
* followed: an authored route that answers a redirect is not delivered, and a
|
|
340
|
+
* redirect could otherwise carry the staging cookie to another host. The body
|
|
341
|
+
* read is part of the attempt, so a server that stalls or resets after its
|
|
342
|
+
* headers is retried and recorded, never thrown.
|
|
343
|
+
*/
|
|
344
|
+
async function probe(context, url, kind, { readBody = false, maximumBytes = DELIVERY_LIMITS.manifestBytes } = {}) {
|
|
345
|
+
const expectation = expectationFor(kind);
|
|
346
|
+
const origin = new URL(url).origin;
|
|
347
|
+
if (!context.allowedOrigins.has(origin)) {
|
|
348
|
+
return {
|
|
349
|
+
row: { url: bounded(url), kind, status: 0, contentType: "", ok: false, attempts: 0, failure: "origin_not_allowed", expectedContentType: expectation.name },
|
|
350
|
+
body: "",
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
let last;
|
|
354
|
+
for (let attempt = 1; attempt <= DELIVERY_LIMITS.attempts; attempt += 1) {
|
|
355
|
+
const row = { url: bounded(url), kind, status: 0, contentType: "", attempts: attempt, expectedContentType: expectation.name };
|
|
356
|
+
let body = "";
|
|
357
|
+
try {
|
|
358
|
+
const response = await context.fetch(url, {
|
|
359
|
+
method: "GET",
|
|
360
|
+
redirect: "manual",
|
|
361
|
+
signal: context.timeoutSignal(DELIVERY_LIMITS.requestMilliseconds),
|
|
362
|
+
headers: {
|
|
363
|
+
accept: kind === "route" || kind === "link" ? "text/html" : "*/*",
|
|
364
|
+
"user-agent": "taproot-site/delivery-check",
|
|
365
|
+
...(context.cookie && origin === context.cookieOrigin ? { cookie: context.cookie } : {}),
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
row.status = response.status;
|
|
369
|
+
row.contentType = typeOf(response);
|
|
370
|
+
row.contentTypeMatches = expectation.test.test(row.contentType);
|
|
371
|
+
row.cacheControl = bounded(response.headers.get("cache-control") ?? "");
|
|
372
|
+
const location = response.headers.get("location");
|
|
373
|
+
// A redirect from the staging edge can carry a handoff or cookie in its
|
|
374
|
+
// Location; the same withholding staging-check applies keeps it out of
|
|
375
|
+
// the report and GITHUB_OUTPUT.
|
|
376
|
+
if (location && response.status >= 300 && response.status < 400) row.location = bounded(withholdCredentialLocation(location));
|
|
377
|
+
if (readBody && response.status === 200) {
|
|
378
|
+
const read = await readBounded(response, maximumBytes);
|
|
379
|
+
if (read === undefined) row.error = "body_too_large";
|
|
380
|
+
else body = read;
|
|
381
|
+
} else {
|
|
382
|
+
await response.body?.cancel().catch(() => {});
|
|
383
|
+
}
|
|
384
|
+
} catch {
|
|
385
|
+
row.error = row.status === 0 ? "request_failed" : "read_failed";
|
|
386
|
+
}
|
|
387
|
+
row.ok = row.status === 200 && row.contentTypeMatches === true && !row.error;
|
|
388
|
+
const failure = describeFailure(row);
|
|
389
|
+
if (failure) row.failure = failure;
|
|
390
|
+
last = { row, body };
|
|
391
|
+
const retryable = row.error === "request_failed" || row.error === "read_failed" || row.status >= 500;
|
|
392
|
+
if (row.ok || !retryable || attempt === DELIVERY_LIMITS.attempts) break;
|
|
393
|
+
await context.sleep(DELIVERY_LIMITS.retryMilliseconds);
|
|
394
|
+
}
|
|
395
|
+
return last;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function mapBounded(items, worker) {
|
|
399
|
+
const results = new Array(items.length);
|
|
400
|
+
let next = 0;
|
|
401
|
+
await Promise.all(Array.from({ length: Math.min(DELIVERY_LIMITS.concurrency, items.length) }, async () => {
|
|
402
|
+
while (next < items.length) {
|
|
403
|
+
const index = next++;
|
|
404
|
+
results[index] = await worker(items[index], index);
|
|
405
|
+
}
|
|
406
|
+
}));
|
|
407
|
+
return results;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function requireHttpsOrigin(value, field) {
|
|
411
|
+
let url;
|
|
412
|
+
try {
|
|
413
|
+
url = new URL(value);
|
|
414
|
+
} catch {
|
|
415
|
+
throw new SiteAuthoringError("delivery.target_invalid", "The delivery target must be an absolute https origin.", { field });
|
|
416
|
+
}
|
|
417
|
+
if (
|
|
418
|
+
url.protocol !== "https:" || url.username || url.password || url.search || url.hash
|
|
419
|
+
|| (url.pathname !== "/" && url.pathname !== "")
|
|
420
|
+
) {
|
|
421
|
+
throw new SiteAuthoringError(
|
|
422
|
+
"delivery.target_invalid",
|
|
423
|
+
"The delivery target must be an https origin with no path, query, credentials or fragment.",
|
|
424
|
+
{ field },
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
return `${url.origin}/`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** A site-relative route as the visitor would request it, or undefined when its spelling could leave the origin. */
|
|
431
|
+
function routeUrl(path, origin) {
|
|
432
|
+
if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//") || path.includes("\\")) return undefined;
|
|
433
|
+
const url = resolveReference(path, origin);
|
|
434
|
+
return url && url.origin === new URL(origin).origin && url.pathname === path ? url.href : undefined;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function strip(row) {
|
|
438
|
+
const { contentTypeMatches, ...rest } = row;
|
|
439
|
+
return rest;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** One complete observation of the target. Run once, and once more after a propagation wait. */
|
|
443
|
+
async function observe(context, origin, sampledRoutes) {
|
|
444
|
+
const routeRows = [];
|
|
445
|
+
const assetRows = [];
|
|
446
|
+
const assetUrls = new Set();
|
|
447
|
+
let imageCount = 0;
|
|
448
|
+
const addAsset = (url, kind) => {
|
|
449
|
+
if (assetUrls.has(url)) return false;
|
|
450
|
+
assetUrls.add(url);
|
|
451
|
+
assetRows.push({ url, kind });
|
|
452
|
+
return true;
|
|
453
|
+
};
|
|
454
|
+
const links = new Set();
|
|
455
|
+
const localOnly = new Set();
|
|
456
|
+
let bootstrap;
|
|
457
|
+
|
|
458
|
+
const routeResults = await mapBounded(sampledRoutes, async (path) => {
|
|
459
|
+
const url = routeUrl(path, origin);
|
|
460
|
+
if (!url) {
|
|
461
|
+
return {
|
|
462
|
+
path,
|
|
463
|
+
row: { url: bounded(path), kind: "route", status: 0, ok: false, attempts: 0, failure: "invalid_route", expectedContentType: "text/html" },
|
|
464
|
+
body: "",
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
const { row, body } = await probe(context, url, "route", { readBody: true, maximumBytes: DELIVERY_LIMITS.htmlBytes });
|
|
468
|
+
return { path, row, body };
|
|
469
|
+
});
|
|
470
|
+
for (const { path, row, body } of routeResults) {
|
|
471
|
+
routeRows.push({ path: bounded(path), ...row });
|
|
472
|
+
if (!row.ok) continue;
|
|
473
|
+
const references = discoverReferences(body, row.url);
|
|
474
|
+
for (const link of references.internalLinks) links.add(link);
|
|
475
|
+
for (const reference of references.localOnly) localOnly.add(reference);
|
|
476
|
+
if (path === "/") {
|
|
477
|
+
bootstrap = parseBootstrap(body);
|
|
478
|
+
for (const href of references.favicon) addAsset(href, "favicon");
|
|
479
|
+
for (const href of references.modules) addAsset(href, "module");
|
|
480
|
+
}
|
|
481
|
+
// Images are a sample, bounded across every route, not per page.
|
|
482
|
+
for (const href of references.images) {
|
|
483
|
+
if (imageCount >= DELIVERY_LIMITS.images) break;
|
|
484
|
+
if (addAsset(href, "image")) imageCount += 1;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// The runtime the page declares: the mutable major pointer a browser
|
|
489
|
+
// resolves, the immutable fallback copy shipped with the site, the entry
|
|
490
|
+
// module the pointer names and the capability modules it maps. The
|
|
491
|
+
// bootstrap is generator-authored, so its origins are trusted here and
|
|
492
|
+
// admitted for the runtime probes only.
|
|
493
|
+
const runtime = {
|
|
494
|
+
declared: bootstrap !== undefined,
|
|
495
|
+
expectedMajorVersion: bootstrap?.runtimeMajorVersion ?? "",
|
|
496
|
+
capabilities: bootstrap?.runtimeCapabilities ?? [],
|
|
497
|
+
...(bootstrap?.capabilitiesDropped ? { capabilitiesDropped: bootstrap.capabilitiesDropped } : {}),
|
|
498
|
+
};
|
|
499
|
+
const admit = (value) => {
|
|
500
|
+
const url = value ? resolveReference(value, origin) : undefined;
|
|
501
|
+
if (url) context.allowedOrigins.add(url.origin);
|
|
502
|
+
return url;
|
|
503
|
+
};
|
|
504
|
+
if (bootstrap?.siteBundleUrl) {
|
|
505
|
+
const bundle = admit(bootstrap.siteBundleUrl);
|
|
506
|
+
// The bundle is also module-preloaded; report it once, as the site bundle.
|
|
507
|
+
if (bundle) {
|
|
508
|
+
const preloaded = assetRows.findIndex((row) => row.url === bundle.href);
|
|
509
|
+
if (preloaded >= 0) assetRows[preloaded].kind = "site-bundle";
|
|
510
|
+
else addAsset(bundle.href, "site-bundle");
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const manifests = [];
|
|
514
|
+
for (const [name, value] of [["majorPointer", bootstrap?.runtimeManifestUrl], ["fallback", bootstrap?.fallbackRuntimeManifestUrl]]) {
|
|
515
|
+
const url = admit(value);
|
|
516
|
+
if (url) manifests.push({ name, url: url.href });
|
|
517
|
+
else runtime[name] = { url: "", ok: false, failure: "undeclared" };
|
|
518
|
+
}
|
|
519
|
+
const manifestResults = await mapBounded(manifests, async ({ name, url }) => {
|
|
520
|
+
const { row, body } = await probe(context, url, "manifest", { readBody: true });
|
|
521
|
+
let parsed;
|
|
522
|
+
if (row.ok) {
|
|
523
|
+
try {
|
|
524
|
+
parsed = JSON.parse(body);
|
|
525
|
+
} catch {
|
|
526
|
+
row.ok = false;
|
|
527
|
+
row.failure = "invalid_json";
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return { name, row, parsed, url };
|
|
531
|
+
});
|
|
532
|
+
const capabilityUrls = [];
|
|
533
|
+
for (const { name, row, parsed, url } of manifestResults) {
|
|
534
|
+
const capabilities = parsed?.capabilities && typeof parsed.capabilities === "object" && !Array.isArray(parsed.capabilities)
|
|
535
|
+
? parsed.capabilities
|
|
536
|
+
: {};
|
|
537
|
+
const declared = runtime.capabilities.filter((tag) => typeof capabilities[tag] === "string" && capabilities[tag] !== "");
|
|
538
|
+
// Declared is not resolvable: a module URL that is invalid or points off
|
|
539
|
+
// the runtime's origin is a failed observation, not a silent omission.
|
|
540
|
+
const outcomes = declared.map((tag) => {
|
|
541
|
+
const module = resolveReference(capabilities[tag], url);
|
|
542
|
+
if (!module) return { tag, failure: "invalid_url" };
|
|
543
|
+
if (!context.allowedOrigins.has(module.origin)) return { tag, url: module.href, failure: "origin_not_allowed" };
|
|
544
|
+
return { tag, url: module.href };
|
|
545
|
+
});
|
|
546
|
+
const resolvable = outcomes.filter((outcome) => outcome.failure === undefined);
|
|
547
|
+
runtime[name] = {
|
|
548
|
+
url: bounded(row.url),
|
|
549
|
+
status: row.status,
|
|
550
|
+
ok: row.ok,
|
|
551
|
+
...(row.failure ? { failure: row.failure } : {}),
|
|
552
|
+
version: typeof parsed?.version === "string" ? bounded(parsed.version) : "",
|
|
553
|
+
majorVersion: parsed?.majorVersion === undefined ? "" : bounded(String(parsed.majorVersion)),
|
|
554
|
+
entry: typeof parsed?.entry === "string" ? bounded(parsed.entry) : "",
|
|
555
|
+
cacheControl: row.cacheControl ?? "",
|
|
556
|
+
capabilities: Object.keys(capabilities).length,
|
|
557
|
+
declaredCapabilities: declared.length,
|
|
558
|
+
resolvableCapabilities: resolvable.length,
|
|
559
|
+
};
|
|
560
|
+
if (name === "majorPointer" && row.ok) {
|
|
561
|
+
for (const outcome of outcomes) {
|
|
562
|
+
if (outcome.failure) capabilityUrls.push({ tag: outcome.tag, url: outcome.url ?? "", failure: outcome.failure });
|
|
563
|
+
else if (capabilityUrls.filter((entry) => !entry.failure).length < DELIVERY_LIMITS.capabilities) capabilityUrls.push(outcome);
|
|
564
|
+
else capabilityUrls.push({ tag: outcome.tag, url: outcome.url, unchecked: true });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const pointer = runtime.majorPointer;
|
|
569
|
+
if (pointer?.ok && pointer.entry) {
|
|
570
|
+
const entry = resolveReference(pointer.entry, pointer.url);
|
|
571
|
+
if (entry && context.allowedOrigins.has(entry.origin)) {
|
|
572
|
+
const { row } = await probe(context, entry.href, "runtime-entry");
|
|
573
|
+
runtime.entry = { url: row.url, status: row.status, ok: row.ok, ...(row.failure ? { failure: row.failure } : {}) };
|
|
574
|
+
} else {
|
|
575
|
+
runtime.entry = { url: bounded(pointer.entry), ok: false, failure: "origin_not_allowed" };
|
|
576
|
+
}
|
|
577
|
+
} else {
|
|
578
|
+
runtime.entry = { url: "", ok: false, failure: "pointer_unavailable" };
|
|
579
|
+
}
|
|
580
|
+
const capabilityResults = await mapBounded(capabilityUrls, async ({ tag, url, failure, unchecked }) => {
|
|
581
|
+
if (failure) return { tag: bounded(tag), url: bounded(url), kind: "capability", status: 0, ok: false, attempts: 0, failure };
|
|
582
|
+
if (unchecked) return { tag: bounded(tag), url: bounded(url), kind: "capability", ok: false, unchecked: true };
|
|
583
|
+
const { row } = await probe(context, url, "capability");
|
|
584
|
+
return { tag: bounded(tag), ...strip(row) };
|
|
585
|
+
});
|
|
586
|
+
const uncheckedCapabilities = capabilityResults.filter((row) => row.unchecked).length;
|
|
587
|
+
const invalidCapabilities = capabilityResults.filter((row) => row.attempts === 0).length;
|
|
588
|
+
runtime.capabilityModules = {
|
|
589
|
+
checked: capabilityResults.length - uncheckedCapabilities - invalidCapabilities,
|
|
590
|
+
invalid: invalidCapabilities,
|
|
591
|
+
failed: capabilityResults.filter((row) => !row.ok && !row.unchecked).length,
|
|
592
|
+
unchecked: uncheckedCapabilities,
|
|
593
|
+
items: capabilityResults.filter((row) => !row.ok && !row.unchecked).slice(0, DELIVERY_LIMITS.capabilities),
|
|
594
|
+
};
|
|
595
|
+
runtime.majorStreamMatches = Boolean(pointer?.ok) && pointer.majorVersion === runtime.expectedMajorVersion;
|
|
596
|
+
runtime.pointerBehindFallback = Boolean(pointer?.ok && runtime.fallback?.ok)
|
|
597
|
+
&& compareVersions(pointer.version, runtime.fallback.version) === -1;
|
|
598
|
+
runtime.capabilitiesResolvable = Boolean(pointer?.ok)
|
|
599
|
+
&& pointer.resolvableCapabilities === runtime.capabilities.length
|
|
600
|
+
&& runtime.capabilityModules.failed === 0;
|
|
601
|
+
runtime.compatible = runtime.declared && runtime.majorStreamMatches && runtime.entry.ok && runtime.capabilitiesResolvable;
|
|
602
|
+
|
|
603
|
+
const assetResults = await mapBounded(assetRows, async ({ url, kind }) => {
|
|
604
|
+
const { row } = await probe(context, url, kind);
|
|
605
|
+
return { kind, ...row };
|
|
606
|
+
});
|
|
607
|
+
const sampledSet = new Set(sampledRoutes.map((path) => routeUrl(path, origin)).filter(Boolean));
|
|
608
|
+
const linkUrls = [...links].filter((href) => !sampledSet.has(href)).slice(0, DELIVERY_LIMITS.links);
|
|
609
|
+
const linkResults = await mapBounded(linkUrls, async (href) => {
|
|
610
|
+
const { row } = await probe(context, href, "link");
|
|
611
|
+
return { path: bounded(new URL(href).pathname), ...row };
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const failures = [
|
|
615
|
+
...routeRows.filter((row) => !row.ok).map((row) => `route ${row.path}: ${row.failure}`),
|
|
616
|
+
...assetResults.filter((row) => !row.ok).map((row) => `${row.kind} ${row.url}: ${row.failure}`),
|
|
617
|
+
...linkResults.filter((row) => !row.ok).map((row) => `link ${row.path}: ${row.failure}`),
|
|
618
|
+
...(runtime.declared ? [] : ["runtime: the home page declares no runtime bootstrap"]),
|
|
619
|
+
...(runtime.declared && !runtime.majorStreamMatches
|
|
620
|
+
? [`runtime: the major pointer serves major ${pointer?.majorVersion || "unknown"}, the page expects ${runtime.expectedMajorVersion}`]
|
|
621
|
+
: []),
|
|
622
|
+
...(runtime.declared && !runtime.entry.ok ? [`runtime: entry ${runtime.entry.url || "(undeclared)"}: ${runtime.entry.failure}`] : []),
|
|
623
|
+
...(runtime.declared && runtime.fallback && !runtime.fallback.ok
|
|
624
|
+
? [`runtime: fallback ${runtime.fallback.url || "(undeclared)"}: ${runtime.fallback.failure}`]
|
|
625
|
+
: []),
|
|
626
|
+
...(runtime.declared && !runtime.capabilitiesResolvable
|
|
627
|
+
? [`runtime: ${runtime.capabilities.length - (pointer?.declaredCapabilities ?? 0)} declared capability module(s) are missing from the major pointer and ${runtime.capabilityModules.failed} were invalid or did not load`]
|
|
628
|
+
: []),
|
|
629
|
+
...(runtime.pointerBehindFallback
|
|
630
|
+
? [`runtime: the major pointer (${pointer.version}) is behind the fallback runtime this site shipped with (${runtime.fallback.version}); returning browsers may be on an older runtime until it is promoted`]
|
|
631
|
+
: []),
|
|
632
|
+
...([...localOnly].slice(0, DELIVERY_LIMITS.localReferences).map((reference) => `local-only reference: ${bounded(reference)}`)),
|
|
633
|
+
].map((value) => [...sanitizeDiagnostic(value, "")].slice(0, DELIVERY_LIMITS.urlScalars).join(""));
|
|
634
|
+
|
|
635
|
+
return {
|
|
636
|
+
routeRows,
|
|
637
|
+
assetResults,
|
|
638
|
+
linkResults,
|
|
639
|
+
localOnly: [...localOnly].slice(0, DELIVERY_LIMITS.localReferences),
|
|
640
|
+
localOnlyTotal: localOnly.size,
|
|
641
|
+
runtime,
|
|
642
|
+
failures: failures.slice(0, DELIVERY_LIMITS.failures),
|
|
643
|
+
failuresTotal: failures.length,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Keep the report inside the output budget: failures first, then successes, with truncation flags. */
|
|
648
|
+
function budgeted(observed, sampledRoutes, selected) {
|
|
649
|
+
const sections = [
|
|
650
|
+
["routes", observed.routeRows],
|
|
651
|
+
["assets", observed.assetResults],
|
|
652
|
+
["links", observed.linkResults],
|
|
653
|
+
];
|
|
654
|
+
let bytes = 0;
|
|
655
|
+
const report = {};
|
|
656
|
+
for (const [name, rows] of sections) {
|
|
657
|
+
const items = [];
|
|
658
|
+
const ordered = [...rows.filter((row) => !row.ok), ...rows.filter((row) => row.ok)];
|
|
659
|
+
for (const row of ordered) {
|
|
660
|
+
const item = strip(row);
|
|
661
|
+
const size = Buffer.byteLength(JSON.stringify(item), "utf8") + 1;
|
|
662
|
+
if (bytes + size > DELIVERY_LIMITS.reportBytes) break;
|
|
663
|
+
items.push(item);
|
|
664
|
+
bytes += size;
|
|
665
|
+
}
|
|
666
|
+
report[name] = {
|
|
667
|
+
items,
|
|
668
|
+
checked: rows.length,
|
|
669
|
+
failed: rows.filter((row) => !row.ok).length,
|
|
670
|
+
...(items.length < rows.length ? { itemsTruncated: true } : {}),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
if (selected.length > sampledRoutes.length) Object.assign(report.routes, { truncated: true, requested: selected.length });
|
|
674
|
+
return report;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Verify a completed deployment's public delivery.
|
|
679
|
+
*
|
|
680
|
+
* @param options.fetch The transport's fetch; bounded per request by `timeoutSignal`.
|
|
681
|
+
* @param options.baseUrl The target origin (staging or production), https only.
|
|
682
|
+
* @param options.routes Site-relative paths to verify; "/" is always included.
|
|
683
|
+
* @param options.cookie Optional staging cookie, sent only to `baseUrl`'s origin.
|
|
684
|
+
* @param options.propagationWaitSeconds Bounded wait before one complete re-observation.
|
|
685
|
+
*/
|
|
686
|
+
export async function checkDelivery({
|
|
687
|
+
fetch,
|
|
688
|
+
timeoutSignal,
|
|
689
|
+
baseUrl,
|
|
690
|
+
routes = [],
|
|
691
|
+
cookie,
|
|
692
|
+
propagationWaitSeconds = 0,
|
|
693
|
+
sleep,
|
|
694
|
+
now = Date.now,
|
|
695
|
+
}) {
|
|
696
|
+
const origin = requireHttpsOrigin(baseUrl, "url");
|
|
697
|
+
const waitSeconds = Math.min(
|
|
698
|
+
Math.max(0, Number.isFinite(propagationWaitSeconds) ? Math.trunc(propagationWaitSeconds) : 0),
|
|
699
|
+
DELIVERY_LIMITS.propagationWaitMaximumSeconds,
|
|
700
|
+
);
|
|
701
|
+
const context = {
|
|
702
|
+
fetch,
|
|
703
|
+
timeoutSignal,
|
|
704
|
+
cookie,
|
|
705
|
+
cookieOrigin: new URL(origin).origin,
|
|
706
|
+
// The site's own origin, plus whatever the page's generator-authored
|
|
707
|
+
// bootstrap declares for the runtime once it has been read.
|
|
708
|
+
allowedOrigins: new Set([new URL(origin).origin]),
|
|
709
|
+
sleep: sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))),
|
|
710
|
+
};
|
|
711
|
+
const selected = [...new Set(["/", ...routes.filter((path) => typeof path === "string" && path.startsWith("/"))])];
|
|
712
|
+
const sampledRoutes = selected.slice(0, DELIVERY_LIMITS.routes);
|
|
713
|
+
|
|
714
|
+
let observed = await observe(context, origin, sampledRoutes);
|
|
715
|
+
// One bounded re-observation of everything, for edge propagation: it waits
|
|
716
|
+
// the caller's budget once and observes again from the home page down, so a
|
|
717
|
+
// recovered route, asset, pointer or bootstrap is seen fresh. It never loops.
|
|
718
|
+
let waited = 0;
|
|
719
|
+
if (waitSeconds > 0 && observed.failures.length > 0) {
|
|
720
|
+
await context.sleep(waitSeconds * 1000);
|
|
721
|
+
waited = waitSeconds;
|
|
722
|
+
observed = await observe(context, origin, sampledRoutes);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const verdict = observed.failuresTotal === 0
|
|
726
|
+
? "delivered"
|
|
727
|
+
: observed.routeRows.every((row) => row.ok) && observed.runtime.compatible
|
|
728
|
+
? "degraded"
|
|
729
|
+
: "failed";
|
|
730
|
+
const report = {
|
|
731
|
+
target: { url: origin, stagingAuthorized: Boolean(cookie) },
|
|
732
|
+
verdict,
|
|
733
|
+
...budgeted(observed, sampledRoutes, selected),
|
|
734
|
+
localOnlyReferences: observed.localOnly.map((value) => bounded(value)),
|
|
735
|
+
...(observed.localOnlyTotal > observed.localOnly.length ? { localOnlyReferencesTotal: observed.localOnlyTotal } : {}),
|
|
736
|
+
runtime: observed.runtime,
|
|
737
|
+
failures: observed.failures,
|
|
738
|
+
...(observed.failuresTotal > observed.failures.length ? { failuresTotal: observed.failuresTotal, failuresTruncated: true } : {}),
|
|
739
|
+
propagation: {
|
|
740
|
+
waitedSeconds: waited,
|
|
741
|
+
maximumSeconds: DELIVERY_LIMITS.propagationWaitMaximumSeconds,
|
|
742
|
+
...(waited > 0 ? { reobservedAfterSeconds: waited } : {}),
|
|
743
|
+
},
|
|
744
|
+
limits: {
|
|
745
|
+
routes: DELIVERY_LIMITS.routes,
|
|
746
|
+
images: DELIVERY_LIMITS.images,
|
|
747
|
+
links: DELIVERY_LIMITS.links,
|
|
748
|
+
capabilities: DELIVERY_LIMITS.capabilities,
|
|
749
|
+
concurrency: DELIVERY_LIMITS.concurrency,
|
|
750
|
+
attempts: DELIVERY_LIMITS.attempts,
|
|
751
|
+
requestMilliseconds: DELIVERY_LIMITS.requestMilliseconds,
|
|
752
|
+
reportBytes: DELIVERY_LIMITS.reportBytes,
|
|
753
|
+
outputBytes: DELIVERY_LIMITS.outputBytes,
|
|
754
|
+
localReferences: DELIVERY_LIMITS.localReferences,
|
|
755
|
+
failures: DELIVERY_LIMITS.failures,
|
|
756
|
+
},
|
|
757
|
+
checkedAt: new Date(now()).toISOString(),
|
|
758
|
+
};
|
|
759
|
+
return withinOutputBudget(report, observed.failuresTotal);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* The whole report, not just its item lists, must stay inside the output
|
|
764
|
+
* budget the CLI's machine-readable result has to fit. Evidence is shed in
|
|
765
|
+
* order of least value: successful items first, then failed items, then
|
|
766
|
+
* failure lines; counts, totals and the verdict always remain.
|
|
767
|
+
*/
|
|
768
|
+
function withinOutputBudget(report, failuresTotal) {
|
|
769
|
+
const size = () => Buffer.byteLength(JSON.stringify(report), "utf8");
|
|
770
|
+
const fits = () => size() <= DELIVERY_LIMITS.outputBytes;
|
|
771
|
+
const shed = [
|
|
772
|
+
...["links", "assets", "routes"].map((section) => () => {
|
|
773
|
+
report[section].items = report[section].items.filter((item) => !item.ok);
|
|
774
|
+
report[section].itemsTruncated = true;
|
|
775
|
+
}),
|
|
776
|
+
...["links", "assets", "routes"].map((section) => () => {
|
|
777
|
+
report[section].items = [];
|
|
778
|
+
}),
|
|
779
|
+
() => {
|
|
780
|
+
report.failures = report.failures.slice(0, Math.floor(report.failures.length / 2));
|
|
781
|
+
report.failuresTruncated = true;
|
|
782
|
+
report.failuresTotal = failuresTotal;
|
|
783
|
+
},
|
|
784
|
+
() => {
|
|
785
|
+
report.failures = [];
|
|
786
|
+
report.failuresTruncated = true;
|
|
787
|
+
report.failuresTotal = failuresTotal;
|
|
788
|
+
},
|
|
789
|
+
() => {
|
|
790
|
+
report.localOnlyReferencesTotal = report.localOnlyReferencesTotal ?? report.localOnlyReferences.length;
|
|
791
|
+
report.localOnlyReferences = [];
|
|
792
|
+
},
|
|
793
|
+
() => {
|
|
794
|
+
if (report.runtime.capabilityModules) {
|
|
795
|
+
report.runtime.capabilityModules.items = [];
|
|
796
|
+
report.runtime.capabilityModules.itemsTruncated = true;
|
|
797
|
+
}
|
|
798
|
+
},
|
|
799
|
+
// Last resort: the runtime detail collapses to its verdict booleans and
|
|
800
|
+
// counts, which are fixed-size, so the report is bounded by construction.
|
|
801
|
+
() => {
|
|
802
|
+
const { runtime } = report;
|
|
803
|
+
report.runtime = {
|
|
804
|
+
declared: runtime.declared,
|
|
805
|
+
expectedMajorVersion: runtime.expectedMajorVersion,
|
|
806
|
+
capabilities: runtime.capabilities.length,
|
|
807
|
+
majorStreamMatches: runtime.majorStreamMatches,
|
|
808
|
+
pointerBehindFallback: runtime.pointerBehindFallback,
|
|
809
|
+
capabilitiesResolvable: runtime.capabilitiesResolvable,
|
|
810
|
+
compatible: runtime.compatible,
|
|
811
|
+
entryOk: Boolean(runtime.entry?.ok),
|
|
812
|
+
detailTruncated: true,
|
|
813
|
+
};
|
|
814
|
+
},
|
|
815
|
+
];
|
|
816
|
+
for (const step of shed) {
|
|
817
|
+
if (fits()) break;
|
|
818
|
+
step();
|
|
819
|
+
}
|
|
820
|
+
return report;
|
|
821
|
+
}
|