@postedin/cms-client 0.1.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.
Files changed (73) hide show
  1. package/README.md +66 -0
  2. package/bin/dissect/cli.mjs +138 -0
  3. package/bin/dissect/dissect.mjs +290 -0
  4. package/bin/profile/build.mjs +106 -0
  5. package/bin/profile/fetch-log.mjs +298 -0
  6. package/bin/profile/format.mjs +90 -0
  7. package/bin/profile/interference-summary.mjs +558 -0
  8. package/bin/profile/interference.mjs +604 -0
  9. package/bin/profile/measure.mjs +137 -0
  10. package/bin/profile/report.mjs +90 -0
  11. package/bin/profile/site-env.mjs +16 -0
  12. package/bin/profile/summarize.mjs +429 -0
  13. package/dist/browser.d.ts +145 -0
  14. package/dist/browser.js +11 -0
  15. package/dist/browser.js.map +1 -0
  16. package/dist/chunk-6V54ITTK.js +197 -0
  17. package/dist/chunk-6V54ITTK.js.map +1 -0
  18. package/dist/chunk-MNZ7DIGC.js +51 -0
  19. package/dist/chunk-MNZ7DIGC.js.map +1 -0
  20. package/dist/form-proxy/upload-policy.d.ts +40 -0
  21. package/dist/form-proxy/upload-policy.js +17 -0
  22. package/dist/form-proxy/upload-policy.js.map +1 -0
  23. package/dist/index.d.ts +570 -0
  24. package/dist/index.js +1636 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/payload-types.d.ts +8985 -0
  27. package/dist/payload-types.js +1 -0
  28. package/dist/payload-types.js.map +1 -0
  29. package/package.json +74 -0
  30. package/src/api.ts +387 -0
  31. package/src/blog-listing.ts +75 -0
  32. package/src/browser.ts +24 -0
  33. package/src/client.ts +144 -0
  34. package/src/cms-to-href.ts +70 -0
  35. package/src/cms.ts +86 -0
  36. package/src/collections/appearance.ts +94 -0
  37. package/src/collections/areas.ts +29 -0
  38. package/src/collections/authors.ts +27 -0
  39. package/src/collections/banners.ts +14 -0
  40. package/src/collections/categories.ts +111 -0
  41. package/src/collections/forms.ts +29 -0
  42. package/src/collections/header-footer.ts +19 -0
  43. package/src/collections/image-links.ts +14 -0
  44. package/src/collections/media.ts +18 -0
  45. package/src/collections/options.ts +10 -0
  46. package/src/collections/pages.ts +83 -0
  47. package/src/collections/posts.ts +249 -0
  48. package/src/collections/project.ts +16 -0
  49. package/src/collections/questions.ts +35 -0
  50. package/src/collections/seo.ts +10 -0
  51. package/src/collections/tags.ts +25 -0
  52. package/src/collections/team-members.ts +79 -0
  53. package/src/config-time.ts +98 -0
  54. package/src/context.ts +12 -0
  55. package/src/decode-html.ts +8 -0
  56. package/src/form-proxy/cms-client.ts +95 -0
  57. package/src/form-proxy/cms-errors.ts +73 -0
  58. package/src/form-proxy/cms-write.ts +44 -0
  59. package/src/form-proxy/http.ts +96 -0
  60. package/src/form-proxy/index.ts +73 -0
  61. package/src/form-proxy/rate-limit.ts +46 -0
  62. package/src/form-proxy/submissions.ts +88 -0
  63. package/src/form-proxy/types.ts +23 -0
  64. package/src/form-proxy/upload-policy.ts +92 -0
  65. package/src/form-proxy/uploads.ts +81 -0
  66. package/src/home-page.ts +83 -0
  67. package/src/index.ts +68 -0
  68. package/src/loader.ts +83 -0
  69. package/src/locales.ts +80 -0
  70. package/src/payload-types.ts +10854 -0
  71. package/src/placeholder.ts +9 -0
  72. package/src/resolve-menu-items.ts +184 -0
  73. package/src/routes.ts +184 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Reading a profile log, and counting what is in it.
