@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,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logs every HTTP request a build makes, one NDJSON line per request.
|
|
3
|
+
*
|
|
4
|
+
* Loaded into a build process with `--import`, before anything else runs, so
|
|
5
|
+
* the wrapper is in place for config-time fetches (`astro.config.mjs` resolves
|
|
6
|
+
* redirects, project settings and the favicon before the build starts) as well
|
|
7
|
+
* as for the ones pages make while they render. Astro builds in a single Node
|
|
8
|
+
* process, so one wrapper sees both.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here is wired into `pnpm build`. `build.mjs` beside this file is what
|
|
11
|
+
* loads it, and it is what sets the two variables read below:
|
|
12
|
+
*
|
|
13
|
+
* - `PROFILE_FETCH_LOG` where to append. Defaults to `.astro/profile/fetch.ndjson`.
|
|
14
|
+
* - `PROFILE_CMS_ORIGIN` the origin that counts as the CMS, so a request can
|
|
15
|
+
* be told apart from a font or a media file. Falls back
|
|
16
|
+
* to `API_BASE_URL` if that happens to be exported.
|
|
17
|
+
*
|
|
18
|
+
* Requests are logged when their body finishes arriving, not when their headers
|
|
19
|
+
* do, so the file is ordered by completion and a request still in flight when
|
|
20
|
+
* the process exits never gets a line. The `end` meta record counts those.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
25
|
+
import { dirname, join, resolve } from 'node:path';
|
|
26
|
+
|
|
27
|
+
/** A `where` clause is unbounded; keep the log readable and hash the rest. */
|
|
28
|
+
const WHERE_MAX = 300;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The CMS's own id for the request it just served.
|
|
32
|
+
*
|
|
33
|
+
* A deployment running with `CMS_PROFILE=1` logs one `cms.profile.request`
|
|
34
|
+
* line per request and echoes that line's `id` back on this header. Recording
|
|
35
|
+
* it is the only way to join a request logged here to the commands and hooks
|
|
36
|
+
* it caused on the other side; without it the two halves are two lists of
|
|
37
|
+
* timings that happen to be about the same build. The name is
|
|
38
|
+
* `PROFILE_ID_HEADER` in the CMS's `src/profiling/requestProfile.ts`.
|
|
39
|
+
*
|
|
40
|
+
* It is absent whenever the target is not profiling, which is the normal case,
|
|
41
|
+
* so a null here is information rather than a fault.
|
|
42
|
+
*/
|
|
43
|
+
const PROFILE_ID_HEADER = 'x-cms-profile-id';
|
|
44
|
+
|
|
45
|
+
/** Above this declared size, trust `content-length` rather than buffer a copy. */
|
|
46
|
+
const CLONE_LIMIT = 8 * 1024 * 1024;
|
|
47
|
+
|
|
48
|
+
const logPath = resolve(
|
|
49
|
+
process.env.PROFILE_FETCH_LOG || join('.astro', 'profile', 'fetch.ndjson'),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const cmsOrigin = originOf(
|
|
53
|
+
process.env.PROFILE_CMS_ORIGIN || process.env.API_BASE_URL,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const startedAt = Date.now();
|
|
57
|
+
let requests = 0;
|
|
58
|
+
let pending = 0;
|
|
59
|
+
|
|
60
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
61
|
+
|
|
62
|
+
writeLine({
|
|
63
|
+
kind: 'meta',
|
|
64
|
+
event: 'start',
|
|
65
|
+
pid: process.pid,
|
|
66
|
+
startedAt,
|
|
67
|
+
cmsOrigin,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const original = globalThis.fetch;
|
|
71
|
+
|
|
72
|
+
globalThis.fetch = function profilingFetch(input, init) {
|
|
73
|
+
const seq = ++requests;
|
|
74
|
+
const requestStartedAt = Date.now();
|
|
75
|
+
const mark = performance.now();
|
|
76
|
+
const shape = describeRequest(urlOf(input), methodOf(input, init));
|
|
77
|
+
|
|
78
|
+
pending += 1;
|
|
79
|
+
|
|
80
|
+
return original(input, init).then(
|
|
81
|
+
(response) => {
|
|
82
|
+
const ttfbMs = round(performance.now() - mark);
|
|
83
|
+
|
|
84
|
+
countBytes(response).then((bytes) => {
|
|
85
|
+
pending -= 1;
|
|
86
|
+
writeLine({
|
|
87
|
+
kind: 'request',
|
|
88
|
+
seq,
|
|
89
|
+
...shape,
|
|
90
|
+
status: response.status,
|
|
91
|
+
profileId: response.headers.get(PROFILE_ID_HEADER),
|
|
92
|
+
startedAt: requestStartedAt,
|
|
93
|
+
endedAt: Date.now(),
|
|
94
|
+
ttfbMs,
|
|
95
|
+
durationMs: round(performance.now() - mark),
|
|
96
|
+
bytes,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return response;
|
|
101
|
+
},
|
|
102
|
+
(error) => {
|
|
103
|
+
pending -= 1;
|
|
104
|
+
writeLine({
|
|
105
|
+
kind: 'request',
|
|
106
|
+
seq,
|
|
107
|
+
...shape,
|
|
108
|
+
status: null,
|
|
109
|
+
profileId: null,
|
|
110
|
+
error: String(error?.message ?? error),
|
|
111
|
+
startedAt: requestStartedAt,
|
|
112
|
+
endedAt: Date.now(),
|
|
113
|
+
ttfbMs: null,
|
|
114
|
+
durationMs: round(performance.now() - mark),
|
|
115
|
+
bytes: null,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
throw error;
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
process.on('exit', () => {
|
|
124
|
+
const endedAt = Date.now();
|
|
125
|
+
|
|
126
|
+
writeLine({
|
|
127
|
+
kind: 'meta',
|
|
128
|
+
event: 'end',
|
|
129
|
+
pid: process.pid,
|
|
130
|
+
endedAt,
|
|
131
|
+
wallMs: endedAt - startedAt,
|
|
132
|
+
requests,
|
|
133
|
+
pending,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
function writeLine(record) {
|
|
138
|
+
try {
|
|
139
|
+
appendFileSync(logPath, `${JSON.stringify(record)}\n`);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
// A profiler must never be the reason a build fails.
|
|
142
|
+
process.emitWarning(
|
|
143
|
+
`fetch-log: could not append to ${logPath}: ${error.message}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Everything the report needs to group a request, read off its URL.
|
|
150
|
+
*
|
|
151
|
+
* A URL that does not parse still gets a line — with `raw` filled in — because
|
|
152
|
+
* a request the profiler cannot describe is exactly the kind worth seeing.
|
|
153
|
+
*/
|
|
154
|
+
function describeRequest(rawUrl, method) {
|
|
155
|
+
let url;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
url = new URL(rawUrl);
|
|
159
|
+
} catch {
|
|
160
|
+
return { cms: false, method, host: null, path: null, raw: String(rawUrl) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const params = url.searchParams;
|
|
164
|
+
const where = [];
|
|
165
|
+
|
|
166
|
+
for (const [key, value] of params) {
|
|
167
|
+
if (key === 'where' || key.startsWith('where[')) {
|
|
168
|
+
where.push(`${key}=${value}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const whereQuery = where.join('&');
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
cms: cmsOrigin !== null && url.origin === cmsOrigin,
|
|
176
|
+
method,
|
|
177
|
+
host: url.host,
|
|
178
|
+
path: url.pathname,
|
|
179
|
+
collection: collectionOf(url.pathname),
|
|
180
|
+
locale: params.get('locale'),
|
|
181
|
+
fallbackLocale: params.get('fallback-locale'),
|
|
182
|
+
depth: numberOf(params.get('depth')),
|
|
183
|
+
limit: numberOf(params.get('limit')),
|
|
184
|
+
page: numberOf(params.get('page')),
|
|
185
|
+
sort: params.get('sort'),
|
|
186
|
+
where: truncate(whereQuery, WHERE_MAX),
|
|
187
|
+
// The truncated `where` is for reading; this is what identifies it. Two
|
|
188
|
+
// queries that differ only past the cut would otherwise look repeated.
|
|
189
|
+
whereHash: whereQuery ? hash(whereQuery) : null,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* `/api/posts` → `posts`, `/api/globals/header` → `globals/header`.
|
|
195
|
+
* Anything outside `/api/` has no collection.
|
|
196
|
+
*/
|
|
197
|
+
function collectionOf(pathname) {
|
|
198
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
199
|
+
|
|
200
|
+
if (segments[0] !== 'api' || segments.length < 2) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (segments[1] === 'globals') {
|
|
205
|
+
return segments.slice(1, 3).join('/');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return segments[1];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The response size, without taking the body away from the caller.
|
|
213
|
+
*
|
|
214
|
+
* `clone()` tees the stream, so the copy can be drained here while whoever
|
|
215
|
+
* called `fetch` reads the original. That does hold both in memory at once,
|
|
216
|
+
* which is fine for JSON and not for a 40 MB video — hence the size cap, above
|
|
217
|
+
* which the declared length is good enough.
|
|
218
|
+
*/
|
|
219
|
+
function countBytes(response) {
|
|
220
|
+
const header = response.headers.get('content-length');
|
|
221
|
+
const declared = header === null ? null : Number(header);
|
|
222
|
+
const fallback = Number.isFinite(declared) ? declared : null;
|
|
223
|
+
|
|
224
|
+
if (!response.body) {
|
|
225
|
+
return Promise.resolve(fallback ?? 0);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (fallback !== null && fallback > CLONE_LIMIT) {
|
|
229
|
+
return Promise.resolve(fallback);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let clone;
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
clone = response.clone();
|
|
236
|
+
} catch {
|
|
237
|
+
return Promise.resolve(fallback);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return clone.arrayBuffer().then(
|
|
241
|
+
(buffer) => buffer.byteLength,
|
|
242
|
+
() => fallback,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function urlOf(input) {
|
|
247
|
+
if (typeof input === 'string') {
|
|
248
|
+
return input;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (input instanceof URL) {
|
|
252
|
+
return input.href;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return input?.url ?? String(input);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function methodOf(input, init) {
|
|
259
|
+
return (init?.method ?? input?.method ?? 'GET').toUpperCase();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function originOf(value) {
|
|
263
|
+
if (!value) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
return new URL(value).origin;
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function numberOf(value) {
|
|
275
|
+
if (value === null) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const parsed = Number(value);
|
|
280
|
+
|
|
281
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function truncate(value, max) {
|
|
285
|
+
if (!value) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function hash(value) {
|
|
293
|
+
return createHash('sha1').update(value).digest('hex').slice(0, 8);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function round(value) {
|
|
297
|
+
return Math.round(value * 10) / 10;
|
|
298
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown and units for both profile reports.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `summarize.mjs` when the interference harness needed the same
|
|
5
|
+
* tables and the same way of printing a duration: two reports that disagree
|
|
6
|
+
* about what "1.4 s" means are two reports that cannot be read side by side.
|
|
7
|
+
*
|
|
8
|
+
* Pure. The numbers that go through here are computed in `measure.mjs`;
|
|
9
|
+
* anything that knows what a fetch log or a poll log means belongs in the
|
|
10
|
+
* summarizer that owns it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const BAR_WIDTH = 24;
|
|
14
|
+
|
|
15
|
+
export function bullets(entries) {
|
|
16
|
+
return entries
|
|
17
|
+
.filter(([, value]) => value !== null && value !== undefined)
|
|
18
|
+
.map(([label, value]) => `- ${label}: ${value}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function table(headers, rows) {
|
|
22
|
+
return [
|
|
23
|
+
`| ${headers.join(' | ')} |`,
|
|
24
|
+
`| ${headers.map(() => '---').join(' | ')} |`,
|
|
25
|
+
...rows.map((cells) => `| ${cells.join(' | ')} |`),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function bar(value, peak, width = BAR_WIDTH) {
|
|
30
|
+
if (!peak || value <= 0) {
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return '#'.repeat(Math.max(1, Math.round((value / peak) * width)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function count(value) {
|
|
38
|
+
return value.toLocaleString('en-US');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function duration(ms) {
|
|
42
|
+
if (ms === null || ms === undefined) {
|
|
43
|
+
return '-';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (ms >= 10_000) {
|
|
47
|
+
return `${(ms / 1000).toFixed(1)} s`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return `${Math.round(ms).toLocaleString('en-US')} ms`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Seconds from the start of a run, for a timeline column.
|
|
55
|
+
*
|
|
56
|
+
* A build's timeline is tens of seconds long and reads best in tenths. An
|
|
57
|
+
* interference run is minutes long, where "115.0s" is a number a reader has to
|
|
58
|
+
* divide, so that report uses `offsetClock` instead. Both live here so the one
|
|
59
|
+
* place the two reports deliberately disagree is visible.
|
|
60
|
+
*/
|
|
61
|
+
export function offsetSeconds(ms) {
|
|
62
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `m:ss` from the start of a run, for a timeline longer than a minute. */
|
|
66
|
+
export function offsetClock(ms) {
|
|
67
|
+
const seconds = Math.round(ms / 1000);
|
|
68
|
+
|
|
69
|
+
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function share(part, whole) {
|
|
73
|
+
if (!whole) {
|
|
74
|
+
return 'n/a';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return `${Math.round((part / whole) * 100)}%`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function size(bytes) {
|
|
81
|
+
if (bytes >= 1024 * 1024) {
|
|
82
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function escapePipes(value) {
|
|
89
|
+
return value.replaceAll('|', '\\|');
|
|
90
|
+
}
|