@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
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @postedin/cms-client
2
+
3
+ Everything a postedin Astro site reads from Payload CMS, and the form proxy it
4
+ writes through: documents in, render inputs out.
5
+
6
+ ```ts
7
+ import { createClient } from '@postedin/cms-client';
8
+
9
+ export const client = createClient({
10
+ apiUrl: import.meta.env.API_BASE_URL,
11
+ apiKey: import.meta.env.CMS_API_KEY,
12
+ projectSlug: import.meta.env.PROJECT_SLUG,
13
+ uploadsBaseUrl: import.meta.env.S3_PUBLIC_URL,
14
+ protectionBypassSecret: process.env.CMS_VERCEL_AUTOMATION_BYPASS_SECRET,
15
+ isDev: import.meta.env.DEV,
16
+ appearance: __APPEARANCE__, // fetched at config time
17
+ project: __PROJECT__, // fetched at config time
18
+ locales: ['es', 'en'],
19
+ defaultLocale: 'es',
20
+ localePrefix: { es: '', en: '/en' },
21
+ });
22
+
23
+ const cms = client.cms('en');
24
+ const posts = await cms.posts.all();
25
+ const routes = client.routes('en', await client.getHomePageRef('en'));
26
+ ```
27
+
28
+ The package reads no environment. The site builds the client from its own
29
+ `import.meta.env`, because Astro externalises an installed package and would
30
+ leave `import.meta.env` empty inside it.
31
+
32
+ ## Entry points
33
+
34
+ - `@postedin/cms-client` — `createClient` and everything server-side.
35
+ - `@postedin/cms-client/browser` — `defineLocales`, `defineRoutes`,
36
+ `defineCmsToHref` and the link types. No credential, no fetch: safe in a
37
+ hydrated island.
38
+ - `@postedin/cms-client/upload-policy` — the CMS's upload limits, read by the
39
+ form islands and the proxy alike.
40
+ - `@postedin/cms-client/payload-types` — the CMS's generated types.
41
+
42
+ ## Options a site may need
43
+
44
+ - `resolveUploadUrl(media, use)` — the site's own rule for where an upload is
45
+ served from, asked in every production build; return `undefined` to use
46
+ the bucket, or the CMS's own URL when there is none.
47
+ - `extensions` — collections only this site reads. Each is built per locale
48
+ from `{ api, locale, cms, createCollectionLoader, createGlobalLoader }` and
49
+ appears on `client.cms(locale)` under its key.
50
+ - `appearanceFallbacks` — what a blank Appearance colour falls back to.
51
+ - `pageConcurrency` — collection pages fetched at a time (default 4).
52
+
53
+ ## Binaries
54
+
55
+ Run from a site's directory:
56
+
57
+ - `cms-profile-build`, `cms-profile-report`, `cms-profile-interference` — see
58
+ the template's `docs/performance/README.md`.
59
+ - `cms-dissect-build-log` — a Vercel build log split into phases and page
60
+ groups; `--deployment <id> --team <id>` reads the full log with
61
+ `VERCEL_TOKEN`, `--labels labels.json` names the groups.
62
+
63
+ ## Versioning
64
+
65
+ Minor for an additive schema change, major for a removal. Sites in this
66
+ repository use `workspace:*`; a client's own repository pins an exact version.
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Dissects a Vercel build log.
4
+ *
5
+ * cms-dissect-build-log build.log # `vercel inspect --logs` output
6
+ * cms-dissect-build-log events.json # `/v3/deployments/{id}/events`
7
+ * cms-dissect-build-log --deployment dpl_… --team team_…
8
+ * … --labels labels.json --out report.md --json summary.json
9
+ *
10
+ * `--deployment` reads the whole log from the Vercel API with `VERCEL_TOKEN`.
11
+ * Both other routes to it are capped: `vercel inspect --logs` prints the last
12
+ * 10,000 lines and the events endpoint answers at most 10,000 per request, so
13
+ * this pages backwards until it has every line. It also reads the deployment's
14
+ * `buildingAt`, which is what every time in the report is measured from.
15
+ *
16
+ * `--labels` names page groups; see `dissect.mjs` for the shape.
17
+ */
18
+
19
+ import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
20
+ import { resolve } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { dissect, parseLog, renderMarkdown } from './dissect.mjs';
23
+
24
+ const API = 'https://api.vercel.com';
25
+
26
+ function parseArgs(argv) {
27
+ const args = { file: null };
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const arg = argv[i];
30
+ if (arg.startsWith('--')) {
31
+ args[arg.slice(2)] = argv[++i];
32
+ } else {
33
+ args.file = arg;
34
+ }
35
+ }
36
+ return args;
37
+ }
38
+
39
+ async function vercel(path, token) {
40
+ const res = await fetch(`${API}${path}`, {
41
+ headers: { Authorization: `Bearer ${token}` },
42
+ });
43
+ if (!res.ok) {
44
+ throw new Error(`${res.status} from ${path}: ${await res.text()}`);
45
+ }
46
+ return await res.json();
47
+ }
48
+
49
+ /** Every event of a deployment's build, oldest first. */
50
+ export async function fetchDeploymentLog(deployment, team, token) {
51
+ const scope = team ? `&teamId=${encodeURIComponent(team)}` : '';
52
+ const byId = new Map();
53
+ let until = null;
54
+
55
+ for (;;) {
56
+ const page = await vercel(
57
+ `/v3/deployments/${deployment}/events?builds=1&limit=-1&direction=backward${scope}${until ? `&until=${until}` : ''}`,
58
+ token,
59
+ );
60
+ const fresh = page.filter((event) => !byId.has(event.id));
61
+ for (const event of page) {
62
+ byId.set(event.id, event);
63
+ }
64
+ if (fresh.length === 0) {
65
+ break;
66
+ }
67
+ until = Math.min(...page.map((event) => event.created));
68
+ }
69
+
70
+ const events = [...byId.values()].sort(
71
+ (a, b) =>
72
+ a.created - b.created || (a.serial ?? '').localeCompare(b.serial ?? ''),
73
+ );
74
+ const { buildingAt } = await vercel(
75
+ `/v13/deployments/${deployment}?${scope.slice(1)}`,
76
+ token,
77
+ );
78
+
79
+ return { events, buildingAt };
80
+ }
81
+
82
+ async function main(argv) {
83
+ const args = parseArgs(argv);
84
+ let input;
85
+ let origin = args.origin ? Number(args.origin) : undefined;
86
+
87
+ if (args.deployment) {
88
+ const token = process.env.VERCEL_TOKEN;
89
+ if (!token) {
90
+ throw new Error(
91
+ '--deployment reads the Vercel API and needs VERCEL_TOKEN.',
92
+ );
93
+ }
94
+ const { events, buildingAt } = await fetchDeploymentLog(
95
+ args.deployment,
96
+ args.team,
97
+ token,
98
+ );
99
+ input = events;
100
+ origin ??= buildingAt;
101
+ if (args['save-log']) {
102
+ writeFileSync(args['save-log'], JSON.stringify(events));
103
+ }
104
+ } else if (args.file) {
105
+ const raw = readFileSync(args.file, 'utf-8');
106
+ input = raw.trimStart().startsWith('[') ? JSON.parse(raw) : raw;
107
+ } else {
108
+ throw new Error('Give a log file, or --deployment <id> [--team <id>].');
109
+ }
110
+
111
+ const labels = args.labels
112
+ ? JSON.parse(readFileSync(args.labels, 'utf-8'))
113
+ : {};
114
+ const summary = dissect(parseLog(input), { origin, labels });
115
+ const markdown = renderMarkdown(summary, {
116
+ title: args.deployment ? `Build ${args.deployment}, dissected` : undefined,
117
+ });
118
+
119
+ if (args.json) {
120
+ writeFileSync(args.json, `${JSON.stringify(summary, null, '\t')}\n`);
121
+ }
122
+ if (args.out) {
123
+ writeFileSync(args.out, markdown);
124
+ console.log(args.out);
125
+ } else {
126
+ console.log(markdown);
127
+ }
128
+ }
129
+
130
+ if (
131
+ process.argv[1] &&
132
+ realpathSync(resolve(process.argv[1])) === fileURLToPath(import.meta.url)
133
+ ) {
134
+ main(process.argv.slice(2)).catch((error) => {
135
+ console.error(error.message);
136
+ process.exit(1);
137
+ });
138
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Dissects a Vercel build log of an Astro site: where the build's time went,
3
+ * phase by phase, and within prerendering, page group by page group.
4
+ *
5
+ * Pure: a list of `{ t, text }` lines in, a summary out. Reading the log from a
6
+ * file or from the Vercel API is `cli.mjs`'s job, so everything here can be
7
+ * tested against a fixture.
8
+ */
9
+
10
+ // Colour codes, which the events API keeps and `vercel inspect` strips.
11
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: that is what they are
12
+ const ANSI = /\x1b\[[0-9;]*m/g;
13
+
14
+ /**
15
+ * The lines that end each phase, in order. A phase runs from the end of the
16
+ * one before it (or the start of the log) to the first line matching its
17
+ * marker. Where a phase names more than one, the first is preferred and the
18
+ * rest are fallbacks for a log that lacks it. A phase whose markers never
19
+ * appear is left out and hands its time to the next one.
20
+ *
21
+ * Prerendering ends where Astro starts waiting on `astro:build:done`, so the
22
+ * adapter's rearranging and bundling after the last page count towards it.
23
+ */
24
+ export const PHASES = [
25
+ { name: 'Clone, restore build cache', until: [/^Running "vercel build"/] },
26
+ {
27
+ name: 'Install dependencies',
28
+ until: [/^Running "(pnpm|npm|yarn|bun) run build"/],
29
+ },
30
+ {
31
+ name: '`astro build` to the start of prerendering',
32
+ until: [/prerendering static routes/],
33
+ },
34
+ {
35
+ name: 'Prerendering static routes',
36
+ until: [/hook "astro:build:done"/, /\[build\] Rearranging server assets/],
37
+ prerender: true,
38
+ },
39
+ {
40
+ name: '`astro:build:done`',
41
+ until: [/\[build\] Server built in/, /\[build\] Complete!/],
42
+ },
43
+ { name: 'Packaging the output', until: [/^Build Completed in /] },
44
+ { name: 'Deploying outputs', until: [/^Deployment completed/] },
45
+ ];
46
+
47
+ const ROUTE_HEADER = /[▶λ] (\S.*?)\s*$/;
48
+ const PAGE_LINE = /[├└]─\s+(\S+)\s+\(\+([\d.]+)(ms|s)\)/;
49
+
50
+ /** Normalises either input format to `{ t, text }` in log order. */
51
+ export function parseLog(input) {
52
+ if (Array.isArray(input)) {
53
+ // Events from `/v3/deployments/{id}/events`.
54
+ return input
55
+ .filter((event) => typeof event.text === 'string')
56
+ .map((event) => ({
57
+ t: event.created,
58
+ text: event.text.replace(ANSI, ''),
59
+ }));
60
+ }
61
+
62
+ // `vercel inspect --logs`: `2026-09-18T22:22:50.810Z <text>`.
63
+ return String(input)
64
+ .split(/\r?\n/)
65
+ .map((line) => {
66
+ const match = line.match(/^(\d{4}-\d\d-\d\dT[\d:.]+Z)\s{2}(.*)$/);
67
+ return match
68
+ ? { t: Date.parse(match[1]), text: match[2].replace(ANSI, '') }
69
+ : null;
70
+ })
71
+ .filter(Boolean);
72
+ }
73
+
74
+ function renderMs(value, unit) {
75
+ const n = Number(value);
76
+ return unit === 's' ? Math.round(n * 1000) : n;
77
+ }
78
+
79
+ /**
80
+ * A label for every page, from the route that rendered it and the locale its
81
+ * path is in. `labels.routes` maps a route (`src/pages/...`) to a group name,
82
+ * or to `{ label, locale: false }` for a group that is not split by locale.
83
+ * A route it does not name is grouped under its own path. Redirects — Astro
84
+ * prints them as a header naming the URL rather than a source file — are one
85
+ * group.
86
+ */
87
+ function groupOf(route, path, labels) {
88
+ if (route === null || !route.startsWith('src/')) {
89
+ return labels.redirects ?? 'Redirects';
90
+ }
91
+
92
+ const entry = labels.routes?.[route];
93
+ const label = typeof entry === 'string' ? entry : (entry?.label ?? route);
94
+ const split = typeof entry === 'object' ? entry.locale !== false : true;
95
+
96
+ if (!split) {
97
+ return label;
98
+ }
99
+
100
+ const prefixes = Object.entries(
101
+ labels.locales ?? { '': 'default', '/en': 'en' },
102
+ ).sort(([a], [b]) => b.length - a.length);
103
+ const [, locale] = prefixes.find(
104
+ ([prefix]) => prefix && path.startsWith(`${prefix}/`),
105
+ ) ??
106
+ prefixes.find(([prefix]) => prefix === '') ?? ['', ''];
107
+
108
+ return locale ? `${label}, ${locale}` : label;
109
+ }
110
+
111
+ function median(sorted) {
112
+ if (sorted.length === 0) {
113
+ return 0;
114
+ }
115
+ const mid = Math.floor(sorted.length / 2);
116
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
117
+ }
118
+
119
+ /**
120
+ * @param {{ t: number, text: string }[]} lines
121
+ * @param {{ origin?: number, labels?: object, pauses?: number }} options
122
+ * `origin` is what every time is measured from — the deployment's
123
+ * `buildingAt`, so the phases sum to the build time the deployments API
124
+ * reports. Defaults to the first line.
125
+ */
126
+ export function dissect(lines, options = {}) {
127
+ const origin = options.origin ?? lines[0]?.t ?? 0;
128
+ const labels = options.labels ?? {};
129
+ const at = (t) => (t - origin) / 1000;
130
+
131
+ // Phases.
132
+ const phases = [];
133
+ let from = origin;
134
+ let cursor = 0;
135
+ let prerenderSpan = null;
136
+ for (const phase of PHASES) {
137
+ let index = -1;
138
+ for (const marker of phase.until) {
139
+ index = lines.findIndex(
140
+ (line, i) => i >= cursor && marker.test(line.text.trim()),
141
+ );
142
+ if (index !== -1) {
143
+ break;
144
+ }
145
+ }
146
+ if (index === -1) {
147
+ continue;
148
+ }
149
+ const to = lines[index].t;
150
+ phases.push({
151
+ name: phase.name,
152
+ from: at(from),
153
+ to: at(to),
154
+ seconds: at(to) - at(from),
155
+ });
156
+ if (phase.prerender) {
157
+ prerenderSpan = { from, to, startIndex: cursor, endIndex: index };
158
+ }
159
+ from = to;
160
+ cursor = index;
161
+ }
162
+
163
+ // Pages, and the gaps between them, inside prerendering only.
164
+ const groups = new Map();
165
+ const pauses = [];
166
+ let renderMsTotal = 0;
167
+ let pageCount = 0;
168
+ let gapMs = 0;
169
+ let tailMs = 0;
170
+
171
+ if (prerenderSpan) {
172
+ let route = null;
173
+ let previous = prerenderSpan.from;
174
+ let previousLabel = 'the start of prerendering';
175
+
176
+ for (let i = prerenderSpan.startIndex; i < prerenderSpan.endIndex; i++) {
177
+ const { t, text } = lines[i];
178
+ const page = text.match(PAGE_LINE);
179
+
180
+ if (!page) {
181
+ const header = text.match(ROUTE_HEADER);
182
+ if (header) {
183
+ route = header[1];
184
+ }
185
+ continue;
186
+ }
187
+
188
+ const ms = renderMs(page[2], page[3]);
189
+ const label = groupOf(route, page[1], labels);
190
+ const group = groups.get(label) ?? { label, pages: 0, ms: 0, each: [] };
191
+ group.pages++;
192
+ group.ms += ms;
193
+ group.each.push(ms);
194
+ groups.set(label, group);
195
+
196
+ // What the log does not account for between this page line and the
197
+ // one before it: time spent outside any page, which is where each
198
+ // route's `getStaticPaths` — its CMS fetches — runs.
199
+ const gap = t - previous - ms;
200
+ gapMs += gap;
201
+ pauses.push({ ms: gap, before: route ?? page[1], after: previousLabel });
202
+ previous = t;
203
+ previousLabel = route ?? page[1];
204
+ renderMsTotal += ms;
205
+ pageCount++;
206
+ }
207
+
208
+ tailMs = prerenderSpan.to - previous;
209
+ }
210
+
211
+ const byGroup = [...groups.values()]
212
+ .map((group) => {
213
+ const each = [...group.each].sort((a, b) => a - b);
214
+ return {
215
+ label: group.label,
216
+ pages: group.pages,
217
+ seconds: group.ms / 1000,
218
+ meanMs: group.ms / group.pages,
219
+ medianMs: median(each),
220
+ };
221
+ })
222
+ .sort((a, b) => b.seconds - a.seconds);
223
+
224
+ return {
225
+ totalSeconds: lines.length ? at(lines.at(-1).t) : 0,
226
+ phases,
227
+ prerender: prerenderSpan
228
+ ? {
229
+ seconds: (prerenderSpan.to - prerenderSpan.from) / 1000,
230
+ renderSeconds: renderMsTotal / 1000,
231
+ gapSeconds: gapMs / 1000,
232
+ tailSeconds: tailMs / 1000,
233
+ pages: pageCount,
234
+ }
235
+ : null,
236
+ pauses: pauses
237
+ .sort((a, b) => b.ms - a.ms)
238
+ .slice(0, options.pauses ?? 5)
239
+ .map((pause) => ({ ...pause, seconds: pause.ms / 1000 })),
240
+ byGroup,
241
+ };
242
+ }
243
+
244
+ const s1 = (n) => `${n.toFixed(1)} s`;
245
+ const ms1 = (n) => `${n.toFixed(1)} ms`;
246
+
247
+ export function renderMarkdown(
248
+ summary,
249
+ { title = 'Build log, dissected' } = {},
250
+ ) {
251
+ const out = [`# ${title}`, ''];
252
+
253
+ out.push('| Phase | Span | Cost |', '| --- | --- | --- |');
254
+ for (const phase of summary.phases) {
255
+ out.push(
256
+ `| ${phase.name} | ${phase.from.toFixed(1)} – ${phase.to.toFixed(1)} | ${s1(phase.seconds)} |`,
257
+ );
258
+ }
259
+ out.push('');
260
+
261
+ if (summary.prerender) {
262
+ const p = summary.prerender;
263
+ out.push(
264
+ '| Prerendering | Cost |',
265
+ '| --- | --- |',
266
+ `| Rendering, summed from ${p.pages.toLocaleString('en')} page lines | ${s1(p.renderSeconds)} |`,
267
+ `| Gaps between page lines — each route's \`getStaticPaths\` | ${s1(p.gapSeconds)} |`,
268
+ `| After the last page line | ${s1(p.tailSeconds)} |`,
269
+ '',
270
+ 'Largest gaps:',
271
+ '',
272
+ ...summary.pauses.map(
273
+ (pause) => `- ${s1(pause.seconds)} before \`${pause.before}\``,
274
+ ),
275
+ '',
276
+ );
277
+ }
278
+
279
+ out.push(
280
+ '| Group | Pages | Total | Mean | Median |',
281
+ '| --- | --- | --- | --- | --- |',
282
+ ...summary.byGroup.map(
283
+ (g) =>
284
+ `| ${g.label} | ${g.pages.toLocaleString('en')} | ${s1(g.seconds)} | ${ms1(g.meanMs)} | ${Math.round(g.medianMs)} ms |`,
285
+ ),
286
+ '',
287
+ );
288
+
289
+ return out.join('\n');
290
+ }
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runs one profiled build: `pnpm build:profile` in a site, which runs the
4
+ * package's `cms-profile-build` from the site's directory.
5
+ *
6
+ * Astro is spawned exactly as `pnpm build` spawns it, plus a `--import` that
7
+ * loads `fetch-log.mjs` into the process. `pnpm build` itself is untouched —
8
+ * the profiler is only ever present in a build started from here.
9
+ *
10
+ * This is also where the log's name and the CMS origin are decided, rather than
11
+ * in the preload, because `--import` applies to worker threads too: a name
12
+ * chosen in the preload would give every thread a file of its own, and the
13
+ * report would see a fraction of the build. One name in the environment means
14
+ * every thread appends to the same log.
15
+ */
16
+
17
+ import { spawn } from 'node:child_process';
18
+ import { existsSync, mkdirSync } from 'node:fs';
19
+ import { dirname, join, resolve } from 'node:path';
20
+ import { fileURLToPath, pathToFileURL } from 'node:url';
21
+ import { LOG_DIR, writeReport } from './report.mjs';
22
+ import { loadSiteEnv } from './site-env.mjs';
23
+
24
+ const here = dirname(fileURLToPath(import.meta.url));
25
+ const preload = pathToFileURL(join(here, 'fetch-log.mjs')).href;
26
+
27
+ // Resolved in the mode the build about to be spawned will resolve it in:
28
+ // `astro build` runs Vite in `production`, so an `.env.production` would be in
29
+ // play for the site's own `import.meta.env.API_BASE_URL` and has to be in play
30
+ // here too. Otherwise the profiler would classify requests against one origin
31
+ // while the build talked to another.
32
+ const { API_BASE_URL } = await loadSiteEnv(
33
+ process.env.NODE_ENV ?? 'production',
34
+ );
35
+
36
+ // A caller profiling a build against something other than what the env files
37
+ // say — a tunnel, a local CMS, a preview deployment — names the origin itself.
38
+ const cmsOrigin = process.env.PROFILE_CMS_ORIGIN || API_BASE_URL || '';
39
+
40
+ // Every request is classified by comparing its origin to this one, so an
41
+ // unresolved value does not fail anything — it quietly moves the whole CMS
42
+ // into "other hosts" and the report reads as a build that never called the CMS.
43
+ if (!cmsOrigin) {
44
+ console.warn(
45
+ 'API_BASE_URL did not resolve, so no request can be recognised as the CMS and the report will say there were none. Check .env / .env.local, or set PROFILE_CMS_ORIGIN.\n',
46
+ );
47
+ }
48
+
49
+ // A caller that needs to read this log itself — the interference harness joins
50
+ // it against its own polls — names it up front. Otherwise it is named here.
51
+ const logPath = resolve(
52
+ process.env.PROFILE_FETCH_LOG || join(LOG_DIR, `build-${stamp()}.ndjson`),
53
+ );
54
+
55
+ mkdirSync(dirname(logPath), { recursive: true });
56
+
57
+ console.log(`Profiling this build into ${logPath}\n`);
58
+
59
+ // Astro 5 shipped its CLI as `astro/astro.js`; Astro 7 ships it as
60
+ // `astro/bin/astro.mjs` and nothing at the old path, so try both rather than
61
+ // tying the profiler to one major.
62
+ const astro = [
63
+ resolve('node_modules', 'astro', 'bin', 'astro.mjs'),
64
+ resolve('node_modules', 'astro', 'astro.js'),
65
+ ].find((candidate) => existsSync(candidate));
66
+
67
+ if (!astro) {
68
+ console.error(
69
+ 'Cannot find the Astro CLI under node_modules/astro. Run `pnpm install` first.',
70
+ );
71
+ process.exit(1);
72
+ }
73
+
74
+ const build = spawn(
75
+ process.execPath,
76
+ [astro, 'build', ...process.argv.slice(2)],
77
+ {
78
+ stdio: 'inherit',
79
+ env: {
80
+ ...process.env,
81
+ PROFILE_FETCH_LOG: logPath,
82
+ PROFILE_CMS_ORIGIN: cmsOrigin,
83
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, `--import ${preload}`]
84
+ .filter(Boolean)
85
+ .join(' '),
86
+ },
87
+ },
88
+ );
89
+
90
+ build.on('exit', (code, signal) => {
91
+ // A build that failed part-way still logged everything it managed to ask
92
+ // for, and that is often the interesting part, so report either way.
93
+ if (existsSync(logPath)) {
94
+ console.log(`\nFetch log: ${logPath}`);
95
+ console.log(`Report: ${writeReport(logPath)}`);
96
+ } else {
97
+ console.error(`\nNo fetch log was written to ${logPath}.`);
98
+ }
99
+
100
+ process.exit(signal ? 1 : (code ?? 0));
101
+ });
102
+
103
+ /** `2026-09-03T14-22-05`: sorts chronologically, survives a file system. */
104
+ function stamp() {
105
+ return new Date().toISOString().slice(0, 19).replaceAll(':', '-');
106
+ }