@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,558 @@
1
+ /**
2
+ * Turns an interference run into a report.
3
+ *
4
+ * Pure functions over the NDJSON written by `interference.mjs`, plus the build
5
+ * fetch log `fetch-log.mjs` wrote during the same run: no file system, no
6
+ * clock, no argv, so the whole thing can be checked against a fixture. The CLI
7
+ * that reads and writes files is `interference.mjs` itself.
8
+ *
9
+ * The question is one question, asked three times: what does a CMS request
10
+ * cost an editor before a build starts, while it runs, and after it finishes.
11
+ * Two things make the answer readable:
12
+ *
13
+ * 1. Percentiles per probe per phase, so "slower" is a number.
14
+ * 2. Every poll joined to how many of the build's CMS requests were in flight
15
+ * at that moment, so latency can be read against what the build was doing
16
+ * rather than against the wall clock. A build is not uniformly busy, and
17
+ * the during-phase p50 hides that.
18
+ */
19
+
20
+ import {
21
+ bar,
22
+ bullets,
23
+ count,
24
+ duration,
25
+ escapePipes,
26
+ offsetClock,
27
+ table,
28
+ } from './format.mjs';
29
+ import { group, parseNdjson, peakInFlight, percentile } from './measure.mjs';
30
+
31
+ /** Phase order is the story the report tells; it is not alphabetical. */
32
+ export const PHASES = ['before', 'during', 'after'];
33
+
34
+ /**
35
+ * How many of the worst polls to name.
36
+ *
37
+ * The medians are the finding; the tail is the thing anyone reading the report
38
+ * actually wants to chase, and chasing it means one row per poll — when it
39
+ * went out, what the build was doing, and the CMS's own id for the request, if
40
+ * the deployment was profiling and gave one.
41
+ */
42
+ const SLOWEST_POLLS = 10;
43
+
44
+ const PHASE_LABELS = {
45
+ before: 'Before',
46
+ during: 'During the build',
47
+ after: 'After',
48
+ };
49
+
50
+ export function parsePollLog(text) {
51
+ const polls = [];
52
+ const meta = [];
53
+
54
+ for (const record of parseNdjson(text)) {
55
+ if (record.kind === 'poll') {
56
+ polls.push(record);
57
+ } else if (record.kind === 'meta') {
58
+ meta.push(record);
59
+ }
60
+ }
61
+
62
+ return { polls, meta };
63
+ }
64
+
65
+ export function summarizeInterference({ polls, meta, buildRequests = [] }) {
66
+ const start = meta.find((record) => record.event === 'start') ?? {};
67
+ const end = meta.find((record) => record.event === 'end') ?? {};
68
+ const build = meta.find((record) => record.event === 'build') ?? null;
69
+ const window = timeWindow(polls, start, end);
70
+ const probeNames = probeOrder(polls, start);
71
+ // A build fetches fonts and posts telemetry as well, and neither of those
72
+ // is something an editor is queueing behind. The overlay counts what
73
+ // reached the CMS, which is the same number the build report calls "peak
74
+ // CMS requests in flight".
75
+ const cmsRequests = buildRequests.filter((request) => request.cms);
76
+ const ticks = tickRows(polls, cmsRequests, window);
77
+
78
+ return {
79
+ tag: start.tag ?? null,
80
+ cmsOrigin: start.cmsOrigin ?? null,
81
+ project: start.project ?? null,
82
+ collection: start.collection ?? null,
83
+ documentId: start.documentId ?? null,
84
+ intervalMs: start.intervalMs ?? null,
85
+ idleMs: start.idleMs ?? null,
86
+ probes: start.probes ?? probeNames.map((name) => ({ name })),
87
+ window,
88
+ build: build && {
89
+ command: build.command ?? null,
90
+ logPath: build.logPath ?? null,
91
+ startedAt: build.startedAt ?? null,
92
+ endedAt: build.endedAt ?? null,
93
+ wallMs:
94
+ build.startedAt && build.endedAt
95
+ ? build.endedAt - build.startedAt
96
+ : null,
97
+ exitCode: build.exitCode ?? null,
98
+ signal: build.signal ?? null,
99
+ // A signalled build has no exit code, and a build that never ran
100
+ // has neither. Only 0 is a build that finished.
101
+ ok: build.exitCode === 0 && !build.signal,
102
+ requests: buildRequests.length,
103
+ cmsRequests: cmsRequests.length,
104
+ },
105
+ phases: phaseRows(polls, probeNames),
106
+ comparison: comparisonRows(polls, probeNames),
107
+ ticks,
108
+ byInFlight: inFlightRows(ticks, probeNames),
109
+ slowest: slowestRows(ticks, probeNames),
110
+ errors: polls
111
+ .filter((poll) => !poll.ok)
112
+ .map((poll) => ({
113
+ atMs: poll.startedAt - window.startedAt,
114
+ phase: poll.phase,
115
+ probe: poll.probe,
116
+ status: poll.status ?? null,
117
+ error: poll.error ?? null,
118
+ })),
119
+ skipped: meta.filter((record) => record.event === 'skipped').length,
120
+ polls: polls.length,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * The probes, in the order the run declared them, so every table reads the
126
+ * same way left to right. A log without a start record falls back to the order
127
+ * they were first polled in.
128
+ */
129
+ function probeOrder(polls, start) {
130
+ if (start.probes?.length) {
131
+ return start.probes.map((probe) => probe.name);
132
+ }
133
+
134
+ const names = [];
135
+
136
+ for (const poll of polls) {
137
+ if (!names.includes(poll.probe)) {
138
+ names.push(poll.probe);
139
+ }
140
+ }
141
+
142
+ return names;
143
+ }
144
+
145
+ function timeWindow(polls, start, end) {
146
+ const starts = [start.startedAt, ...polls.map((poll) => poll.startedAt)];
147
+ const ends = [
148
+ end.endedAt,
149
+ ...polls.map((poll) => poll.endedAt ?? poll.startedAt),
150
+ ];
151
+
152
+ const startedAt = pick(starts, Math.min);
153
+ const endedAt = pick(ends, Math.max);
154
+
155
+ return { startedAt, endedAt: Math.max(endedAt, startedAt) };
156
+ }
157
+
158
+ function pick(values, choose) {
159
+ const numbers = values.filter((value) => typeof value === 'number');
160
+
161
+ return numbers.length ? choose(...numbers) : 0;
162
+ }
163
+
164
+ /** One row per phase, carrying one row per probe. */
165
+ function phaseRows(polls, probeNames) {
166
+ return PHASES.filter((phase) =>
167
+ polls.some((poll) => poll.phase === phase),
168
+ ).map((phase) => {
169
+ const members = polls.filter((poll) => poll.phase === phase);
170
+ const startedAt = pick(
171
+ members.map((poll) => poll.startedAt),
172
+ Math.min,
173
+ );
174
+ const endedAt = pick(
175
+ members.map((poll) => poll.endedAt ?? poll.startedAt),
176
+ Math.max,
177
+ );
178
+
179
+ return {
180
+ phase,
181
+ startedAt,
182
+ endedAt,
183
+ wallMs: endedAt - startedAt,
184
+ probes: probeNames.map((name) =>
185
+ statsFor(
186
+ name,
187
+ members.filter((poll) => poll.probe === name),
188
+ ),
189
+ ),
190
+ };
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Latency for one probe over a set of polls.
196
+ *
197
+ * Percentiles cover the polls the CMS answered. A refused or failed poll has
198
+ * no latency worth ranking — it has a status — so it is counted beside them
199
+ * rather than mixed in.
200
+ */
201
+ function statsFor(probe, polls) {
202
+ const answered = polls.filter((poll) => poll.ok);
203
+ const values = answered.map((poll) => poll.ms);
204
+
205
+ return {
206
+ probe,
207
+ polls: polls.length,
208
+ samples: answered.length,
209
+ errors: polls.length - answered.length,
210
+ p50: percentile(values, 0.5),
211
+ p95: percentile(values, 0.95),
212
+ min: values.length ? Math.min(...values) : null,
213
+ max: values.length ? Math.max(...values) : null,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * The headline: each probe's p50 in each phase side by side, and what the
219
+ * build multiplied it by.
220
+ *
221
+ * `after` is in the table because it is the control. A during/before ratio on
222
+ * its own cannot tell a build's cost apart from a CMS that was simply cold at
223
+ * the start of the run, and the baseline showed how large that effect is.
224
+ */
225
+ function comparisonRows(polls, probeNames) {
226
+ return probeNames.map((name) => {
227
+ const byPhase = {};
228
+
229
+ for (const phase of PHASES) {
230
+ byPhase[phase] = statsFor(
231
+ name,
232
+ polls.filter((poll) => poll.probe === name && poll.phase === phase),
233
+ );
234
+ }
235
+
236
+ return {
237
+ probe: name,
238
+ ...byPhase,
239
+ ratio:
240
+ byPhase.before.p50 && byPhase.during.p50
241
+ ? byPhase.during.p50 / byPhase.before.p50
242
+ : null,
243
+ };
244
+ });
245
+ }
246
+
247
+ /**
248
+ * One row per poll tick: when it went out, in which phase, what each probe
249
+ * cost, and how many of the build's CMS requests were in flight while it was
250
+ * open.
251
+ *
252
+ * The overlay is the peak concurrency over the tick's own span rather than the
253
+ * level at the instant it started. A poll that takes two seconds was not
254
+ * competing only with whatever happened to be open when it left.
255
+ */
256
+ function tickRows(polls, cmsRequests, window) {
257
+ const ticks = group(polls, (poll) => poll.tick ?? poll.startedAt);
258
+
259
+ return [...ticks.values()]
260
+ .map(({ members }) => {
261
+ const startedAt = pick(
262
+ members.map((poll) => poll.startedAt),
263
+ Math.min,
264
+ );
265
+ const endedAt = pick(
266
+ members.map((poll) => poll.endedAt ?? poll.startedAt),
267
+ Math.max,
268
+ );
269
+ const samples = {};
270
+
271
+ for (const poll of members) {
272
+ samples[poll.probe] = poll;
273
+ }
274
+
275
+ return {
276
+ tick: members[0].tick ?? null,
277
+ phase: members[0].phase,
278
+ atMs: startedAt - window.startedAt,
279
+ startedAt,
280
+ endedAt,
281
+ inFlight: peakInFlight(cmsRequests, startedAt, endedAt),
282
+ samples,
283
+ };
284
+ })
285
+ .sort((a, b) => a.startedAt - b.startedAt);
286
+ }
287
+
288
+ /**
289
+ * The same latencies grouped by how busy the build was, which is the reading
290
+ * the wall clock cannot give: a build's during-phase p50 averages its quiet
291
+ * stretches together with its bursts.
292
+ */
293
+ function inFlightRows(ticks, probeNames) {
294
+ const rows = group(ticks, (tick) => tick.inFlight);
295
+
296
+ return [...rows.values()]
297
+ .map(({ key, members }) => ({
298
+ inFlight: key,
299
+ ticks: members.length,
300
+ probes: probeNames.map((name) =>
301
+ statsFor(
302
+ name,
303
+ members.map((tick) => tick.samples[name]).filter(Boolean),
304
+ ),
305
+ ),
306
+ }))
307
+ .sort((a, b) => a.inFlight - b.inFlight);
308
+ }
309
+
310
+ /**
311
+ * The worst polls the CMS answered, each beside what the build was doing and
312
+ * the CMS's own id for it.
313
+ *
314
+ * Refused polls are left out: they are in the failed-poll table, and a 429's
315
+ * latency is not a measurement of a slow read. The ordering is by latency
316
+ * rather than by phase, because a stall in an idle phase is the most useful
317
+ * row in the table — it is the one that says the build is not the cause.
318
+ */
319
+ function slowestRows(ticks, probeNames) {
320
+ const rows = [];
321
+
322
+ for (const tick of ticks) {
323
+ for (const name of probeNames) {
324
+ const poll = tick.samples[name];
325
+
326
+ if (!poll?.ok || typeof poll.ms !== 'number') {
327
+ continue;
328
+ }
329
+
330
+ rows.push({
331
+ atMs: tick.atMs,
332
+ phase: tick.phase,
333
+ probe: name,
334
+ ms: poll.ms,
335
+ inFlight: tick.inFlight,
336
+ profileId: poll.profileId ?? null,
337
+ });
338
+ }
339
+ }
340
+
341
+ return rows.sort((a, b) => b.ms - a.ms).slice(0, SLOWEST_POLLS);
342
+ }
343
+
344
+ export function renderInterferenceMarkdown(
345
+ summary,
346
+ { logPath, generatedAt } = {},
347
+ ) {
348
+ const probeNames = summary.probes.map((probe) => probe.name);
349
+ const peak = Math.max(0, ...summary.ticks.map((tick) => tick.inFlight));
350
+
351
+ return `${[
352
+ '# Interference profile',
353
+ '',
354
+ ...bullets([
355
+ ['Log', logPath ? `\`${logPath}\`` : null],
356
+ ['Generated', generatedAt ?? null],
357
+ ['Tag', summary.tag ? `**${summary.tag}**` : null],
358
+ ['CMS origin', summary.cmsOrigin ? `\`${summary.cmsOrigin}\`` : null],
359
+ ['Project', summary.project ? `\`${summary.project}\`` : null],
360
+ [
361
+ 'Poll interval',
362
+ summary.intervalMs ? `${summary.intervalMs / 1000}s` : null,
363
+ ],
364
+ ['Idle phases', summary.idleMs ? `${summary.idleMs / 1000}s each` : null],
365
+ [
366
+ 'Polls',
367
+ `${count(summary.polls)} across ${count(summary.ticks.length)} tick${summary.ticks.length === 1 ? '' : 's'}`,
368
+ ],
369
+ [
370
+ 'Ticks skipped',
371
+ summary.skipped
372
+ ? `${count(summary.skipped)} — the previous tick was still in flight`
373
+ : null,
374
+ ],
375
+ ...buildBullets(summary.build),
376
+ ]),
377
+ '',
378
+ '## What was polled',
379
+ '',
380
+ ...table(
381
+ ['Probe', 'Request', 'Stands in for'],
382
+ summary.probes.map((probe) => [
383
+ `\`${probe.name}\``,
384
+ probe.request ? `\`${escapePipes(probe.request)}\`` : '-',
385
+ probe.description ?? '-',
386
+ ]),
387
+ ),
388
+ '',
389
+ '## Before, during, after',
390
+ '',
391
+ ...table(
392
+ [
393
+ 'Probe',
394
+ 'Before p50',
395
+ 'During p50',
396
+ 'After p50',
397
+ 'During / before',
398
+ 'During p95',
399
+ 'During max',
400
+ ],
401
+ summary.comparison.map((row) => [
402
+ `\`${row.probe}\``,
403
+ duration(row.before.p50),
404
+ duration(row.during.p50),
405
+ duration(row.after.p50),
406
+ row.ratio ? `${row.ratio.toFixed(2)}×` : '-',
407
+ duration(row.during.p95),
408
+ duration(row.during.max),
409
+ ]),
410
+ ),
411
+ '',
412
+ '## Every phase in full',
413
+ '',
414
+ ...table(
415
+ ['Phase', 'Span', 'Probe', 'Polls', 'Errors', 'p50', 'p95', 'Max'],
416
+ summary.phases.flatMap((phase) =>
417
+ phase.probes.map((row, index) => [
418
+ index === 0 ? (PHASE_LABELS[phase.phase] ?? phase.phase) : '',
419
+ index === 0 ? duration(phase.wallMs) : '',
420
+ `\`${row.probe}\``,
421
+ count(row.polls),
422
+ count(row.errors),
423
+ duration(row.p50),
424
+ duration(row.p95),
425
+ duration(row.max),
426
+ ]),
427
+ ),
428
+ ),
429
+ '',
430
+ '## Latency against build concurrency',
431
+ '',
432
+ ...(summary.build
433
+ ? [
434
+ `Polls grouped by how many of the build's CMS requests were open while they were, peaking at **${count(peak)}**.`,
435
+ '',
436
+ ...table(
437
+ [
438
+ 'CMS requests in flight',
439
+ 'Ticks',
440
+ ...probeNames.map((name) => `\`${name}\` p50`),
441
+ 'Slowest poll',
442
+ ],
443
+ summary.byInFlight.map((row) => [
444
+ count(row.inFlight),
445
+ count(row.ticks),
446
+ ...row.probes.map((probe) => duration(probe.p50)),
447
+ duration(
448
+ Math.max(0, ...row.probes.map((probe) => probe.max ?? 0)),
449
+ ),
450
+ ]),
451
+ ),
452
+ ]
453
+ : ['No build ran in this log, so there is nothing to overlay.']),
454
+ '',
455
+ '## Slowest polls',
456
+ '',
457
+ ...(summary.slowest.length
458
+ ? [
459
+ ...table(
460
+ [
461
+ 'At',
462
+ 'Phase',
463
+ 'Probe',
464
+ 'Latency',
465
+ 'CMS requests in flight',
466
+ 'CMS profile id',
467
+ ],
468
+ summary.slowest.map((row) => [
469
+ offsetClock(row.atMs),
470
+ row.phase,
471
+ `\`${row.probe}\``,
472
+ duration(row.ms),
473
+ count(row.inFlight),
474
+ row.profileId ? `\`${row.profileId}\`` : '-',
475
+ ]),
476
+ ),
477
+ '',
478
+ ...(summary.slowest.some((row) => row.profileId)
479
+ ? [
480
+ '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.',
481
+ ]
482
+ : [
483
+ 'No response carried `x-cms-profile-id`, so these are latencies with nothing behind them. Run the target with `CMS_PROFILE=1` to join them to the CMS\u2019s own lines.',
484
+ ]),
485
+ ]
486
+ : ['No poll was answered, so there is nothing to rank.']),
487
+ '',
488
+ '## Poll by poll',
489
+ '',
490
+ ...table(
491
+ [
492
+ 'At',
493
+ 'Phase',
494
+ 'In flight',
495
+ '',
496
+ ...probeNames.map((name) => `\`${name}\``),
497
+ ],
498
+ summary.ticks.map((tick) => [
499
+ offsetClock(tick.atMs),
500
+ tick.phase,
501
+ count(tick.inFlight),
502
+ bar(tick.inFlight, peak, 8),
503
+ ...probeNames.map((name) => cell(tick.samples[name])),
504
+ ]),
505
+ ),
506
+ '',
507
+ '## Failed polls',
508
+ '',
509
+ ...(summary.errors.length
510
+ ? table(
511
+ ['At', 'Phase', 'Probe', 'Status', 'Error'],
512
+ summary.errors.map((row) => [
513
+ offsetClock(row.atMs),
514
+ row.phase,
515
+ `\`${row.probe}\``,
516
+ row.status === null ? '-' : count(row.status),
517
+ row.error ? escapePipes(row.error) : '-',
518
+ ]),
519
+ )
520
+ : ['Every poll was answered.']),
521
+ '',
522
+ ].join('\n')}\n`;
523
+ }
524
+
525
+ function buildBullets(build) {
526
+ if (!build) {
527
+ return [['Build', 'none — the harness polled and never built']];
528
+ }
529
+
530
+ return [
531
+ ['Build command', build.command ? `\`${build.command}\`` : null],
532
+ ['Build fetch log', build.logPath ? `\`${build.logPath}\`` : null],
533
+ ['Build wall time', build.wallMs === null ? null : duration(build.wallMs)],
534
+ [
535
+ 'Build outcome',
536
+ build.ok
537
+ ? 'exit 0'
538
+ : `**failed** — ${build.signal ? `signal ${build.signal}` : `exit ${build.exitCode}`}`,
539
+ ],
540
+ [
541
+ 'Build requests',
542
+ `${count(build.requests)}, of which ${count(build.cmsRequests)} to the CMS`,
543
+ ],
544
+ ];
545
+ }
546
+
547
+ /** A poll's latency, marked if the CMS did not answer it. */
548
+ function cell(poll) {
549
+ if (!poll) {
550
+ return '-';
551
+ }
552
+
553
+ if (!poll.ok) {
554
+ return `**${poll.status ?? 'error'}**`;
555
+ }
556
+
557
+ return duration(poll.ms);
558
+ }