3
+ *
4
+ * Both profilers write NDJSON where every record carries a `kind`, and both
5
+ * reports then ask the same three kinds of question of it: group these records,
6
+ * rank these numbers, and work out how many requests were open at once. This
7
+ * is that, once, so `summarize.mjs` and `interference-summary.mjs` cannot drift
8
+ * apart on the answers.
9
+ *
10
+ * Pure. The markdown these numbers end up in is `format.mjs`.
11
+ */
12
+
13
+ /**
14
+ * Every record in an NDJSON log, in order.
15
+ *
16
+ * A process killed mid-write leaves a partial last line, and a line that does
17
+ * not parse is skipped rather than thrown: everything before it is still worth
18
+ * reading.
19
+ */
20
+ export function parseNdjson(text) {
21
+ const records = [];
22
+
23
+ for (const line of text.split('\n')) {
24
+ const trimmed = line.trim();
25
+
26
+ if (!trimmed) {
27
+ continue;
28
+ }
29
+
30
+ try {
31
+ records.push(JSON.parse(trimmed));
32
+ } catch {
33
+ continue;
34
+ }
35
+ }
36
+
37
+ return records;
38
+ }
39
+
40
+ export function sum(values) {
41
+ return values.reduce(
42
+ (total, value) => total + (typeof value === 'number' ? value : 0),
43
+ 0,
44
+ );
45
+ }
46
+
47
+ export function max(values) {
48
+ return values.reduce(
49
+ (highest, value) =>
50
+ typeof value === 'number' && value > highest ? value : highest,
51
+ 0,
52
+ );
53
+ }
54
+
55
+ export function group(items, keyOf) {
56
+ const groups = new Map();
57
+
58
+ for (const item of items) {
59
+ const key = keyOf(item);
60
+ const existing = groups.get(key);
61
+
62
+ if (existing) {
63
+ existing.members.push(item);
64
+ } else {
65
+ groups.set(key, { key, members: [item] });
66
+ }
67
+ }
68
+
69
+ return groups;
70
+ }
71
+
72
+ /**
73
+ * Nearest-rank percentile: the smallest sample at or above the requested
74
+ * share of the distribution, with no interpolation between two samples.
75
+ *
76
+ * A poll run produces tens of samples, not thousands, so an interpolated p95
77
+ * would be a number that was never measured. This returns one that was.
78
+ */
79
+ export function percentile(values, share) {
80
+ const sorted = values
81
+ .filter((value) => typeof value === 'number')
82
+ .sort((a, b) => a - b);
83
+
84
+ if (!sorted.length) {
85
+ return null;
86
+ }
87
+
88
+ const rank = Math.ceil(share * sorted.length);
89
+
90
+ return sorted[Math.min(Math.max(rank, 1), sorted.length) - 1];
91
+ }
92
+
93
+ /**
94
+ * A set of requests as a sorted stream of open and close events, ready to
95
+ * sweep for how many were in flight at once.
96
+ *
97
+ * `from` and `to` clamp the events to a span, so a caller asking about one
98
+ * moment of a build gets the level inside it rather than the level overall. A
99
+ * request that closed before the span opened is dropped entirely.
100
+ */
101
+ export function flightEvents(
102
+ requests,
103
+ { from = -Infinity, to = Infinity } = {},
104
+ ) {
105
+ const events = [];
106
+
107
+ for (const request of requests) {
108
+ const startedAt = request.startedAt;
109
+ const endedAt = request.endedAt ?? request.startedAt;
110
+
111
+ if (typeof startedAt !== 'number' || endedAt < from || startedAt > to) {
112
+ continue;
113
+ }
114
+
115
+ events.push({ at: Math.max(startedAt, from), delta: 1 });
116
+ events.push({ at: Math.min(endedAt, to), delta: -1 });
117
+ }
118
+
119
+ // At an identical timestamp, close before opening: two requests that merely
120
+ // touch at millisecond resolution were not in flight together.
121
+ events.sort((a, b) => a.at - b.at || a.delta - b.delta);
122
+
123
+ return events;
124
+ }
125
+
126
+ /** The most requests open at once anywhere in `[from, to]`. */
127
+ export function peakInFlight(requests, from, to) {
128
+ let level = 0;
129
+ let peak = 0;
130
+
131
+ for (const event of flightEvents(requests, { from, to })) {
132
+ level += event.delta;
133
+ peak = Math.max(peak, level);
134
+ }
135
+
136
+ return peak;
137
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Renders a fetch log as Markdown.
4
+ *
5
+ * cms-profile-report # newest log in .astro/profile
6
+ * cms-profile-report path/to/log.ndjson
7
+ * cms-profile-report log.ndjson --out report.md
8
+ *
9
+ * `pnpm build:profile` calls `writeReport` directly once its build finishes;
10
+ * this entry point exists to re-render a log without rebuilding, and to render
11
+ * one collected somewhere else.
12
+ */
13
+
14
+ import {
15
+ existsSync,
16
+ readdirSync,
17
+ readFileSync,
18
+ realpathSync,
19
+ statSync,
20
+ writeFileSync,
21
+ } from 'node:fs';
22
+ import { basename, dirname, join, resolve } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { parseLog, renderMarkdown, summarize } from './summarize.mjs';
25
+
26
+ export const LOG_DIR = join('.astro', 'profile');
27
+
28
+ /** Renders `logPath` and writes the Markdown, returning where it landed. */
29
+ export function writeReport(logPath, outPath = defaultOutPath(logPath)) {
30
+ const { requests, meta } = parseLog(readFileSync(logPath, 'utf-8'));
31
+
32
+ const markdown = renderMarkdown(summarize({ requests, meta }), {
33
+ logPath,
34
+ generatedAt: new Date().toISOString(),
35
+ });
36
+
37
+ writeFileSync(outPath, markdown);
38
+
39
+ return outPath;
40
+ }
41
+
42
+ /** The most recently written `.ndjson` in `dir`, or null if there is none. */
43
+ export function newestLog(dir = LOG_DIR) {
44
+ if (!existsSync(dir)) {
45
+ return null;
46
+ }
47
+
48
+ const logs = readdirSync(dir)
49
+ .filter((name) => name.endsWith('.ndjson'))
50
+ .map((name) => join(dir, name))
51
+ .map((path) => ({ path, at: statSync(path).mtimeMs }))
52
+ .sort((a, b) => b.at - a.at);
53
+
54
+ return logs.length ? logs[0].path : null;
55
+ }
56
+
57
+ function defaultOutPath(logPath) {
58
+ return join(
59
+ dirname(logPath),
60
+ `${basename(logPath).replace(/\.ndjson$/, '')}.md`,
61
+ );
62
+ }
63
+
64
+ function main(argv) {
65
+ const out = argv.indexOf('--out');
66
+ const outPath = out === -1 ? undefined : argv[out + 1];
67
+ const positional = (out === -1 ? argv : argv.slice(0, out)).filter(
68
+ (argument) => !argument.startsWith('-'),
69
+ );
70
+
71
+ const logPath = positional[0] ?? newestLog();
72
+
73
+ if (!logPath) {
74
+ console.error(
75
+ `No fetch log found in ${LOG_DIR}. Run \`pnpm build:profile\` first.`,
76
+ );
77
+ process.exit(1);
78
+ }
79
+
80
+ console.log(writeReport(logPath, outPath));
81
+ }
82
+
83
+ // Through a package bin, `argv[1]` is the path via the site's `node_modules`
84
+ // symlink while `import.meta.url` is the real one, so compare real paths.
85
+ if (
86
+ process.argv[1] &&
87
+ realpathSync(resolve(process.argv[1])) === fileURLToPath(import.meta.url)
88
+ ) {
89
+ main(process.argv.slice(2));
90
+ }
@@ -0,0 +1,16 @@
1
+ import { createRequire } from 'node:module';
2
+ import { join } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ /**
6
+ * The site's environment, resolved by the site's own Vite, in the directory the
7
+ * command was run from. The profilers run inside a site but ship in this
8
+ * package, and the package does not depend on Vite: reading the env files any
9
+ * other way could resolve a different value than the build about to be profiled.
10
+ */
11
+ export async function loadSiteEnv(mode) {
12
+ const require = createRequire(join(process.cwd(), 'package.json'));
13
+ const { loadEnv } = await import(pathToFileURL(require.resolve('vite')).href);
14
+
15
+ return loadEnv(mode, process.cwd(), '');
16
+ }
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Turns a fetch log into a report.
3
+ *
4
+ * Pure functions over the NDJSON written by `fetch-log.mjs`: no file system, no
5
+ * clock, no argv, so the whole thing can be checked against a fixture. The CLI
6
+ * that reads and writes files is `report.mjs`.
7
+ *
8
+ * Three questions shape what gets computed, in the order they are worth asking:
9
+ *
10
+ * 1. How much does a build ask for at all — requests, bytes, time.
11
+ * 2. How much of it arrives at once, which is what the CMS feels.
12
+ * 3. How much of it was already asked for, which is what a missing cache looks
13
+ * like from the outside.
14
+ */
15
+
16
+ import {
17
+ bar,
18
+ bullets,
19
+ count,
20
+ duration,
21
+ escapePipes,
22
+ offsetSeconds,
23
+ share,
24
+ size,
25
+ table,
26
+ } from './format.mjs';
27
+ import { flightEvents, group, max, parseNdjson, sum } from './measure.mjs';
28
+
29
+ const TIMELINE_BUCKETS = 24;
30
+
31
+ /** How many of the build's costliest CMS requests to name individually. */
32
+ const SLOWEST_REQUESTS = 10;
33
+
34
+ export function parseLog(text) {
35
+ const requests = [];
36
+ const meta = [];
37
+
38
+ for (const record of parseNdjson(text)) {
39
+ if (record.kind === 'request') {
40
+ requests.push(record);
41
+ } else if (record.kind === 'meta') {
42
+ meta.push(record);
43
+ }
44
+ }
45
+
46
+ return { requests, meta };
47
+ }
48
+
49
+ export function summarize({ requests, meta }) {
50
+ const cms = requests.filter((request) => request.cms);
51
+ const other = requests.filter((request) => !request.cms);
52
+ const window = timeWindow(requests, meta);
53
+
54
+ return {
55
+ cmsOrigin: meta.find((record) => record.cmsOrigin)?.cmsOrigin ?? null,
56
+ window,
57
+ totals: {
58
+ requests: requests.length,
59
+ cmsRequests: cms.length,
60
+ otherRequests: other.length,
61
+ wallMs: window.endedAt - window.startedAt,
62
+ requestMs: sum(requests.map((request) => request.durationMs)),
63
+ cmsRequestMs: sum(cms.map((request) => request.durationMs)),
64
+ bytes: sum(requests.map((request) => request.bytes)),
65
+ cmsBytes: sum(cms.map((request) => request.bytes)),
66
+ failed: requests.filter((request) => !isOk(request)).length,
67
+ },
68
+ collections: collectionRows(cms),
69
+ concurrency: concurrency(requests, window),
70
+ // Fonts and remote images arrive in one burst that dwarfs anything the
71
+ // CMS is asked, so the overall peak says nothing about the CMS. This is
72
+ // the number the CMS feels.
73
+ cmsConcurrency: concurrency(cms, window),
74
+ repeated: repeatedRows(cms),
75
+ slowest: slowestRows(cms, window),
76
+ hosts: hostRows(other),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * The CMS requests that cost the most, each with the CMS's own id for it.
82
+ *
83
+ * A collection row says where a build's time went; this says which single
84
+ * request to go and read. `profileId` is the `x-cms-profile-id` a deployment
85
+ * running with `CMS_PROFILE=1` returns, and it is the whole point of the row:
86
+ * without it a slow request is a duration, and with it the CMS can be asked
87
+ * what the time was spent on.
88
+ */
89
+ function slowestRows(requests, window) {
90
+ return [...requests]
91
+ .filter((request) => typeof request.durationMs === 'number')
92
+ .sort((a, b) => b.durationMs - a.durationMs)
93
+ .slice(0, SLOWEST_REQUESTS)
94
+ .map((request) => ({
95
+ atMs: (request.startedAt ?? window.startedAt) - window.startedAt,
96
+ collection: request.collection ?? request.path ?? '-',
97
+ locale: request.locale ?? '-',
98
+ depth: request.depth ?? '-',
99
+ page: request.page ?? '-',
100
+ durationMs: request.durationMs,
101
+ status: request.status ?? null,
102
+ profileId: request.profileId ?? null,
103
+ }));
104
+ }
105
+
106
+ /**
107
+ * CMS requests grouped the way the CMS sees them: which collection, in which
108
+ * locale, at which depth. Ordered by the time they cost, because that is the
109
+ * column a reader is looking for.
110
+ */
111
+ function collectionRows(requests) {
112
+ const rows = group(requests, (request) =>
113
+ [
114
+ request.collection ?? request.path ?? '-',
115
+ request.locale ?? '-',
116
+ request.depth ?? '-',
117
+ ].join(' '),
118
+ );
119
+
120
+ return [...rows.values()]
121
+ .map(({ key, members }) => {
122
+ const [collection, locale, depth] = key.split(' ');
123
+
124
+ return {
125
+ collection,
126
+ locale,
127
+ depth,
128
+ count: members.length,
129
+ totalMs: sum(members.map((request) => request.durationMs)),
130
+ maxMs: max(members.map((request) => request.durationMs)),
131
+ bytes: sum(members.map((request) => request.bytes)),
132
+ };
133
+ })
134
+ .sort((a, b) => b.totalMs - a.totalMs || b.count - a.count);
135
+ }
136
+
137
+ /**
138
+ * The peak number of requests in flight, and where in the build the overlap
139
+ * sits. Wall time is cut into fixed buckets and each one reports the highest
140
+ * level reached inside it, so a single burst stays visible instead of being
141
+ * averaged away.
142
+ */
143
+ function concurrency(requests, window) {
144
+ const events = flightEvents(requests);
145
+
146
+ const wallMs = Math.max(window.endedAt - window.startedAt, 1);
147
+ const bucketCount = Math.min(TIMELINE_BUCKETS, Math.max(wallMs, 1));
148
+ const width = wallMs / bucketCount;
149
+
150
+ let level = 0;
151
+ let peak = 0;
152
+ let cursor = 0;
153
+ const timeline = [];
154
+
155
+ for (let bucket = 0; bucket < bucketCount; bucket += 1) {
156
+ const startMs = bucket * width;
157
+ const endMs = bucket === bucketCount - 1 ? wallMs : startMs + width;
158
+ let bucketPeak = level;
159
+
160
+ while (
161
+ cursor < events.length &&
162
+ events[cursor].at - window.startedAt < endMs
163
+ ) {
164
+ level += events[cursor].delta;
165
+ bucketPeak = Math.max(bucketPeak, level);
166
+ peak = Math.max(peak, level);
167
+ cursor += 1;
168
+ }
169
+
170
+ timeline.push({ startMs, endMs, peak: bucketPeak });
171
+ }
172
+
173
+ // Whatever is left is an event landing exactly on the end of the window.
174
+ while (cursor < events.length) {
175
+ level += events[cursor].delta;
176
+ peak = Math.max(peak, level);
177
+ cursor += 1;
178
+ }
179
+
180
+ return { peak, timeline };
181
+ }
182
+
183
+ /**
184
+ * Requests a build made more than once with the same question: same collection,
185
+ * same locale, same page, same `where`. One of these per generated page is the
186
+ * signature of a fetch that should have been cached and was not.
187
+ */
188
+ function repeatedRows(requests) {
189
+ const rows = group(requests, (request) =>
190
+ [
191
+ request.collection ?? request.path ?? '-',
192
+ request.locale ?? '-',
193
+ request.page ?? '-',
194
+ request.whereHash ?? request.where ?? '-',
195
+ ].join(' '),
196
+ );
197
+
198
+ return [...rows.values()]
199
+ .filter(({ members }) => members.length > 1)
200
+ .map(({ key, members }) => {
201
+ const [collection, locale, page] = key.split(' ');
202
+
203
+ return {
204
+ collection,
205
+ locale,
206
+ page,
207
+ where: members[0].where ?? null,
208
+ count: members.length,
209
+ totalMs: sum(members.map((request) => request.durationMs)),
210
+ };
211
+ })
212
+ .sort((a, b) => b.count - a.count || b.totalMs - a.totalMs);
213
+ }
214
+
215
+ /** Everything that was not the CMS - fonts, media, image sources - by host. */
216
+ function hostRows(requests) {
217
+ const rows = group(requests, (request) => request.host ?? '-');
218
+
219
+ return [...rows.values()]
220
+ .map(({ key, members }) => ({
221
+ host: key,
222
+ count: members.length,
223
+ totalMs: sum(members.map((request) => request.durationMs)),
224
+ bytes: sum(members.map((request) => request.bytes)),
225
+ }))
226
+ .sort((a, b) => b.count - a.count);
227
+ }
228
+
229
+ /**
230
+ * The span the report covers. Meta records bracket the process, but a log
231
+ * missing them - a build killed before exit, or a hand-run preload - still has
232
+ * request timestamps to fall back on.
233
+ */
234
+ function timeWindow(requests, meta) {
235
+ const starts = [
236
+ ...meta.map((record) => record.startedAt),
237
+ ...requests.map((request) => request.startedAt),
238
+ ].filter((value) => typeof value === 'number');
239
+
240
+ const ends = [
241
+ ...meta.map((record) => record.endedAt),
242
+ ...requests.map((request) => request.endedAt ?? request.startedAt),
243
+ ].filter((value) => typeof value === 'number');
244
+
245
+ const startedAt = starts.length ? Math.min(...starts) : 0;
246
+ const endedAt = ends.length ? Math.max(...ends) : startedAt;
247
+
248
+ return { startedAt, endedAt };
249
+ }
250
+
251
+ /** A request the CMS answered, as opposed to one it refused or never saw. */
252
+ function isOk(request) {
253
+ return (
254
+ typeof request.status === 'number' &&
255
+ request.status >= 200 &&
256
+ request.status < 400
257
+ );
258
+ }
259
+
260
+ /**
261
+ * Why a log holds no CMS request — which is two different facts.
262
+ *
263
+ * A request is only the CMS's because its origin matched the one the profiler
264
+ * was given, so an origin that never resolved moves every request the build
265
+ * made into "other hosts" and leaves this section empty. That reads exactly
266
+ * like a build that never called the CMS, and is a misconfigured profiler
267
+ * rather than a finding, so it has to be said in the report and not only in
268
+ * the terminal the run scrolled past.
269
+ */
270
+ function noCmsRequests(summary, { brief = false } = {}) {
271
+ if (summary.cmsOrigin) {
272
+ return 'No CMS requests in this log.';
273
+ }
274
+
275
+ return brief
276
+ ? 'No CMS origin was configured, so nothing could be recognised as the CMS.'
277
+ : 'No CMS origin was configured, so nothing could be recognised as the CMS and every request is counted under Other hosts. Set `API_BASE_URL`, or `PROFILE_CMS_ORIGIN` to profile against another origin.';
278
+ }
279
+
280
+ export function renderMarkdown(summary, { logPath, generatedAt } = {}) {
281
+ const { totals, concurrency: overlap } = summary;
282
+
283
+ const lines = [
284
+ '# Build fetch profile',
285
+ '',
286
+ ...bullets([
287
+ ['Log', logPath ? `\`${logPath}\`` : null],
288
+ ['Generated', generatedAt ?? null],
289
+ ['CMS origin', summary.cmsOrigin ? `\`${summary.cmsOrigin}\`` : null],
290
+ [
291
+ 'Build started',
292
+ summary.window.startedAt
293
+ ? new Date(summary.window.startedAt).toISOString()
294
+ : null,
295
+ ],
296
+ ]),
297
+ '',
298
+ '## Totals',
299
+ '',
300
+ ...table(
301
+ ['Metric', 'Value'],
302
+ [
303
+ ['Build wall time', duration(totals.wallMs)],
304
+ ['Requests', count(totals.requests)],
305
+ ['To the CMS', count(totals.cmsRequests)],
306
+ ['Elsewhere', count(totals.otherRequests)],
307
+ ['Failed or errored', count(totals.failed)],
308
+ [
309
+ 'Summed request time',
310
+ `${duration(totals.requestMs)} (${share(totals.requestMs, totals.wallMs)} of wall time)`,
311
+ ],
312
+ ['Summed CMS request time', duration(totals.cmsRequestMs)],
313
+ ['Peak requests in flight', count(overlap.peak)],
314
+ ['Peak CMS requests in flight', count(summary.cmsConcurrency.peak)],
315
+ ['Response bytes', size(totals.bytes)],
316
+ ['Response bytes from the CMS', size(totals.cmsBytes)],
317
+ ],
318
+ ),
319
+ '',
320
+ '## CMS requests by collection',
321
+ '',
322
+ ...(summary.collections.length
323
+ ? table(
324
+ ['Collection', 'Locale', 'Depth', 'Requests', 'Total', 'Max', 'Size'],
325
+ summary.collections.map((row) => [
326
+ `\`${row.collection}\``,
327
+ row.locale,
328
+ row.depth,
329
+ count(row.count),
330
+ duration(row.totalMs),
331
+ duration(row.maxMs),
332
+ size(row.bytes),
333
+ ]),
334
+ )
335
+ : [noCmsRequests(summary)]),
336
+ '',
337
+ '## Slowest CMS requests',
338
+ '',
339
+ ...(summary.slowest.length
340
+ ? [
341
+ ...table(
342
+ [
343
+ 'At',
344
+ 'Collection',
345
+ 'Locale',
346
+ 'Depth',
347
+ 'Page',
348
+ 'Duration',
349
+ 'CMS profile id',
350
+ ],
351
+ summary.slowest.map((row) => [
352
+ offsetSeconds(row.atMs),
353
+ `\`${row.collection}\``,
354
+ row.locale,
355
+ row.depth,
356
+ row.page,
357
+ duration(row.durationMs),
358
+ row.profileId ? `\`${row.profileId}\`` : '-',
359
+ ]),
360
+ ),
361
+ '',
362
+ ...(summary.slowest.some((row) => row.profileId)
363
+ ? [
364
+ 'Each id is one `cms.profile.request` line on the CMS, so a row here can be opened into the commands and hooks that produced it.',
365
+ ]
366
+ : [
367
+ 'No response carried `x-cms-profile-id`, so these are durations with nothing behind them. Run the target with `CMS_PROFILE=1` to join them to the CMS’s own lines.',
368
+ ]),
369
+ ]
370
+ : [noCmsRequests(summary, { brief: true })]),
371
+ '',
372
+ '## Concurrency',
373
+ '',
374
+ `Peak: **${count(overlap.peak)}** request${overlap.peak === 1 ? '' : 's'} in flight at once, and **${count(summary.cmsConcurrency.peak)}** counting only the CMS.`,
375
+ '',
376
+ ...table(
377
+ ['Window', 'Peak in flight', 'Peak at the CMS', 'CMS overlap'],
378
+ overlap.timeline.map((bucket, index) => {
379
+ // Scaled to the CMS peak, not the overall one: one burst of fonts
380
+ // would otherwise flatten every bar that matters into nothing.
381
+ const cmsPeak = summary.cmsConcurrency.timeline[index]?.peak ?? 0;
382
+
383
+ return [
384
+ `${offsetSeconds(bucket.startMs)} to ${offsetSeconds(bucket.endMs)}`,
385
+ count(bucket.peak),
386
+ count(cmsPeak),
387
+ bar(cmsPeak, summary.cmsConcurrency.peak),
388
+ ];
389
+ }),
390
+ ),
391
+ '',
392
+ '## Repeated requests',
393
+ '',
394
+ ...(summary.repeated.length
395
+ ? [
396
+ 'Same collection, locale, page and `where`, asked more than once.',
397
+ '',
398
+ ...table(
399
+ ['Collection', 'Locale', 'Page', 'Times', 'Total', 'Where'],
400
+ summary.repeated.map((row) => [
401
+ `\`${row.collection}\``,
402
+ row.locale,
403
+ row.page,
404
+ count(row.count),
405
+ duration(row.totalMs),
406
+ row.where ? `\`${escapePipes(row.where)}\`` : '-',
407
+ ]),
408
+ ),
409
+ ]
410
+ : ['Nothing was requested twice.']),
411
+ '',
412
+ '## Other hosts',
413
+ '',
414
+ ...(summary.hosts.length
415
+ ? table(
416
+ ['Host', 'Requests', 'Total', 'Size'],
417
+ summary.hosts.map((row) => [
418
+ `\`${row.host}\``,
419
+ count(row.count),
420
+ duration(row.totalMs),
421
+ size(row.bytes),
422
+ ]),
423
+ )
424
+ : ['Every request went to the CMS.']),
425
+ '',
426
+ ];
427
+
428
+ return `${lines.join('\n')}\n`;
429
+ }