@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
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
|
+
}
|