@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.
- package/README.md +66 -0
- package/bin/dissect/cli.mjs +138 -0
- package/bin/dissect/dissect.mjs +290 -0
- package/bin/profile/build.mjs +106 -0
- package/bin/profile/fetch-log.mjs +298 -0
- package/bin/profile/format.mjs +90 -0
- package/bin/profile/interference-summary.mjs +558 -0
- package/bin/profile/interference.mjs +604 -0
- package/bin/profile/measure.mjs +137 -0
- package/bin/profile/report.mjs +90 -0
- package/bin/profile/site-env.mjs +16 -0
- package/bin/profile/summarize.mjs +429 -0
- package/dist/browser.d.ts +145 -0
- package/dist/browser.js +11 -0
- package/dist/browser.js.map +1 -0
- package/dist/chunk-6V54ITTK.js +197 -0
- package/dist/chunk-6V54ITTK.js.map +1 -0
- package/dist/chunk-MNZ7DIGC.js +51 -0
- package/dist/chunk-MNZ7DIGC.js.map +1 -0
- package/dist/form-proxy/upload-policy.d.ts +40 -0
- package/dist/form-proxy/upload-policy.js +17 -0
- package/dist/form-proxy/upload-policy.js.map +1 -0
- package/dist/index.d.ts +570 -0
- package/dist/index.js +1636 -0
- package/dist/index.js.map +1 -0
- package/dist/payload-types.d.ts +8985 -0
- package/dist/payload-types.js +1 -0
- package/dist/payload-types.js.map +1 -0
- package/package.json +74 -0
- package/src/api.ts +387 -0
- package/src/blog-listing.ts +75 -0
- package/src/browser.ts +24 -0
- package/src/client.ts +144 -0
- package/src/cms-to-href.ts +70 -0
- package/src/cms.ts +86 -0
- package/src/collections/appearance.ts +94 -0
- package/src/collections/areas.ts +29 -0
- package/src/collections/authors.ts +27 -0
- package/src/collections/banners.ts +14 -0
- package/src/collections/categories.ts +111 -0
- package/src/collections/forms.ts +29 -0
- package/src/collections/header-footer.ts +19 -0
- package/src/collections/image-links.ts +14 -0
- package/src/collections/media.ts +18 -0
- package/src/collections/options.ts +10 -0
- package/src/collections/pages.ts +83 -0
- package/src/collections/posts.ts +249 -0
- package/src/collections/project.ts +16 -0
- package/src/collections/questions.ts +35 -0
- package/src/collections/seo.ts +10 -0
- package/src/collections/tags.ts +25 -0
- package/src/collections/team-members.ts +79 -0
- package/src/config-time.ts +98 -0
- package/src/context.ts +12 -0
- package/src/decode-html.ts +8 -0
- package/src/form-proxy/cms-client.ts +95 -0
- package/src/form-proxy/cms-errors.ts +73 -0
- package/src/form-proxy/cms-write.ts +44 -0
- package/src/form-proxy/http.ts +96 -0
- package/src/form-proxy/index.ts +73 -0
- package/src/form-proxy/rate-limit.ts +46 -0
- package/src/form-proxy/submissions.ts +88 -0
- package/src/form-proxy/types.ts +23 -0
- package/src/form-proxy/upload-policy.ts +92 -0
- package/src/form-proxy/uploads.ts +81 -0
- package/src/home-page.ts +83 -0
- package/src/index.ts +68 -0
- package/src/loader.ts +83 -0
- package/src/locales.ts +80 -0
- package/src/payload-types.ts +10854 -0
- package/src/placeholder.ts +9 -0
- package/src/resolve-menu-items.ts +184 -0
- package/src/routes.ts +184 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Puts a build and an editor on the same clock.
|
|
4
|
+
*
|
|
5
|
+
* pnpm profile:interference
|
|
6
|
+
* pnpm profile:interference --tag "m10 cluster"
|
|
7
|
+
* pnpm profile:interference --idle 30 --collection posts
|
|
8
|
+
*
|
|
9
|
+
* "The admin is slower while a build runs" is the reported symptom and the one
|
|
10
|
+
* nothing measures. This polls a fixed set of REST requests every two seconds
|
|
11
|
+
* through three phases — idle, a profiled build, idle again — and reports what
|
|
12
|
+
* each one cost in each phase, joined to how many build requests were in
|
|
13
|
+
* flight at that moment.
|
|
14
|
+
*
|
|
15
|
+
* REST latency stands in for admin latency: the admin UI is served by the same
|
|
16
|
+
* functions against the same database. Timing the admin UI itself — list view,
|
|
17
|
+
* open document, autosave, publish — belongs on the CMS side.
|
|
18
|
+
*
|
|
19
|
+
* The default target is whatever `API_BASE_URL` resolves to, which is the demo
|
|
20
|
+
* CMS in this repository's `.env`. Pointing it at a client CMS means editing
|
|
21
|
+
* that variable, which is a deliberate act rather than a flag default.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn } from 'node:child_process';
|
|
25
|
+
import {
|
|
26
|
+
appendFileSync,
|
|
27
|
+
existsSync,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
readFileSync,
|
|
30
|
+
writeFileSync,
|
|
31
|
+
} from 'node:fs';
|
|
32
|
+
import { dirname, join, resolve } from 'node:path';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
import { loadSiteEnv } from './site-env.mjs';
|
|
35
|
+
import {
|
|
36
|
+
parsePollLog,
|
|
37
|
+
renderInterferenceMarkdown,
|
|
38
|
+
summarizeInterference,
|
|
39
|
+
} from './interference-summary.mjs';
|
|
40
|
+
import { LOG_DIR } from './report.mjs';
|
|
41
|
+
import { parseLog } from './summarize.mjs';
|
|
42
|
+
|
|
43
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
|
|
45
|
+
/** A page of the list view, as the admin pages it. */
|
|
46
|
+
const LIST_LIMIT = 10;
|
|
47
|
+
|
|
48
|
+
/** What the site fetches a collection at, and what the edit view resolves. */
|
|
49
|
+
const DOCUMENT_DEPTH = 2;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The CMS's own id for the request it just served, echoed on the response by a
|
|
53
|
+
* deployment running with `CMS_PROFILE=1` (`PROFILE_ID_HEADER` in the CMS's
|
|
54
|
+
* `src/profiling/requestProfile.ts`). Recording it is what lets a slow poll be
|
|
55
|
+
* looked up as a `cms.profile.request` line and opened into the commands and
|
|
56
|
+
* hooks behind it, rather than staying a number with no explanation. Null when
|
|
57
|
+
* the target is not profiling, which is the normal case.
|
|
58
|
+
*/
|
|
59
|
+
const PROFILE_ID_HEADER = 'x-cms-profile-id';
|
|
60
|
+
|
|
61
|
+
const DEFAULTS = {
|
|
62
|
+
/** Every 2s. Short enough to see a burst, long enough not to be load. */
|
|
63
|
+
intervalMs: 2000,
|
|
64
|
+
/** Each idle phase. One minute of quiet on either side of the build. */
|
|
65
|
+
idleMs: 60_000,
|
|
66
|
+
/** The collection whose list and whose newest document stand in for the admin's. */
|
|
67
|
+
collection: 'posts',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const options = parseArgs(process.argv.slice(2));
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The phase the run is in: written by `phase`, read by the poll loop.
|
|
74
|
+
*
|
|
75
|
+
* A poll is labelled with the phase it started in rather than the one it
|
|
76
|
+
* finished in. A poll that goes out during the build and lands after it is a
|
|
77
|
+
* measurement of the build.
|
|
78
|
+
*/
|
|
79
|
+
let currentPhase = 'before';
|
|
80
|
+
|
|
81
|
+
// Resolved in the mode the build will resolve it in — `astro build` runs Vite
|
|
82
|
+
// in `production` — so the harness and the build it spawns are pointed at one
|
|
83
|
+
// CMS even on a site that keeps an `.env.production`. `build.mjs` reads it the
|
|
84
|
+
// same way for the same reason.
|
|
85
|
+
const env = await loadSiteEnv(process.env.NODE_ENV ?? 'production');
|
|
86
|
+
const { API_BASE_URL, CMS_API_KEY, PROJECT_SLUG } = env;
|
|
87
|
+
|
|
88
|
+
if (!API_BASE_URL || !CMS_API_KEY || !PROJECT_SLUG) {
|
|
89
|
+
fail(
|
|
90
|
+
"API_BASE_URL, CMS_API_KEY and PROJECT_SLUG all have to be set. A local .env.local wins over .env in Vite's precedence, so check which one is in play.",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The scheme names the collection the key belongs to, and the harness holds
|
|
95
|
+
// the same credential the site builds with: an `api-keys` document, scoped to
|
|
96
|
+
// one project and able to write nothing. `@postedin/cms-client`'s `api.ts` is where
|
|
97
|
+
// that decision lives; sending `users` here would either fail to authenticate
|
|
98
|
+
// or, with somebody's personal key, measure a principal no build ever uses.
|
|
99
|
+
const CMS_API_KEY_COLLECTION = 'api-keys';
|
|
100
|
+
|
|
101
|
+
const headers = {
|
|
102
|
+
Authorization: `${CMS_API_KEY_COLLECTION} API-Key ${CMS_API_KEY}`,
|
|
103
|
+
...(env.CMS_VERCEL_AUTOMATION_BYPASS_SECRET
|
|
104
|
+
? { 'x-vercel-protection-bypass': env.CMS_VERCEL_AUTOMATION_BYPASS_SECRET }
|
|
105
|
+
: {}),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const stamp = new Date().toISOString().slice(0, 19).replaceAll(':', '-');
|
|
109
|
+
const pollLogPath = resolve(LOG_DIR, `interference-${stamp}.ndjson`);
|
|
110
|
+
const buildLogPath = resolve(LOG_DIR, `interference-${stamp}-build.ndjson`);
|
|
111
|
+
const reportPath = pollLogPath.replace(/\.ndjson$/, '.md');
|
|
112
|
+
|
|
113
|
+
mkdirSync(dirname(pollLogPath), { recursive: true });
|
|
114
|
+
|
|
115
|
+
await main();
|
|
116
|
+
|
|
117
|
+
async function main() {
|
|
118
|
+
const probes = await buildProbes();
|
|
119
|
+
|
|
120
|
+
console.log(`Polling ${API_BASE_URL} every ${options.intervalMs / 1000}s.`);
|
|
121
|
+
console.log(`Log: ${pollLogPath}\n`);
|
|
122
|
+
|
|
123
|
+
writeLine({
|
|
124
|
+
kind: 'meta',
|
|
125
|
+
event: 'start',
|
|
126
|
+
startedAt: Date.now(),
|
|
127
|
+
cmsOrigin: new URL(API_BASE_URL).origin,
|
|
128
|
+
project: PROJECT_SLUG,
|
|
129
|
+
collection: options.collection,
|
|
130
|
+
documentId: probes.documentId,
|
|
131
|
+
tag: options.tag,
|
|
132
|
+
intervalMs: options.intervalMs,
|
|
133
|
+
idleMs: options.idleMs,
|
|
134
|
+
probes: probes.list.map(({ name, request, description }) => ({
|
|
135
|
+
name,
|
|
136
|
+
request,
|
|
137
|
+
description,
|
|
138
|
+
})),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const poller = startPolling(probes.list);
|
|
142
|
+
|
|
143
|
+
await phase('before', () => idle(options.idleMs));
|
|
144
|
+
|
|
145
|
+
const build = await phase('during', () => runBuild());
|
|
146
|
+
|
|
147
|
+
await phase('after', () => idle(options.idleMs));
|
|
148
|
+
|
|
149
|
+
poller.stop();
|
|
150
|
+
await poller.drained();
|
|
151
|
+
|
|
152
|
+
writeLine({
|
|
153
|
+
kind: 'meta',
|
|
154
|
+
event: 'end',
|
|
155
|
+
endedAt: Date.now(),
|
|
156
|
+
polls: poller.polls(),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
report();
|
|
160
|
+
|
|
161
|
+
if (!build.ok) {
|
|
162
|
+
// A build that failed still produced latency worth reading, so the
|
|
163
|
+
// report is written either way — but the run did not measure what it
|
|
164
|
+
// set out to, and the exit code has to say so.
|
|
165
|
+
console.error(
|
|
166
|
+
`\nThe build failed: ${build.signal ? `signal ${build.signal}` : `exit ${build.exitCode}`}. The report covers what was polled, not a successful build.`,
|
|
167
|
+
);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Announces a phase, runs it, and labels every poll that starts inside it. */
|
|
173
|
+
async function phase(name, run) {
|
|
174
|
+
currentPhase = name;
|
|
175
|
+
writeLine({ kind: 'meta', event: 'phase', phase: name, at: Date.now() });
|
|
176
|
+
console.log(`[${name}]`);
|
|
177
|
+
|
|
178
|
+
return await run();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The three requests the admin issues most, resolved against real data and
|
|
183
|
+
* checked before the run starts.
|
|
184
|
+
*
|
|
185
|
+
* The document probe needs an id, and the honest one is whatever an editor
|
|
186
|
+
* touched last — the same document the admin's list view puts at the top. It
|
|
187
|
+
* costs one request before the run, outside every phase, and so does the check
|
|
188
|
+
* each probe then gets.
|
|
189
|
+
*
|
|
190
|
+
* Every probe has to be a request this credential genuinely performs. An
|
|
191
|
+
* `api-keys` document is not a person, so `/api/users/me` answers it `200
|
|
192
|
+
* {"user":null}` — a shape that looks like a measurement and is really an
|
|
193
|
+
* unauthenticated stub, contributing a few hundred milliseconds of "admin
|
|
194
|
+
* latency" that no admin ever waits. The floor probe below is what replaced
|
|
195
|
+
* it: the same question — what does a request cost before it does any work —
|
|
196
|
+
* asked in a way that 403s when the credential is wrong.
|
|
197
|
+
*/
|
|
198
|
+
async function buildProbes() {
|
|
199
|
+
const listRequest = listUrl();
|
|
200
|
+
const newest = await probeJson(listRequest, 'the list-view probe');
|
|
201
|
+
const documentId = newest.docs?.[0]?.id;
|
|
202
|
+
|
|
203
|
+
if (!documentId) {
|
|
204
|
+
fail(
|
|
205
|
+
`No documents in \`${options.collection}\` for project \`${PROJECT_SLUG}\`, so there is no edit view to time. Pass --collection.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const documentRequest = `${API_BASE_URL}/api/${options.collection}/${documentId}?depth=${DOCUMENT_DEPTH}`;
|
|
210
|
+
const floorRequest = floorUrl();
|
|
211
|
+
|
|
212
|
+
const probes = [
|
|
213
|
+
{
|
|
214
|
+
name: 'list',
|
|
215
|
+
request: pathAndQuery(listRequest),
|
|
216
|
+
url: listRequest,
|
|
217
|
+
description: `The list view: ${LIST_LIMIT} documents, newest first, filtered to the project`,
|
|
218
|
+
// Already in hand: this is the request the document id came from.
|
|
219
|
+
answered: newest,
|
|
220
|
+
check: (body) =>
|
|
221
|
+
body.docs?.length ? null : 'it carried no `docs` to have cost anything',
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: 'document',
|
|
225
|
+
request: pathAndQuery(documentRequest),
|
|
226
|
+
url: documentRequest,
|
|
227
|
+
description: `The edit view: one document by id at depth ${DOCUMENT_DEPTH}, relationships resolved`,
|
|
228
|
+
check: (body) =>
|
|
229
|
+
body.id === documentId
|
|
230
|
+
? null
|
|
231
|
+
: `it carried \`${body.id ?? 'no id'}\` rather than the document that was asked for`,
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: 'floor',
|
|
235
|
+
request: pathAndQuery(floorRequest),
|
|
236
|
+
url: floorRequest,
|
|
237
|
+
description:
|
|
238
|
+
'The fixed cost of an authenticated, project-scoped read: one document, nothing resolved',
|
|
239
|
+
check: (body) =>
|
|
240
|
+
body.docs?.length ? null : 'it carried no `docs` to have cost anything',
|
|
241
|
+
},
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
for (const probe of probes) {
|
|
245
|
+
await verifyProbe(probe);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { documentId, list: probes };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Refuses to start a run whose probe answers without measuring anything.
|
|
253
|
+
*
|
|
254
|
+
* A 200 is not enough: an endpoint can answer an unauthenticated caller with an
|
|
255
|
+
* empty shape, at full latency, and every table downstream will report those
|
|
256
|
+
* milliseconds as what an editor waits. The check is per probe because what
|
|
257
|
+
* counts as an answer differs — a page of documents, one document by id — and
|
|
258
|
+
* it runs once, before the first phase, so a misconfigured run costs a request
|
|
259
|
+
* rather than three minutes. A probe whose answer is already in hand — the
|
|
260
|
+
* list, which is where the document id came from — is not asked twice.
|
|
261
|
+
*/
|
|
262
|
+
async function verifyProbe(probe) {
|
|
263
|
+
const body =
|
|
264
|
+
probe.answered ??
|
|
265
|
+
(await probeJson(probe.url, `the \`${probe.name}\` probe`));
|
|
266
|
+
const complaint = probe.check(body);
|
|
267
|
+
|
|
268
|
+
if (complaint) {
|
|
269
|
+
fail(
|
|
270
|
+
`The \`${probe.name}\` probe (${probe.request}) answered 200 but ${complaint}. Timing that would measure an empty round trip, so the run stops here.`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The list view's query, in the shape the admin sends it: a page of ten,
|
|
277
|
+
* newest edit first, scoped to one project. `qs-esm` is not imported here
|
|
278
|
+
* because this script has to run before anything is built.
|
|
279
|
+
*
|
|
280
|
+
* `depth=0` because a list view resolves no relationships. The edit view does,
|
|
281
|
+
* and it is a different endpoint — `/api/{collection}/{id}`, which takes no
|
|
282
|
+
* `where` — so it needs no builder at all.
|
|
283
|
+
*/
|
|
284
|
+
function listUrl() {
|
|
285
|
+
const params = new URLSearchParams({
|
|
286
|
+
limit: String(LIST_LIMIT),
|
|
287
|
+
depth: '0',
|
|
288
|
+
sort: '-updatedAt',
|
|
289
|
+
'where[and][0][project.slug][equals]': PROJECT_SLUG,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return `${API_BASE_URL}/api/${options.collection}?${params}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The same query asked for a single document, unsorted and unresolved.
|
|
297
|
+
*
|
|
298
|
+
* What is left when a read returns almost nothing is what every read pays
|
|
299
|
+
* whatever it asks for: the round trip, authenticating the key, resolving the
|
|
300
|
+
* project it is scoped to. Against the list probe's ten documents it is also
|
|
301
|
+
* the one comparison this harness can make on its own — how much of a list view
|
|
302
|
+
* is the page of documents, and how much is the request itself.
|
|
303
|
+
*/
|
|
304
|
+
function floorUrl() {
|
|
305
|
+
const params = new URLSearchParams({
|
|
306
|
+
limit: '1',
|
|
307
|
+
depth: '0',
|
|
308
|
+
'where[and][0][project.slug][equals]': PROJECT_SLUG,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
return `${API_BASE_URL}/api/${options.collection}?${params}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Everything after the origin, which is what the report prints. */
|
|
315
|
+
function pathAndQuery(url) {
|
|
316
|
+
const parsed = new URL(url);
|
|
317
|
+
|
|
318
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Polls every probe on a fixed cadence for the life of the run.
|
|
323
|
+
*
|
|
324
|
+
* A tick that is still in flight when the next one is due is not doubled up on:
|
|
325
|
+
* the harness is meant to measure the CMS under a build's load, not to add its
|
|
326
|
+
* own. The skip is logged, because it only happens when latency has grown past
|
|
327
|
+
* the interval and that is a fact about the run.
|
|
328
|
+
*/
|
|
329
|
+
function startPolling(probes) {
|
|
330
|
+
let stopped = false;
|
|
331
|
+
let ticks = 0;
|
|
332
|
+
let polls = 0;
|
|
333
|
+
let busy = false;
|
|
334
|
+
const settled = [];
|
|
335
|
+
|
|
336
|
+
const timer = setInterval(() => {
|
|
337
|
+
if (stopped) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (busy) {
|
|
342
|
+
writeLine({
|
|
343
|
+
kind: 'meta',
|
|
344
|
+
event: 'skipped',
|
|
345
|
+
phase: currentPhase,
|
|
346
|
+
at: Date.now(),
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const tick = ++ticks;
|
|
353
|
+
const phase = currentPhase;
|
|
354
|
+
|
|
355
|
+
busy = true;
|
|
356
|
+
|
|
357
|
+
settled.push(
|
|
358
|
+
Promise.all(
|
|
359
|
+
probes.map((probe) =>
|
|
360
|
+
pollOnce(probe, tick, phase).then(() => {
|
|
361
|
+
polls += 1;
|
|
362
|
+
}),
|
|
363
|
+
),
|
|
364
|
+
).finally(() => {
|
|
365
|
+
busy = false;
|
|
366
|
+
}),
|
|
367
|
+
);
|
|
368
|
+
}, options.intervalMs);
|
|
369
|
+
|
|
370
|
+
// The harness's own clock must not be what holds the process open.
|
|
371
|
+
timer.unref?.();
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
stop() {
|
|
375
|
+
stopped = true;
|
|
376
|
+
clearInterval(timer);
|
|
377
|
+
},
|
|
378
|
+
drained: () => Promise.allSettled(settled),
|
|
379
|
+
polls: () => polls,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** One probe, one line: latency to the last byte, and what came back. */
|
|
384
|
+
async function pollOnce(probe, tick, phase) {
|
|
385
|
+
const startedAt = Date.now();
|
|
386
|
+
const mark = performance.now();
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
const response = await fetch(probe.url, { headers });
|
|
390
|
+
const ttfbMs = round(performance.now() - mark);
|
|
391
|
+
const body = await response.arrayBuffer();
|
|
392
|
+
|
|
393
|
+
writeLine({
|
|
394
|
+
kind: 'poll',
|
|
395
|
+
tick,
|
|
396
|
+
phase,
|
|
397
|
+
probe: probe.name,
|
|
398
|
+
startedAt,
|
|
399
|
+
endedAt: Date.now(),
|
|
400
|
+
ttfbMs,
|
|
401
|
+
ms: round(performance.now() - mark),
|
|
402
|
+
status: response.status,
|
|
403
|
+
ok: response.ok,
|
|
404
|
+
bytes: body.byteLength,
|
|
405
|
+
profileId: response.headers.get(PROFILE_ID_HEADER),
|
|
406
|
+
});
|
|
407
|
+
} catch (error) {
|
|
408
|
+
writeLine({
|
|
409
|
+
kind: 'poll',
|
|
410
|
+
tick,
|
|
411
|
+
phase,
|
|
412
|
+
probe: probe.name,
|
|
413
|
+
startedAt,
|
|
414
|
+
endedAt: Date.now(),
|
|
415
|
+
ttfbMs: null,
|
|
416
|
+
ms: round(performance.now() - mark),
|
|
417
|
+
status: null,
|
|
418
|
+
ok: false,
|
|
419
|
+
bytes: null,
|
|
420
|
+
profileId: null,
|
|
421
|
+
error: String(error?.message ?? error),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Runs the build `pnpm build:profile` runs, with its fetch log named here so
|
|
428
|
+
* the report can join the two by timestamp.
|
|
429
|
+
*
|
|
430
|
+
* `pnpm` is not spawned; `build:profile` is `cms-profile-build`, which is
|
|
431
|
+
* `build.mjs` beside this file,
|
|
432
|
+
* and calling it directly keeps the harness independent of PATH.
|
|
433
|
+
*/
|
|
434
|
+
function runBuild() {
|
|
435
|
+
const script = join(here, 'build.mjs');
|
|
436
|
+
const command = `node ${relativeToCwd(script)}`;
|
|
437
|
+
const startedAt = Date.now();
|
|
438
|
+
|
|
439
|
+
console.log(`Starting the build: ${command}\n`);
|
|
440
|
+
|
|
441
|
+
return new Promise((settle) => {
|
|
442
|
+
const build = spawn(process.execPath, [script, ...options.buildArgs], {
|
|
443
|
+
stdio: 'inherit',
|
|
444
|
+
env: {
|
|
445
|
+
...process.env,
|
|
446
|
+
PROFILE_FETCH_LOG: buildLogPath,
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
build.on('error', (error) => {
|
|
451
|
+
writeLine({
|
|
452
|
+
kind: 'meta',
|
|
453
|
+
event: 'build',
|
|
454
|
+
command,
|
|
455
|
+
logPath: buildLogPath,
|
|
456
|
+
startedAt,
|
|
457
|
+
endedAt: Date.now(),
|
|
458
|
+
exitCode: null,
|
|
459
|
+
signal: null,
|
|
460
|
+
error: String(error?.message ?? error),
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
settle({ ok: false, exitCode: null, signal: null });
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
build.on('exit', (exitCode, signal) => {
|
|
467
|
+
writeLine({
|
|
468
|
+
kind: 'meta',
|
|
469
|
+
event: 'build',
|
|
470
|
+
command,
|
|
471
|
+
logPath: buildLogPath,
|
|
472
|
+
startedAt,
|
|
473
|
+
endedAt: Date.now(),
|
|
474
|
+
exitCode,
|
|
475
|
+
signal,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
settle({ ok: exitCode === 0 && !signal, exitCode, signal });
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function idle(ms) {
|
|
484
|
+
return new Promise((settle) => {
|
|
485
|
+
setTimeout(settle, ms);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Renders the poll log against the build's fetch log. */
|
|
490
|
+
function report() {
|
|
491
|
+
const { polls, meta } = parsePollLog(readFileSync(pollLogPath, 'utf-8'));
|
|
492
|
+
const buildRequests = existsSync(buildLogPath)
|
|
493
|
+
? parseLog(readFileSync(buildLogPath, 'utf-8')).requests
|
|
494
|
+
: [];
|
|
495
|
+
|
|
496
|
+
const markdown = renderInterferenceMarkdown(
|
|
497
|
+
summarizeInterference({ polls, meta, buildRequests }),
|
|
498
|
+
{ logPath: pollLogPath, generatedAt: new Date().toISOString() },
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
writeFileSync(reportPath, markdown);
|
|
502
|
+
|
|
503
|
+
console.log(`\nPoll log: ${pollLogPath}`);
|
|
504
|
+
console.log(`Build log: ${buildLogPath}`);
|
|
505
|
+
console.log(`Report: ${reportPath}`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function writeLine(record) {
|
|
509
|
+
try {
|
|
510
|
+
appendFileSync(pollLogPath, `${JSON.stringify(record)}\n`);
|
|
511
|
+
} catch (error) {
|
|
512
|
+
process.emitWarning(
|
|
513
|
+
`interference: could not append to ${pollLogPath}: ${error.message}`,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function probeJson(url, what) {
|
|
519
|
+
let response;
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
response = await fetch(url, { headers });
|
|
523
|
+
} catch (error) {
|
|
524
|
+
fail(`Could not reach the CMS for ${what}: ${error?.message ?? error}`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (!response.ok) {
|
|
528
|
+
fail(
|
|
529
|
+
`The CMS answered ${response.status} ${response.statusText} for ${what} (${pathAndQuery(url)}).`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return await response.json();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function parseArgs(argv) {
|
|
537
|
+
const parsed = {
|
|
538
|
+
...DEFAULTS,
|
|
539
|
+
tag: null,
|
|
540
|
+
buildArgs: [],
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
544
|
+
const argument = argv[index];
|
|
545
|
+
|
|
546
|
+
if (argument === '--tag') {
|
|
547
|
+
parsed.tag = argv[++index] ?? null;
|
|
548
|
+
} else if (argument === '--collection') {
|
|
549
|
+
parsed.collection = argv[++index] ?? parsed.collection;
|
|
550
|
+
} else if (argument === '--idle') {
|
|
551
|
+
parsed.idleMs = seconds('--idle', argv[++index]);
|
|
552
|
+
} else if (argument === '--interval') {
|
|
553
|
+
parsed.intervalMs = seconds('--interval', argv[++index]);
|
|
554
|
+
} else if (argument === '--') {
|
|
555
|
+
parsed.buildArgs.push(...argv.slice(index + 1));
|
|
556
|
+
break;
|
|
557
|
+
} else if (argument === '--help' || argument === '-h') {
|
|
558
|
+
console.log(usage());
|
|
559
|
+
process.exit(0);
|
|
560
|
+
} else {
|
|
561
|
+
// A run costs three minutes, so a typo says so now rather than
|
|
562
|
+
// after the build has failed on an argument astro never wanted.
|
|
563
|
+
fail(
|
|
564
|
+
`Unknown argument \`${argument}\`. Build arguments go after \`--\`.\n\n${usage()}`,
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return parsed;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function seconds(flag, value) {
|
|
573
|
+
const parsed = Number(value);
|
|
574
|
+
|
|
575
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
576
|
+
fail(`${flag} wants a number of seconds, not \`${value}\`.`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return parsed * 1000;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function usage() {
|
|
583
|
+
return `Usage: cms-profile-interference [options] [-- astro build args]
|
|
584
|
+
|
|
585
|
+
--tag <label> Recorded in the report, for runs that vary one thing
|
|
586
|
+
--collection <name> The collection to poll (default ${DEFAULTS.collection})
|
|
587
|
+
--idle <seconds> Each idle phase (default ${DEFAULTS.idleMs / 1000})
|
|
588
|
+
--interval <seconds> Poll interval (default ${DEFAULTS.intervalMs / 1000})`;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function relativeToCwd(path) {
|
|
592
|
+
const cwd = `${process.cwd()}/`;
|
|
593
|
+
|
|
594
|
+
return path.startsWith(cwd) ? path.slice(cwd.length) : path;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function round(value) {
|
|
598
|
+
return Math.round(value * 10) / 10;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function fail(message) {
|
|
602
|
+
console.error(message);
|
|
603
|
+
process.exit(1);
|
|
604
|
+
}
|